mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(ai-builder): Allow AIA agent builder to publish and unpublish first-class agents (#34368)
This commit is contained in:
@@ -36,11 +36,18 @@ export const BUILDER_NOT_CONFIGURED_CODE = 'BUILDER_NOT_CONFIGURED' as const;
|
||||
export const BUILDER_CHECKPOINT_UNAVAILABLE_CODE = 'BUILDER_CHECKPOINT_UNAVAILABLE' as const;
|
||||
|
||||
/**
|
||||
* The only two agent-builder tools that mutate the agent config. Mirrors
|
||||
* `BUILDER_TOOLS.WRITE_CONFIG` / `PATCH_CONFIG` in
|
||||
* Agent-builder tools whose success should set `configUpdated` on `build-agent`
|
||||
* (refresh the agent artifact preview) and emit "Builder modified agent"
|
||||
* telemetry. Includes config writers and publish lifecycle tools. Values must
|
||||
* match `BUILDER_TOOLS` in
|
||||
* `packages/cli/src/modules/agents/builder/builder-tool-names.ts`.
|
||||
*/
|
||||
export const CONFIG_MUTATION_TOOL_NAMES = ['write_config', 'patch_config'] as const;
|
||||
export const CONFIG_MUTATION_TOOL_NAMES = [
|
||||
'write_config',
|
||||
'patch_config',
|
||||
'publish_agent',
|
||||
'unpublish_agent',
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ask_questions
|
||||
|
||||
@@ -685,8 +685,11 @@ Delegates agent building to the agents-module builder chat
|
||||
turn per call. Registered in `createOrchestrationTools` only when the host
|
||||
provides `builderDelegate` (agents module active). The builder's own prompt
|
||||
and tools drive the build, including its interactive tools (`ask_questions`,
|
||||
`ask_credential`, `ask_embedding_credential`, `configure_channel`) — the
|
||||
sub-agent session no longer excludes them. Builder session state is keyed to
|
||||
`ask_credential`, `ask_embedding_credential`, `configure_channel`) and
|
||||
lifecycle tools (`publish_agent`, `unpublish_agent`) on the bound target agent —
|
||||
the sub-agent session no longer excludes them. Forward publish/unpublish/
|
||||
activate/make-live intents to `build-agent`; never tell the user to open the
|
||||
agent editor and click Publish. Builder session state is keyed to
|
||||
instance-AI-scoped threads (`ia-builder:<threadId>:<agentId>`) and never
|
||||
appears in the agents-module builder UI.
|
||||
|
||||
|
||||
@@ -243,13 +243,16 @@ describe('getSystemPrompt', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('routes agent listing and agent switching guidance when agents are enabled', async () => {
|
||||
it('routes agent listing, switching, and publish guidance when agents are enabled', async () => {
|
||||
const prompt = await getSystemPromptWithEnabledModules('agents,instance-ai');
|
||||
|
||||
expect(prompt).toContain('Each distinct agent the user asks for is its own build target');
|
||||
expect(prompt).toContain('calls without either continue the most recent target');
|
||||
expect(prompt).toContain('call `agents(action="list")` directly');
|
||||
expect(prompt).toContain('find its id via `agents(action="list")` and pass it as `agentId`');
|
||||
expect(prompt).toContain('forward that intent to `build-agent`');
|
||||
expect(prompt).toContain('never tell the user to open the agent editor and click Publish');
|
||||
expect(prompt).toContain('publishes, and unpublishes n8n **Agent** artifacts');
|
||||
});
|
||||
|
||||
it('omits the build-agent fence and intent gate when the agents module is disabled', async () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ const INTENT_HINT = isAgentFeatureEnabled()
|
||||
: '';
|
||||
|
||||
const AGENT_BUILD_ROUTE = isAgentFeatureEnabled()
|
||||
? '\n- **Agent build or edit** (agent-anchored per the intent gate: chat or session interaction, cross-session memory, proactive or long-running operation, learning from feedback) → call `build-agent` right away and let the builder gather requirements — do not run your own requirement-gathering round first. The builder cannot see this conversation: its only knowledge is what you pass in `message`, so include ALL requirements, constraints, and answers already gathered in the first `message` (plus `name` for a new agent or `agentId` for an existing one, and `workflowContext` for workflows built this session). Each distinct agent the user asks for is its own build target — pass `name` or `agentId` again to create or switch agents (prefer the `agentId` returned by earlier build-agent results when switching back; a name used earlier in this conversation also switches back to that agent rather than duplicating it); calls without either continue the most recent target. When the user message includes an agent-preview transcript / `<agent-preview-context>`, treat it as an edit of that agent: call `build-agent` with the given `agentId` and put the concrete behavioral findings (failures, bad tool use, wrong answers) into `message` — do not ask the user to re-describe what already appears in the transcript. While a build is in progress, forward each user follow-up to `build-agent` near-verbatim and relay its `builderReply` back. When the builder needs user input it asks directly through interactive cards in this chat — do not re-ask those questions yourself; the tool call resumes with the user’s answer and returns the builder’s reply. Actions the agent should invoke as reusable tools are built as workflows via `workflow-builder` first, then attached via `workflowContext`. For questions about existing agents ("what agents do I have?") call `agents(action="list")` directly — no intent gate; and to edit an agent NOT built in this conversation, find its id via `agents(action="list")` and pass it as `agentId`.'
|
||||
? '\n- **Agent build or edit** (agent-anchored per the intent gate: chat or session interaction, cross-session memory, proactive or long-running operation, learning from feedback) → call `build-agent` right away and let the builder gather requirements — do not run your own requirement-gathering round first. The builder cannot see this conversation: its only knowledge is what you pass in `message`, so include ALL requirements, constraints, and answers already gathered in the first `message` (plus `name` for a new agent or `agentId` for an existing one, and `workflowContext` for workflows built this session). Each distinct agent the user asks for is its own build target — pass `name` or `agentId` again to create or switch agents (prefer the `agentId` returned by earlier build-agent results when switching back; a name used earlier in this conversation also switches back to that agent rather than duplicating it); calls without either continue the most recent target. When the user message includes an agent-preview transcript / `<agent-preview-context>`, treat it as an edit of that agent: call `build-agent` with the given `agentId` and put the concrete behavioral findings (failures, bad tool use, wrong answers) into `message` — do not ask the user to re-describe what already appears in the transcript. While a build is in progress, forward each user follow-up to `build-agent` near-verbatim and relay its `builderReply` back. When the builder needs user input it asks directly through interactive cards in this chat — do not re-ask those questions yourself; the tool call resumes with the user’s answer and returns the builder’s reply. When the user asks to publish, unpublish, activate, or make an agent live/usable, forward that intent to `build-agent` (the builder calls `publish_agent` / `unpublish_agent`) — never tell the user to open the agent editor and click Publish. Actions the agent should invoke as reusable tools are built as workflows via `workflow-builder` first, then attached via `workflowContext`. For questions about existing agents ("what agents do I have?") call `agents(action="list")` directly — no intent gate; and to edit an agent NOT built in this conversation, find its id via `agents(action="list")` and pass it as `agentId`.'
|
||||
: '';
|
||||
|
||||
const WORKFLOW_ROUTE_GATE_REF = isAgentFeatureEnabled()
|
||||
@@ -53,7 +53,7 @@ const WORKFLOW_ROUTE_GATE_REF = isAgentFeatureEnabled()
|
||||
: '';
|
||||
|
||||
const AGENT_BUILDER_FENCE = isAgentFeatureEnabled()
|
||||
? 'The `build-agent` tool builds and edits n8n **Agent** artifacts only (instructions, model, tools, skills, tasks, integrations, sub-agents) by delegating to the agents-module builder. It is only for that purpose. When you have classified the request as workflow-anchored (via the intent gate above), stay on the `workflow-builder` path and do not call `build-agent` at all — not to inspect nodes, not to list workflows, and not to compile custom tools. If a workflow build seems to need a utility tool the workspace does not provide, ask the user or use a placeholder; do not route around that by delegating to `build-agent`.'
|
||||
? 'The `build-agent` tool builds, edits, publishes, and unpublishes n8n **Agent** artifacts (instructions, model, tools, skills, tasks, integrations, sub-agents, and publish lifecycle) by delegating to the agents-module builder. It is only for that purpose. When you have classified the request as workflow-anchored (via the intent gate above), stay on the `workflow-builder` path and do not call `build-agent` at all — not to inspect nodes, not to list workflows, and not to compile custom tools. If a workflow build seems to need a utility tool the workspace does not provide, ask the user or use a placeholder; do not route around that by delegating to `build-agent`.'
|
||||
: '';
|
||||
|
||||
function getInstanceInfoSection(webhookBaseUrl: string, formBaseUrl: string): string {
|
||||
|
||||
+3
-3
@@ -178,9 +178,9 @@ function toTurnStream(result: StreamResult): BuilderTurnStream {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the agent-builder sub-agent: `write_config` (a plain config-mutation
|
||||
* tool — its name drives `configUpdated` via `CONFIG_MUTATION_TOOL_NAMES`)
|
||||
* and `ask_questions` (an interruptible tool using the real shared contract
|
||||
* Build the agent-builder sub-agent: `write_config` (a mutation tool whose
|
||||
* name drives `configUpdated` via `CONFIG_MUTATION_TOOL_NAMES`) and
|
||||
* `ask_questions` (an interruptible tool using the real shared contract
|
||||
* from `@n8n/api-types`, mirroring the cli's own `ask_questions` tool). On
|
||||
* resume, `onResume` records the exact `ctx.resumeData` the SDK handed back
|
||||
* after validating it against `questionsResumeSchema` — the central
|
||||
|
||||
+1
-1
@@ -500,7 +500,7 @@ describe('build-agent tool', () => {
|
||||
});
|
||||
|
||||
describe('configUpdated', () => {
|
||||
it.each(['write_config', 'patch_config'])(
|
||||
it.each(['write_config', 'patch_config', 'publish_agent', 'unpublish_agent'])(
|
||||
'is true when the work summary has a succeeded %s call',
|
||||
async (toolName) => {
|
||||
const { context, delegate } = makeContext();
|
||||
|
||||
@@ -647,7 +647,10 @@ export function createBuildAgentTool(context: OrchestrationContext) {
|
||||
'without either keep editing the current agent. To build ANOTHER agent in the same ' +
|
||||
'conversation, pass its `name` or `agentId` — a name matching an agent already built ' +
|
||||
'in this conversation switches back to it; an unmatched name creates a new agent and ' +
|
||||
'switches the active target. When the builder needs user input (a choice, a ' +
|
||||
'switches the active target. The builder can also publish or unpublish the target ' +
|
||||
'agent when the user asks to publish, activate, make it live/usable, or unpublish — ' +
|
||||
'forward that intent in `message`; never tell the user to open the agent editor and ' +
|
||||
'click Publish. When the builder needs user input (a choice, a ' +
|
||||
'credential, or a chat channel), it surfaces automatically as an interactive card in ' +
|
||||
'this chat — do not relay those questions yourself; this tool call resumes with the ' +
|
||||
'user’s answer and returns the builder’s reply. Returns the builder’s reply, the ' +
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { DynamicNodeParametersService } from '@/services/dynamic-node-param
|
||||
import type { AgentConfigService } from '../agent-config.service';
|
||||
import type { AgentCustomToolsService } from '../agent-custom-tools.service';
|
||||
import type { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service';
|
||||
import type { AgentPublishService } from '../agent-publish.service';
|
||||
import type { AgentSkillsService } from '../agent-skills.service';
|
||||
import type { AgentTaskService } from '../agent-task.service';
|
||||
import type { AgentsToolsService } from '../agents-tools.service';
|
||||
@@ -38,6 +39,7 @@ import { BUILDER_TOOLS } from '../builder/builder-tool-names';
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
|
||||
import type { AiService } from '@/services/ai.service';
|
||||
import * as checkAccess from '@/permissions.ee/check-access';
|
||||
|
||||
const ctx = {
|
||||
resumeData: undefined,
|
||||
@@ -73,6 +75,7 @@ function makeService() {
|
||||
const credentialTypes = mock<CredentialTypes>();
|
||||
const mcpRegistryService = mock<McpRegistryService>();
|
||||
const agentTaskService = mock<AgentTaskService>();
|
||||
const agentPublishService = mock<AgentPublishService>();
|
||||
const aiService = mock<AiService>();
|
||||
aiService.isProxyEnabled.mockReturnValue(false);
|
||||
const dynamicNodeParametersService = mock<DynamicNodeParametersService>();
|
||||
@@ -101,6 +104,7 @@ function makeService() {
|
||||
mock(),
|
||||
credentialTypes,
|
||||
agentTaskService,
|
||||
agentPublishService,
|
||||
aiService,
|
||||
outboundHttp,
|
||||
dynamicNodeParametersService,
|
||||
@@ -115,6 +119,7 @@ function makeService() {
|
||||
secureRuntime,
|
||||
attachableWorkflowsService,
|
||||
agentTaskService,
|
||||
agentPublishService,
|
||||
nodeTypes,
|
||||
outboundHttp,
|
||||
};
|
||||
@@ -219,6 +224,10 @@ describe('AgentsBuilderToolsService', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('JSON config tools', () => {
|
||||
function getJsonTool(service: AgentsBuilderToolsService, name: string) {
|
||||
return service
|
||||
@@ -235,6 +244,16 @@ describe('AgentsBuilderToolsService', () => {
|
||||
expect(toolNames).toContain(BUILDER_TOOLS.SEARCH_MCP_SERVERS);
|
||||
});
|
||||
|
||||
it('registers publish and unpublish tools in the builder toolset', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
const toolNames = service
|
||||
.getTools(agentId, projectId, credentialProvider, user)
|
||||
.json.map((tool) => tool.name);
|
||||
expect(toolNames).toContain(BUILDER_TOOLS.PUBLISH_AGENT);
|
||||
expect(toolNames).toContain(BUILDER_TOOLS.UNPUBLISH_AGENT);
|
||||
});
|
||||
|
||||
it('builds verify_mcp_server with OutboundHttp SSRF protection enabled', () => {
|
||||
const { service, outboundHttp } = makeService();
|
||||
|
||||
@@ -1391,4 +1410,139 @@ describe('AgentsBuilderToolsService', () => {
|
||||
expect(result).toEqual({ ok: false, errors: [{ message: 'Agent "agent-1" not found' }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish_agent / unpublish_agent tools', () => {
|
||||
function getPublishTool(service: AgentsBuilderToolsService) {
|
||||
return service
|
||||
.getTools(agentId, projectId, credentialProvider, user)
|
||||
.json.find((tool) => tool.name === BUILDER_TOOLS.PUBLISH_AGENT)!;
|
||||
}
|
||||
|
||||
function getUnpublishTool(service: AgentsBuilderToolsService) {
|
||||
return service
|
||||
.getTools(agentId, projectId, credentialProvider, user)
|
||||
.json.find((tool) => tool.name === BUILDER_TOOLS.UNPUBLISH_AGENT)!;
|
||||
}
|
||||
|
||||
it('publishes the bound agent draft when the user has agent:publish', async () => {
|
||||
const { service, agentPublishService } = makeService();
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
|
||||
agentPublishService.publishAgent.mockResolvedValue({
|
||||
activeVersionId: 'v-active',
|
||||
versionId: 'v-active',
|
||||
} as Agent);
|
||||
|
||||
const result = await getPublishTool(service).handler!({}, ctx);
|
||||
|
||||
expect(checkAccess.userHasScopes).toHaveBeenCalledWith(user, ['agent:publish'], false, {
|
||||
projectId,
|
||||
});
|
||||
expect(agentPublishService.publishAgent).toHaveBeenCalledWith(
|
||||
agentId,
|
||||
projectId,
|
||||
user,
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
agentId,
|
||||
activeVersionId: 'v-active',
|
||||
versionId: 'v-active',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an optional versionId to publishAgent', async () => {
|
||||
const { service, agentPublishService } = makeService();
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
|
||||
agentPublishService.publishAgent.mockResolvedValue({
|
||||
activeVersionId: 'v-history',
|
||||
versionId: 'v-draft',
|
||||
} as Agent);
|
||||
|
||||
const result = await getPublishTool(service).handler!({ versionId: 'v-history' }, ctx);
|
||||
|
||||
expect(agentPublishService.publishAgent).toHaveBeenCalledWith(
|
||||
agentId,
|
||||
projectId,
|
||||
user,
|
||||
'v-history',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
agentId,
|
||||
activeVersionId: 'v-history',
|
||||
versionId: 'v-draft',
|
||||
});
|
||||
});
|
||||
|
||||
it('denies publish when the user lacks agent:publish', async () => {
|
||||
const { service, agentPublishService } = makeService();
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(false);
|
||||
|
||||
const result = await getPublishTool(service).handler!({}, ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: [{ message: 'You do not have permission to publish agents in this project.' }],
|
||||
});
|
||||
expect(agentPublishService.publishAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces publish service errors to the model', async () => {
|
||||
const { service, agentPublishService } = makeService();
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
|
||||
agentPublishService.publishAgent.mockRejectedValue(
|
||||
new Error('Cannot publish agent with missing custom tools: my_tool'),
|
||||
);
|
||||
|
||||
const result = await getPublishTool(service).handler!({}, ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: [{ message: 'Cannot publish agent with missing custom tools: my_tool' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('unpublishes the bound agent when the user has agent:unpublish', async () => {
|
||||
const { service, agentPublishService } = makeService();
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
|
||||
agentPublishService.unpublishAgent.mockResolvedValue({
|
||||
activeVersionId: null,
|
||||
} as Agent);
|
||||
|
||||
const result = await getUnpublishTool(service).handler!({}, ctx);
|
||||
|
||||
expect(checkAccess.userHasScopes).toHaveBeenCalledWith(user, ['agent:unpublish'], false, {
|
||||
projectId,
|
||||
});
|
||||
expect(agentPublishService.unpublishAgent).toHaveBeenCalledWith(agentId, projectId);
|
||||
expect(result).toEqual({ ok: true, agentId, activeVersionId: null });
|
||||
});
|
||||
|
||||
it('denies unpublish when the user lacks agent:unpublish', async () => {
|
||||
const { service, agentPublishService } = makeService();
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(false);
|
||||
|
||||
const result = await getUnpublishTool(service).handler!({}, ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: [{ message: 'You do not have permission to unpublish agents in this project.' }],
|
||||
});
|
||||
expect(agentPublishService.unpublishAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces unpublish service errors to the model', async () => {
|
||||
const { service, agentPublishService } = makeService();
|
||||
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
|
||||
agentPublishService.unpublishAgent.mockRejectedValue(new Error('Agent "agent-1" not found'));
|
||||
|
||||
const result = await getUnpublishTool(service).handler!({}, ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: [{ message: 'Agent "agent-1" not found' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,6 @@ a \`baseConfigHash\` for a later write. If \`write_config\` or
|
||||
using the \`config\` and \`configHash\` it returns. Call \`read_config\`
|
||||
again immediately before every later mutation and before any later
|
||||
inspection of the config.`;
|
||||
|
||||
export const RESPONSE_STYLE_SECTION = `\
|
||||
## Response Style
|
||||
|
||||
@@ -113,7 +112,11 @@ export const WORKFLOW_SECTION = `\
|
||||
6. Follow Config Freshness immediately before every config mutation.
|
||||
7. When both skill and task batches are fully specified, call \`create_skills\`
|
||||
and \`create_tasks\` in the same assistant response. Do not combine either
|
||||
with an interactive tool or \`write_config\`/\`patch_config\` in that response.`;
|
||||
with an interactive tool or \`write_config\`/\`patch_config\` in that response.
|
||||
8. When the user asks to publish, activate, or make the agent live/usable, call
|
||||
\`publish_agent\`. Never tell them to click Publish in the editor. Do not
|
||||
auto-publish without that intent. Use \`unpublish_agent\` when they ask to
|
||||
unpublish.`;
|
||||
|
||||
export const FEW_SHOT_FLOWS_SECTION = `\
|
||||
## Example flows
|
||||
@@ -163,7 +166,12 @@ export const FEW_SHOT_FLOWS_SECTION = `\
|
||||
### Ambiguous request: "Make it post somewhere"
|
||||
1. \`ask_questions(...)\` with the known destination choices.
|
||||
2. Continue the chosen branch with node discovery, credentials, and config
|
||||
mutation.`;
|
||||
mutation.
|
||||
|
||||
### Publish after build: "Publish it" / "Make it live"
|
||||
1. Finish any pending config mutations.
|
||||
2. \`publish_agent()\`.
|
||||
3. Confirm the agent is live; do not send the user to the editor Publish button.`;
|
||||
|
||||
export interface BuilderPromptContext {
|
||||
agentPreviewPath: string;
|
||||
|
||||
@@ -33,6 +33,7 @@ import { CredentialTypes } from '@/credential-types';
|
||||
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { OauthService } from '@/oauth/oauth.service';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
import { AiService } from '@/services/ai.service';
|
||||
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
|
||||
import { createAiMcpFetch } from '@/utils/ai-proxy-fetch';
|
||||
@@ -40,6 +41,7 @@ import { createAiMcpFetch } from '@/utils/ai-proxy-fetch';
|
||||
import { AgentConfigService } from '../agent-config.service';
|
||||
import { AgentCustomToolsService } from '../agent-custom-tools.service';
|
||||
import { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service';
|
||||
import { AgentPublishService } from '../agent-publish.service';
|
||||
import { AgentSkillsService } from '../agent-skills.service';
|
||||
import { AgentTaskService } from '../agent-task.service';
|
||||
import { AgentsToolsService } from '../agents-tools.service';
|
||||
@@ -180,6 +182,7 @@ export class AgentsBuilderToolsService {
|
||||
private readonly oauthService: OauthService,
|
||||
private readonly credentialTypes: CredentialTypes,
|
||||
private readonly agentTaskService: AgentTaskService,
|
||||
private readonly agentPublishService: AgentPublishService,
|
||||
private readonly aiService: AiService,
|
||||
private readonly outboundHttp: OutboundHttp,
|
||||
private readonly dynamicNodeParametersService: DynamicNodeParametersService,
|
||||
@@ -465,6 +468,82 @@ export class AgentsBuilderToolsService {
|
||||
})
|
||||
.build();
|
||||
|
||||
const publishAgentTool = new Tool(BUILDER_TOOLS.PUBLISH_AGENT)
|
||||
.description(
|
||||
'Publish this target agent so it becomes live: integrations sync and scheduled tasks start running. ' +
|
||||
'Idempotent when the draft is already the active published version. Pass optional `versionId` to ' +
|
||||
'activate an existing history row instead of publishing the current draft. Call only when the user ' +
|
||||
'asks to publish, activate, or make the agent live/usable — never tell them to click Publish in the editor. ' +
|
||||
'Returns { ok: true, agentId, activeVersionId, versionId } or { ok: false, errors }.',
|
||||
)
|
||||
.input(
|
||||
z.object({
|
||||
versionId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional history version ID to activate. Omit to publish the current draft.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ versionId }: { versionId?: string }) => {
|
||||
if (!(await userHasScopes(user, ['agent:publish'], false, { projectId }))) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ message: 'You do not have permission to publish agents in this project.' }],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const agent = await this.agentPublishService.publishAgent(
|
||||
agentId,
|
||||
projectId,
|
||||
user,
|
||||
versionId,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
agentId,
|
||||
activeVersionId: agent.activeVersionId,
|
||||
versionId: agent.versionId,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ message: e instanceof Error ? e.message : String(e) }],
|
||||
};
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
const unpublishAgentTool = new Tool(BUILDER_TOOLS.UNPUBLISH_AGENT)
|
||||
.description(
|
||||
'Unpublish this target agent: clears the live version while preserving the draft, disconnects chat ' +
|
||||
'integrations, and stops scheduled tasks. Call when the user asks to unpublish or take the agent offline. ' +
|
||||
'Returns { ok: true, agentId, activeVersionId: null } or { ok: false, errors }.',
|
||||
)
|
||||
.input(z.object({}))
|
||||
.handler(async () => {
|
||||
if (!(await userHasScopes(user, ['agent:unpublish'], false, { projectId }))) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{ message: 'You do not have permission to unpublish agents in this project.' },
|
||||
],
|
||||
};
|
||||
}
|
||||
try {
|
||||
await this.agentPublishService.unpublishAgent(agentId, projectId);
|
||||
return { ok: true, agentId, activeVersionId: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ message: e instanceof Error ? e.message : String(e) }],
|
||||
};
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
const modelLookup: ModelLookup = {
|
||||
list: async (credentialId, credentialType, provider) =>
|
||||
await this.builderModelLiveLookupService.list(
|
||||
@@ -482,6 +561,8 @@ export class AgentsBuilderToolsService {
|
||||
patchConfigTool,
|
||||
listIntegrationTypesTool,
|
||||
listSubAgentsTool,
|
||||
publishAgentTool,
|
||||
unpublishAgentTool,
|
||||
buildResolveLlmTool({ credentialProvider, modelLookup }),
|
||||
buildAskCredentialTool({
|
||||
credentialProvider,
|
||||
@@ -618,7 +699,7 @@ export class AgentsBuilderToolsService {
|
||||
'objective field carries its own structured template. The whole batch is all-or-nothing: an ' +
|
||||
'invalid cron or objective rejects every task in the call. This adds a `{ type: "task", id, ' +
|
||||
'enabled }` ref per task to the agent config (config.tasks) and each task starts running once ' +
|
||||
'the agent is (re)published. Returns { ok: true, tasks: [{ id, name, enabled }, ...] } (same ' +
|
||||
'the agent is (re)published via `publish_agent`. Returns { ok: true, tasks: [{ id, name, enabled }, ...] } (same ' +
|
||||
'order as input, objectives and crons are not echoed back) or { ok: false, errors }.',
|
||||
)
|
||||
.systemInstruction(
|
||||
@@ -661,7 +742,7 @@ export class AgentsBuilderToolsService {
|
||||
try {
|
||||
// Adds a `{ type:'task', id, enabled }` ref per task to the agent config
|
||||
// and creates every body in one transaction. Enabled by default; each
|
||||
// task starts running once the agent is (re)published.
|
||||
// task starts running once the agent is (re)published via publish_agent.
|
||||
const created = await this.agentTaskService.createTasks(
|
||||
agentId,
|
||||
projectId,
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
*/
|
||||
export const BUILDER_TOOLS = {
|
||||
READ_CONFIG: 'read_config',
|
||||
// WRITE_CONFIG / PATCH_CONFIG values must match `CONFIG_MUTATION_TOOL_NAMES`
|
||||
// in `@n8n/api-types` (agents/agent-interaction.schema.ts).
|
||||
// WRITE_CONFIG / PATCH_CONFIG / PUBLISH_AGENT / UNPUBLISH_AGENT values must
|
||||
// match `CONFIG_MUTATION_TOOL_NAMES` in `@n8n/api-types`
|
||||
// (agents/agent-interaction.schema.ts).
|
||||
WRITE_CONFIG: 'write_config',
|
||||
PATCH_CONFIG: 'patch_config',
|
||||
BUILD_CUSTOM_TOOL: 'build_custom_tool',
|
||||
@@ -19,6 +20,8 @@ export const BUILDER_TOOLS = {
|
||||
GET_RESOURCE_LOCATOR_OPTIONS: 'get_resource_locator_options',
|
||||
LIST_INTEGRATION_TYPES: 'list_integration_types',
|
||||
LIST_SUB_AGENTS: 'list_sub_agents',
|
||||
PUBLISH_AGENT: 'publish_agent',
|
||||
UNPUBLISH_AGENT: 'unpublish_agent',
|
||||
RESOLVE_LLM: 'resolve_llm',
|
||||
SEARCH_MCP_SERVERS: 'search_mcp_servers',
|
||||
VERIFY_MCP_SERVER: 'verify_mcp_server',
|
||||
|
||||
@@ -8,7 +8,13 @@ export function targetTasksSkill(): RuntimeSkill {
|
||||
name: 'Agent Builder Target Tasks',
|
||||
description:
|
||||
'Use when the user wants the target agent to run something on a recurring schedule (a "task"): a daily/weekly/hourly objective the agent carries out on its own with create_tasks. Not for one-off requests, chat/event triggers, or config/tool/skill/model edits.',
|
||||
recommendedTools: ['create_tasks', 'ask_questions', 'read_config', 'patch_config'],
|
||||
recommendedTools: [
|
||||
'create_tasks',
|
||||
'ask_questions',
|
||||
'read_config',
|
||||
'patch_config',
|
||||
'publish_agent',
|
||||
],
|
||||
allowedTools: [
|
||||
'create_tasks',
|
||||
'ask_questions',
|
||||
@@ -19,6 +25,7 @@ export function targetTasksSkill(): RuntimeSkill {
|
||||
'search_nodes',
|
||||
'get_node_types',
|
||||
'ask_credential',
|
||||
'publish_agent',
|
||||
],
|
||||
instructions: `\
|
||||
## Purpose
|
||||
@@ -89,10 +96,12 @@ template and pin down the cadence for every task. Never create a placeholder or
|
||||
|
||||
- \`create_tasks\` adds a \`{ type: "task", id, enabled }\` ref per task to
|
||||
\`config.tasks\` and creates each task body. Tasks are enabled by default and
|
||||
only start running once the agent is (re)published; tell the user this when
|
||||
relevant.
|
||||
only start running once the agent is (re)published via \`publish_agent\`; tell
|
||||
the user this when relevant, and call \`publish_agent\` when they ask to publish
|
||||
or make the agent live.
|
||||
- To disable or remove a task, edit \`config.tasks\` with \`patch_config\` (set
|
||||
\`enabled: false\`, or drop the ref). Changes take effect on the next publish.
|
||||
\`enabled: false\`, or drop the ref). Changes take effect on the next
|
||||
\`publish_agent\`.
|
||||
- \`create_tasks\` does NOT add tools — if a task needs a tool the agent lacks,
|
||||
add it to the config yourself first.
|
||||
- Do not call \`create_tasks\` once per task when several are ready; batch them
|
||||
|
||||
@@ -24,7 +24,9 @@ export const INSTANCE_AI_BUILDER_ADDENDUM = `## Instance AI session rules
|
||||
|
||||
You are running as a sub-agent inside n8n's instance AI chat; the user sees your questions as chat cards.
|
||||
|
||||
The agent preview link is not visible in this chat; describe outcomes in text instead of linking the preview.`;
|
||||
The agent preview link is not visible in this chat; describe outcomes in text instead of linking the preview.
|
||||
|
||||
You can publish and unpublish the target agent with \`publish_agent\` and \`unpublish_agent\`. Never tell the user to open the agent editor and click Publish.`;
|
||||
|
||||
function isTextDeltaChunk(
|
||||
chunk: StreamChunk,
|
||||
|
||||
Reference in New Issue
Block a user