diff --git a/packages/@n8n/api-types/src/agents/agent-interaction.schema.ts b/packages/@n8n/api-types/src/agents/agent-interaction.schema.ts index 8a883c5b62c..c9a67178f41 100644 --- a/packages/@n8n/api-types/src/agents/agent-interaction.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-interaction.schema.ts @@ -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 diff --git a/packages/@n8n/instance-ai/docs/tools.md b/packages/@n8n/instance-ai/docs/tools.md index e4200fc6a6d..40694a113a4 100644 --- a/packages/@n8n/instance-ai/docs/tools.md +++ b/packages/@n8n/instance-ai/docs/tools.md @@ -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::`) and never appears in the agents-module builder UI. diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts index 6086406f819..d769f24756a 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts @@ -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 () => { diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index 9bf67d31acf..d89e8d9d6fc 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -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 / ``, 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 / ``, 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 { diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-agent.cascade-restart.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-agent.cascade-restart.test.ts index 385ef3e14ce..617f2a8b61a 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-agent.cascade-restart.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-agent.cascade-restart.test.ts @@ -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 diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-agent.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-agent.tool.test.ts index 969891f8200..0c7bc391800 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-agent.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-agent.tool.test.ts @@ -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(); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-agent.tool.ts index a616fcdcc91..72f76a9649c 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-agent.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-agent.tool.ts @@ -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 ' + diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index e96eea0230d..8725a69c02f 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -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(); const mcpRegistryService = mock(); const agentTaskService = mock(); + const agentPublishService = mock(); const aiService = mock(); aiService.isProxyEnabled.mockReturnValue(false); const dynamicNodeParametersService = mock(); @@ -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' }], + }); + }); + }); }); diff --git a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts index af1b1599f7a..d90006c02b5 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -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; diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index 0610c4cdd11..e06dbfce2ad 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -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, diff --git a/packages/cli/src/modules/agents/builder/builder-tool-names.ts b/packages/cli/src/modules/agents/builder/builder-tool-names.ts index c4cacf1003e..0c4457e1dfa 100644 --- a/packages/cli/src/modules/agents/builder/builder-tool-names.ts +++ b/packages/cli/src/modules/agents/builder/builder-tool-names.ts @@ -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', diff --git a/packages/cli/src/modules/agents/builder/skills/target-tasks.skill.ts b/packages/cli/src/modules/agents/builder/skills/target-tasks.skill.ts index 709b3b71899..8172d6c33c5 100644 --- a/packages/cli/src/modules/agents/builder/skills/target-tasks.skill.ts +++ b/packages/cli/src/modules/agents/builder/skills/target-tasks.skill.ts @@ -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 diff --git a/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts b/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts index 2a2c80c86e5..fa8ed2a78c0 100644 --- a/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts +++ b/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts @@ -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,