From bfff25f05dd5fbcdf7f5c0f13ecbf96fecbda174 Mon Sep 17 00:00:00 2001 From: bjorger <50590409+bjorger@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:37:39 +0200 Subject: [PATCH] feat(core): Add sub-agent executions (#31540) Co-authored-by: Cursor Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- packages/@n8n/agents/AGENTS.md | 7 +- .../agents/docs/agent-runtime-architecture.md | 17 +- packages/@n8n/agents/examples/basic-agent.ts | 24 +- .../integration/delegate-sub-agent.test.ts | 125 ++++++ .../integration/events-and-abort.test.ts | 53 --- .../__tests__/integration/sub-agent.test.ts | 88 ---- .../src/__tests__/integration/usage.test.ts | 75 ---- packages/@n8n/agents/src/index.ts | 25 +- .../runtime/__tests__/agent-runtime.test.ts | 204 ++++++++- .../__tests__/delegate-sub-agent-tool.test.ts | 290 ++++++++++++ .../src/runtime/__tests__/stream.test.ts | 23 + .../__tests__/sub-agent-task-path.test.ts | 74 +++ .../runtime/__tests__/tool-adapter.test.ts | 33 +- .../@n8n/agents/src/runtime/agent-runtime.ts | 122 +++-- .../src/runtime/delegate-sub-agent-tool.ts | 417 +++++++++++++++++ .../agents/src/runtime/runtime-helpers.ts | 12 +- packages/@n8n/agents/src/runtime/stream.ts | 12 + .../agents/src/runtime/sub-agent-task-path.ts | 149 ++++++ .../@n8n/agents/src/runtime/tool-adapter.ts | 38 +- packages/@n8n/agents/src/sdk/agent.ts | 67 +-- packages/@n8n/agents/src/sdk/network.ts | 90 ---- packages/@n8n/agents/src/types/index.ts | 1 - .../@n8n/agents/src/types/runtime/event.ts | 35 ++ packages/@n8n/agents/src/types/sdk/agent.ts | 46 +- packages/@n8n/agents/src/types/sdk/tool.ts | 21 +- .../src/agent-builder-interactive.ts | 2 +- packages/@n8n/api-types/src/agent-sse.ts | 15 + .../src/agents/agent-json-config.schema.ts | 16 + packages/@n8n/api-types/src/agents/index.ts | 1 + .../api-types/src/agents/sub-agent.schema.ts | 52 +++ packages/@n8n/api-types/src/agents/types.ts | 4 + .../@n8n/config/src/configs/agents.config.ts | 8 + packages/@n8n/config/test/config.test.ts | 2 + .../agent-execution-thread.repository.test.ts | 29 ++ .../__tests__/agent-execution.service.test.ts | 49 ++ .../__tests__/agent-json-config.test.ts | 34 +- .../agents/__tests__/agent-sse-stream.test.ts | 44 ++ .../agents-builder-tools.service.test.ts | 47 ++ .../agents/__tests__/agents.service.test.ts | 53 +++ .../__tests__/execution-recorder.test.ts | 76 ++++ .../execution-to-message-mapper.test.ts | 4 + .../modules/agents/agent-execution.service.ts | 5 + .../src/modules/agents/agent-sse-stream.ts | 12 + .../cli/src/modules/agents/agents.service.ts | 138 ++++++ ...ents-builder-model-recommendations.test.ts | 26 ++ .../agents/builder/agents-builder-prompts.ts | 41 ++ .../builder/agents-builder-tools.service.ts | 22 + .../agents/builder/builder-tool-names.ts | 1 + .../__tests__/ask-question.tool.test.ts | 14 + .../builder/interactive/ask-question.tool.ts | 11 +- .../builder/prompts/config-mutation.prompt.ts | 22 +- .../builder/prompts/config-rules.prompt.ts | 11 +- .../src/modules/agents/execution-recorder.ts | 57 ++- .../agent-execution-thread.repository.ts | 10 + .../__tests__/delegate-sub-agent-tool.test.ts | 213 +++++++++ .../sub-agent-foreground-runner.test.ts | 425 ++++++++++++++++++ .../sub-agent-source-resolver.test.ts | 180 ++++++++ .../sub-agents/delegate-sub-agent-tool.ts | 86 ++++ .../sub-agents/sub-agent-foreground-runner.ts | 287 ++++++++++++ .../sub-agents/sub-agent-source-resolver.ts | 123 +++++ .../utils/execution-to-message-mapper.ts | 2 + .../frontend/@n8n/i18n/src/locales/en.json | 23 +- .../__tests__/AgentBuilder.readonly.test.ts | 18 + .../AgentBuilderEditorColumn.spec.ts | 109 ++++- .../__tests__/AgentChatMessageList.test.ts | 2 +- .../__tests__/agentChatMessages.test.ts | 47 ++ .../agents/__tests__/delegate-tool.spec.ts | 149 ++++++ .../__tests__/session-timeline.utils.spec.ts | 15 + .../__tests__/useAgentChatStream.test.ts | 123 ++++- .../__tests__/useProjectAgentsList.test.ts | 37 ++ .../components/AgentBuilderEditorColumn.vue | 267 ++++++++++- .../features/agents/components/AgentCard.vue | 2 + .../components/AgentChatMessageList.vue | 7 +- .../agents/components/AgentChatToolSteps.vue | 260 ++++++++--- .../agents/components/AgentSubAgentsModal.vue | 219 +++++++++ .../agents/components/SessionDetailPanel.vue | 9 +- .../components/SessionTimelineChart.vue | 21 +- .../agents/components/SessionTimelinePill.vue | 3 +- .../agents/components/SessionTimelineRow.vue | 12 +- .../agents/composables/agentChatMessages.ts | 13 +- .../agents/composables/useAgentChatStream.ts | 36 +- .../agents/composables/useAgentPublish.ts | 4 + .../agents/composables/useAgentThreadsApi.ts | 2 + .../composables/useProjectAgentsList.ts | 43 +- .../agents/composables/useSubAgentNames.ts | 28 ++ .../src/features/agents/constants.ts | 1 + .../src/features/agents/module.descriptor.ts | 12 + .../agents/session-timeline.styles.ts | 15 +- .../features/agents/session-timeline.types.ts | 6 + .../features/agents/session-timeline.utils.ts | 30 +- .../features/agents/utils/delegate-tool.ts | 97 ++++ .../agents/views/AgentBuilderView.vue | 2 + .../agents/views/AgentSessionTimelineView.vue | 22 +- .../agents/views/AgentSessionsListView.vue | 64 ++- .../features/agents/views/NewAgentView.vue | 3 + 95 files changed, 5193 insertions(+), 678 deletions(-) create mode 100644 packages/@n8n/agents/src/__tests__/integration/delegate-sub-agent.test.ts delete mode 100644 packages/@n8n/agents/src/__tests__/integration/sub-agent.test.ts create mode 100644 packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts create mode 100644 packages/@n8n/agents/src/runtime/__tests__/sub-agent-task-path.test.ts create mode 100644 packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts create mode 100644 packages/@n8n/agents/src/runtime/sub-agent-task-path.ts delete mode 100644 packages/@n8n/agents/src/sdk/network.ts create mode 100644 packages/@n8n/api-types/src/agents/sub-agent.schema.ts create mode 100644 packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts create mode 100644 packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts create mode 100644 packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts create mode 100644 packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts create mode 100644 packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts create mode 100644 packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/delegate-tool.spec.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentSubAgentsModal.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useSubAgentNames.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/delegate-tool.ts diff --git a/packages/@n8n/agents/AGENTS.md b/packages/@n8n/agents/AGENTS.md index 6e16f121468..5a3f7ddc281 100644 --- a/packages/@n8n/agents/AGENTS.md +++ b/packages/@n8n/agents/AGENTS.md @@ -9,9 +9,9 @@ Conventions for the `@n8n/agents` package. - **Builder pattern with lazy build** — all public primitives use a fluent builder API. **User code never calls `.build()`**. Builders are passed directly to the consuming method (e.g. `agent.tool(myTool)`) which calls - `.build()` internally. Agent and Network have `run()`/`stream()` directly - on the class, which lazy-build via `ensureBuilt()` on first call. `build()` - is `protected` on Agent and Network to keep it out of the public API. + `.build()` internally. Agent has `generate()`/`stream()` directly on the + class, which lazy-build via `ensureBuilt()` on first call. `build()` is + `protected` on Agent to keep it out of the public API. - **Zod for schemas** — all input/output schemas use Zod. ## Package Structure @@ -34,7 +34,6 @@ src/ mcp-client.ts # MCP client integration memory.ts # Memory builder message.ts # LLM/DB message helpers - network.ts # Network builder provider-tools.ts # Provider-defined tool factories telemetry.ts # Telemetry builder (OTel, redaction) tool.ts # Tool builder diff --git a/packages/@n8n/agents/docs/agent-runtime-architecture.md b/packages/@n8n/agents/docs/agent-runtime-architecture.md index 2f52d98950e..72ffae56b76 100644 --- a/packages/@n8n/agents/docs/agent-runtime-architecture.md +++ b/packages/@n8n/agents/docs/agent-runtime-architecture.md @@ -65,7 +65,6 @@ graph TD | `on(event, handler)` | Register a lifecycle event handler | | `abort()` | Cancel the currently running agent | | `getState()` | Return the latest `SerializableAgentState` snapshot | -| `asTool(description)` | Wrap the agent as a `BuiltTool` for multi-agent composition | `ExecutionOptions` includes `abortSignal?: AbortSignal`, forwarded into `AgentEventBus.resetAbort()` so callers can cancel via an external signal as @@ -187,18 +186,6 @@ interface SerializableAgentState { `suspendPayload`, `resumeSchema`) from calls not yet executed (`suspended: false`) when a batch stops at the first suspension. ---- - -## asTool() - -`agent.asTool(description)` wraps the agent as a `BuiltTool`. The handler calls -`agent.generate(input, { telemetry: ctx.parentTelemetry })`, collects assistant -text, and returns `{ result: string }`. When the sub-run produces usage, -results are wrapped so the parent runtime can merge **`SubAgentUsage`** and -**`totalCost`** into the parent `GenerateResult` / stream `finish` chunk. - ---- - ## Message types | Type | Definition | Purpose | @@ -395,7 +382,7 @@ readable side immediately; the loop writes chunks in the background. | `tool-call-delta` | Streaming tool name / arguments | | `message` | Full assistant or tool message | | `tool-call-suspended` | Suspension: `runId`, `toolCallId`, tool metadata, optional `resumeSchema`, `suspendPayload` | -| `finish` | `finishReason`, `usage` (with optional **cost**), `model`, optional **`structuredOutput`**, **`subAgentUsage`**, **`totalCost`** | +| `finish` | `finishReason`, `usage` (with optional **cost**), `model`, optional **`structuredOutput`** | | `error` | Failure or abort | --- @@ -412,7 +399,7 @@ src/ memory-store.ts — saveMessagesToThread helper messages.ts — AI SDK message conversion model-factory.ts — createModel / createEmbeddingModel - tool-adapter.ts — buildToolMap, executeTool, toAiSdkTools, suspend / agent-result guards + tool-adapter.ts — buildToolMap, executeTool, toAiSdkTools, suspend guards stream.ts — convertChunk, toTokenUsage runtime-helpers.ts — normalizeInput, usage merge, stream error helpers, … working-memory.ts — instruction text, update_working_memory tool builder diff --git a/packages/@n8n/agents/examples/basic-agent.ts b/packages/@n8n/agents/examples/basic-agent.ts index bd50b035391..b3ec7b1c04f 100644 --- a/packages/@n8n/agents/examples/basic-agent.ts +++ b/packages/@n8n/agents/examples/basic-agent.ts @@ -3,7 +3,7 @@ * * This example demonstrates the complete builder-pattern API for creating * and running AI agents. It shows: tools, agents, memory, guardrails, - * scorers, multi-agent patterns (agent-as-tool), and tool interrupts. + * scorers, and tool interrupts. * * To run with real LLM calls, set ANTHROPIC_API_KEY. * Without keys, the runtime will throw on actual LLM calls. @@ -90,18 +90,6 @@ const writer = new Agent('writer') .tool(writeFileTool) .checkpoint('memory'); -// --------------------------------------------------------------------------- -// Multi-Agent: Agent as Tool -// --------------------------------------------------------------------------- - -const orchestrator = new Agent('orchestrator') - .model('anthropic/claude-sonnet-4') - .instructions( - 'You coordinate research and writing. Delegate research to the researcher and writing to the writer.', - ) - .tool(researcher.asTool('Delegate research tasks to the research specialist')) - .tool(writer.asTool('Delegate writing tasks to the content writer')); - // --------------------------------------------------------------------------- // Execution // --------------------------------------------------------------------------- @@ -132,13 +120,11 @@ async function main() { console.log(' (Set ANTHROPIC_API_KEY to run with real LLM calls)'); } - // --- 2. Orchestrator (agent-as-tool pattern) --- - console.log('\n2. Orchestrator (agent-as-tool pattern):'); + // --- 2. Tool interrupt --- + console.log('\n2. Tool interrupt:'); try { - const orchResult = await orchestrator.generate( - 'Research RAG architectures and write a summary', - ); - const text = orchResult.messages + const writerResult = await writer.generate('Write a short summary to /tmp/rag-summary.txt'); + const text = writerResult.messages .flatMap((m) => ('content' in m ? m.content : [])) .filter((c) => c.type === 'text') .map((c) => ('text' in c ? c.text : '')) diff --git a/packages/@n8n/agents/src/__tests__/integration/delegate-sub-agent.test.ts b/packages/@n8n/agents/src/__tests__/integration/delegate-sub-agent.test.ts new file mode 100644 index 00000000000..58592aac017 --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/delegate-sub-agent.test.ts @@ -0,0 +1,125 @@ +import { expect, it } from 'vitest'; + +import { describeIf, getModel } from './helpers'; +import { + Agent, + createDelegateSubAgentTool, + filterLlmMessages, + type AgentMessage, +} from '../../index'; + +const describe = describeIf('anthropic'); + +const SENTINEL = 'SUBAGENT_OK_731'; + +describe('delegate_subagent integration', () => { + it('lets a real parent agent call delegate_subagent and use its result', async () => { + const child = new Agent('sub-agent-child-integration') + .model(getModel('anthropic')) + .instructions( + [ + 'You are a deterministic test sub-agent.', + `Always answer with exactly this token and nothing else: ${SENTINEL}`, + ].join(' '), + ); + + const delegateTool = createDelegateSubAgentTool({ + policy: { maxChildren: 1 }, + runSubAgent: async (request) => { + const childResult = await child.generate(`Goal:\n${request.goal}`); + return { + status: + childResult.finishReason === 'error' || childResult.error !== undefined + ? 'failed' + : 'completed', + taskPath: request.taskPath, + runId: childResult.runId, + answer: lastText(childResult.messages), + ...(childResult.usage !== undefined + ? { + usage: { + promptTokens: childResult.usage.promptTokens, + completionTokens: childResult.usage.completionTokens, + totalTokens: childResult.usage.totalTokens, + }, + } + : {}), + ...(childResult.finishReason !== undefined + ? { finishReason: childResult.finishReason } + : {}), + }; + }, + }); + + const parent = new Agent('sub-agent-parent-integration') + .model(getModel('anthropic')) + .instructions( + [ + 'You are a parent test agent.', + 'You must call delegate_subagent exactly once before answering.', + 'The child result will contain a sentinel token.', + 'After the tool returns, answer with exactly: PARENT_SAW_ followed by the child answer, with no extra text.', + ].join(' '), + ) + .tool(delegateTool); + + try { + const result = await parent.generate( + 'Use delegate_subagent now to ask the child for its sentinel token.', + ); + + expect(result.toolCalls?.map((toolCall) => toolCall.tool) ?? []).toContain( + 'delegate_subagent', + ); + expect(lastText(result.messages)).toContain(`PARENT_SAW_${SENTINEL}`); + + const delegateToolCall = result.toolCalls?.find( + (toolCall) => toolCall.tool === 'delegate_subagent', + ); + const delegateOutput = delegateToolCall?.output; + if (!isDelegateOutput(delegateOutput)) { + throw new Error('delegate_subagent did not return the expected output shape'); + } + + expect(delegateOutput.status).toBe('completed'); + expect(delegateOutput.runId).toBeDefined(); + expect(delegateOutput.answer).toContain(SENTINEL); + expect(delegateOutput.usage?.totalTokens).toBeGreaterThan(0); + expect(delegateOutput.taskPath).toMatch(/^\/root\/[a-z0-9_]+$/); + } finally { + await parent.close(); + await child.close(); + } + }, 60_000); +}); + +function lastText(messages: AgentMessage[]): string { + const llmMessages = filterLlmMessages(messages); + for (let i = llmMessages.length - 1; i >= 0; i--) { + const message = llmMessages[i]; + if (!message) continue; + + const text = message.content.find((content) => content.type === 'text'); + if (text?.type === 'text') return text.text; + } + + return ''; +} + +function isDelegateOutput(value: unknown): value is { + status: 'completed' | 'failed'; + taskPath: string; + runId: string; + answer: string; + usage: { totalTokens: number }; +} { + return ( + typeof value === 'object' && + value !== null && + 'status' in value && + 'taskPath' in value && + 'runId' in value && + 'answer' in value && + 'usage' in value + ); +} diff --git a/packages/@n8n/agents/src/__tests__/integration/events-and-abort.test.ts b/packages/@n8n/agents/src/__tests__/integration/events-and-abort.test.ts index c34ec03c4cc..8b96f462cec 100644 --- a/packages/@n8n/agents/src/__tests__/integration/events-and-abort.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/events-and-abort.test.ts @@ -224,56 +224,3 @@ describe('getState()', () => { expect(state.persistence?.threadId).toBe('thread-abc'); }); }); - -// --------------------------------------------------------------------------- -// asTool() -// --------------------------------------------------------------------------- - -describe('asTool()', () => { - it('wraps the agent as a BuiltTool with the correct name and description', () => { - const agent = createSimpleAgent(); - const tool = agent.asTool('A helpful assistant tool'); - - expect(tool.name).toBe('events-test-agent'); - expect(tool.description).toBe('A helpful assistant tool'); - expect(tool.inputSchema).toBeDefined(); - expect(typeof tool.handler).toBe('function'); - }); - - it('asTool handler calls the agent and returns text result', async () => { - const agent = createSimpleAgent(); - const tool = agent.asTool('A helpful assistant tool'); - - const result = await tool.handler!({ input: 'Say "pong"' }, {}); - - expect(result).toHaveProperty('result'); - expect(typeof (result as { result: string }).result).toBe('string'); - expect((result as { result: string }).result.length).toBeGreaterThan(0); - }); - - it('coordinator agent can use sub-agent via asTool', async () => { - const specialist = new Agent('specialist') - .model(getModel('anthropic')) - .instructions('You are a specialist. When asked, reply with exactly "SPECIALIST_RESPONSE".'); - - const coordinator = new Agent('coordinator') - .model(getModel('anthropic')) - .instructions( - 'You coordinate tasks. Use the specialist tool to answer questions. Relay the exact response.', - ) - .tool(specialist.asTool('A specialist agent')); - - const result = await coordinator.generate( - 'Ask the specialist for their response and tell me what they said.', - ); - - const text = result.messages - .filter((m) => 'role' in m && m.role === 'assistant') - .flatMap((m) => ('content' in m ? m.content : [])) - .filter((c) => c.type === 'text') - .map((c) => ('text' in c ? c.text : '')) - .join(''); - - expect(text.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/@n8n/agents/src/__tests__/integration/sub-agent.test.ts b/packages/@n8n/agents/src/__tests__/integration/sub-agent.test.ts deleted file mode 100644 index 009e027f96e..00000000000 --- a/packages/@n8n/agents/src/__tests__/integration/sub-agent.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect, it } from 'vitest'; - -import { - chunksOfType, - collectStreamChunks, - collectTextDeltas, - describeIf, - getModel, -} from './helpers'; -import { Agent } from '../../index'; - -const describe = describeIf('anthropic'); - -describe('sub-agent (asTool) integration', () => { - it('orchestrator calls a sub-agent as a tool and gets its response', async () => { - const mathAgent = new Agent('math-specialist') - .model(getModel('anthropic')) - .instructions( - 'You are a math specialist. When given a math problem, compute the answer and reply with just the number. No explanation.', - ); - - const orchestrator = new Agent('orchestrator') - .model(getModel('anthropic')) - .instructions( - 'You are a coordinator. When asked a math question, delegate to the math_specialist tool. ' + - 'Pass the question as the prompt. Then relay the answer back.', - ) - .tool(mathAgent.asTool('A math specialist that can solve math problems')); - - const { stream: fullStream } = await orchestrator.stream('What is 15 * 4?'); - - const chunks = await collectStreamChunks(fullStream); - const text = collectTextDeltas(chunks); - const toolResults = chunksOfType(chunks, 'tool-result'); - - // The orchestrator should have called the sub-agent tool - expect(toolResults.length).toBeGreaterThan(0); - const mathCall = toolResults.find((tc) => tc.toolName === 'math-specialist'); - expect(mathCall).toBeDefined(); - - // The output should contain the sub-agent's response - expect(mathCall!.output).toBeDefined(); - - // The final text should reference 60 - expect(text).toBeTruthy(); - expect(text).toContain('60'); - }); - - it('handles a chain of two sub-agents', async () => { - const translatorAgent = new Agent('translator') - .model(getModel('anthropic')) - .instructions( - 'You are a translator. Translate the given text to French. Reply with only the French translation.', - ); - - const uppercaseAgent = new Agent('uppercaser') - .model(getModel('anthropic')) - .instructions( - 'You convert text to uppercase. Reply with the input text in all uppercase letters. Nothing else.', - ); - - const orchestrator = new Agent('chain-orchestrator') - .model(getModel('anthropic')) - .instructions( - 'You are a coordinator with two tools. ' + - 'When asked to translate and uppercase text: ' + - '1. First use the translator tool to translate to French. ' + - '2. Then use the uppercaser tool to convert the French text to uppercase. ' + - 'Return the final uppercase French text.', - ) - .tool(translatorAgent.asTool('Translates text to French')) - .tool(uppercaseAgent.asTool('Converts text to uppercase')); - - const { stream: fullStream } = await orchestrator.stream( - 'Translate "hello" to French and then make it uppercase.', - ); - const chunks = await collectStreamChunks(fullStream); - const toolResults = chunksOfType(chunks, 'tool-result'); - - // Should have called both tools - expect(toolResults.length).toBeGreaterThanOrEqual(2); - - const text = collectTextDeltas(chunks); - expect(text).toBeTruthy(); - // The result should contain BONJOUR (or SALUT) — uppercase French for hello - expect(text).toMatch(/BONJOUR/i); - }); -}); diff --git a/packages/@n8n/agents/src/__tests__/integration/usage.test.ts b/packages/@n8n/agents/src/__tests__/integration/usage.test.ts index 3f0907bd62e..39bdb0738d1 100644 --- a/packages/@n8n/agents/src/__tests__/integration/usage.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/usage.test.ts @@ -74,81 +74,6 @@ describeAnthropic('usage and cost (Anthropic)', () => { expect(finish.usage!.cost).toBeDefined(); expect(finish.usage!.cost).toBeGreaterThan(0); }); - - it('aggregates sub-agent usage when using asTool()', async () => { - const subAgent = new Agent('translator') - .model(getModel('anthropic')) - .instructions('Translate the input to French. Reply with only the translation.'); - - const parentAgent = new Agent('orchestrator') - .model(getModel('anthropic')) - .instructions( - 'You are an orchestrator. When asked to translate, use the translator tool. Be concise.', - ) - .tool(subAgent.asTool('Translate text to French')); - - const result = await parentAgent.generate('Translate "hello world" to French'); - - // Parent should have its own usage - expect(result.usage).toBeDefined(); - expect(result.usage!.promptTokens).toBeGreaterThan(0); - expect(result.usage!.cost).toBeGreaterThan(0); - expect(result.model).toBe(getModel('anthropic')); - - // Sub-agent usage should be captured - expect(result.subAgentUsage).toBeDefined(); - expect(result.subAgentUsage!.length).toBeGreaterThan(0); - - const translatorUsage = result.subAgentUsage!.find((s) => s.agent === 'translator'); - expect(translatorUsage).toBeDefined(); - expect(translatorUsage!.usage.promptTokens).toBeGreaterThan(0); - expect(translatorUsage!.usage.cost).toBeGreaterThan(0); - - // Total cost should be parent + sub-agent - expect(result.totalCost).toBeDefined(); - expect(result.totalCost!).toBeGreaterThan(result.usage!.cost!); - expect(result.totalCost!).toBeCloseTo(result.usage!.cost! + translatorUsage!.usage.cost!, 6); - }); - - it('aggregates sub-agent usage via stream()', async () => { - const subAgent = new Agent('stream-translator') - .model(getModel('anthropic')) - .instructions('Translate the input to French. Reply with only the translation.'); - - const parentAgent = new Agent('stream-orchestrator') - .model(getModel('anthropic')) - .instructions( - 'You are an orchestrator. When asked to translate, use the stream-translator tool. Be concise.', - ) - .tool(subAgent.asTool('Translate text to French')); - - const { stream: fullStream } = await parentAgent.stream('Translate "goodbye" to French'); - const chunks = await collectStreamChunks(fullStream); - const finishChunks = chunksOfType(chunks, 'finish'); - - expect(finishChunks.length).toBeGreaterThan(0); - const finish = finishChunks[finishChunks.length - 1] as StreamChunk & { type: 'finish' }; - - // Should have usage with cost - expect(finish.usage).toBeDefined(); - expect(finish.usage!.cost).toBeGreaterThan(0); - - // Should include model - expect(finish.model).toBe(getModel('anthropic')); - - // Should include sub-agent usage - expect(finish.subAgentUsage).toBeDefined(); - expect(finish.subAgentUsage!.length).toBeGreaterThan(0); - - const translatorUsage = finish.subAgentUsage!.find((s) => s.agent === 'stream-translator'); - expect(translatorUsage).toBeDefined(); - expect(translatorUsage!.usage.promptTokens).toBeGreaterThan(0); - expect(translatorUsage!.usage.cost).toBeGreaterThan(0); - - // Total cost should include parent + sub-agent - expect(finish.totalCost).toBeDefined(); - expect(finish.totalCost!).toBeGreaterThan(finish.usage!.cost!); - }); }); const describeOpenAI = describeIf('openai'); diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index 4689d89f675..dcba99e5b27 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -19,7 +19,6 @@ export type { InterruptibleToolContext, CheckpointStore, StreamChunk, - SubAgentUsage, Provider, ThinkingConfig, ThinkingConfigFor, @@ -161,7 +160,6 @@ export type { CredentialListItem, } from './types'; export { McpClient } from './sdk/mcp-client'; -export { Network } from './sdk/network'; export { providerTools } from './sdk/provider-tools'; export { verify } from './sdk/verify'; export type { VerifyResult } from './sdk/verify'; @@ -198,6 +196,29 @@ export { BaseMemory } from './storage/base-memory'; export type { ToolDescriptor } from './types/sdk/tool-descriptor'; export { createModel } from './runtime/model-factory'; +export { + ROOT_SUB_AGENT_TASK_PATH, + assertSubAgentPolicyAllowsChild, + assertSubAgentPolicyAllowsChildCount, + assertSubAgentTaskPath, + createChildSubAgentTaskPath, + isSubAgentTaskPath, + sanitizeSubAgentTaskName, +} from './runtime/sub-agent-task-path'; +export type { SubAgentTaskPath, SubAgentTaskPathPolicy } from './runtime/sub-agent-task-path'; +export { + DELEGATE_SUB_AGENT_TOOL_NAME, + createDelegateSubAgentTool, + generateResultToDelegateSubAgentOutput, + renderDelegateSubAgentPrompt, +} from './runtime/delegate-sub-agent-tool'; +export type { + CreateDelegateSubAgentToolOptions, + DelegateSubAgentInput, + DelegateSubAgentPolicy, + DelegateSubAgentRequest, + DelegateSubAgentToolOutput, +} from './runtime/delegate-sub-agent-tool'; export { createEmbeddingModel } from './runtime/model-factory'; export { generateTitleFromMessage } from './runtime/title-generation'; export { diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index 40e540a6525..b17bb664315 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -9,7 +9,7 @@ import type { AgentEventData } from '../../types/runtime/event'; import type { StreamChunk } from '../../types/sdk/agent'; import type { BuiltMemory } from '../../types/sdk/memory'; import type { ContentToolCall, Message } from '../../types/sdk/message'; -import type { BuiltTool, InterruptibleToolContext } from '../../types/sdk/tool'; +import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../../types/sdk/tool'; import type { BuiltTelemetry } from '../../types/telemetry'; import { AgentRuntime } from '../agent-runtime'; import { AgentEventBus } from '../event-bus'; @@ -144,6 +144,60 @@ function makeStreamSuccess(text = 'Hello') { }; } +/** + * streamText response where the model invokes a provider-executed tool (e.g. + * native web search): the SDK streams a `tool-call` and its terminal part + * (`tool-result` on success, `tool-error` on failure) with `providerExecuted`, + * then finishes with `stop` (the provider runs the tool server-side mid-step). + */ +function makeStreamWithProviderTool(opts: { + toolCallId: string; + toolName: string; + input: unknown; + output?: unknown; + error?: unknown; + text?: string; +}) { + const terminal = + opts.error !== undefined + ? { + type: 'tool-error', + toolCallId: opts.toolCallId, + toolName: opts.toolName, + input: opts.input, + error: opts.error, + providerExecuted: true, + } + : { + type: 'tool-result', + toolCallId: opts.toolCallId, + toolName: opts.toolName, + input: opts.input, + output: opts.output, + providerExecuted: true, + }; + const text = opts.text ?? 'done'; + return { + fullStream: makeChunkStream([ + { + type: 'tool-call', + toolCallId: opts.toolCallId, + toolName: opts.toolName, + input: opts.input, + providerExecuted: true, + }, + terminal, + { type: 'text-delta', textDelta: text }, + ]), + finishReason: Promise.resolve('stop'), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }), + response: Promise.resolve({ + messages: [{ role: 'assistant', content: [{ type: 'text', text }] }], + }), + toolCalls: Promise.resolve([]), + }; +} + /** Build a default runtime wired to the shared eventBus for inspection. */ function createRuntime(eventBus?: AgentEventBus) { const bus = eventBus ?? new AgentEventBus(); @@ -1435,6 +1489,80 @@ describe('AgentRuntime — concurrent tool execution', () => { expect(finishChunks.length).toBe(1); expect((finishChunks[0] as StreamChunk & { type: 'finish' }).finishReason).toBe('tool-calls'); }); + + it('bridges subagent lifecycle events from tool context into stream chunks', async () => { + const lifecycleTool: BuiltTool = { + name: 'delegate_subagent', + description: 'Delegate work', + inputSchema: z.object({ value: z.string().optional() }), + handler: async (_input, ctx) => { + const toolCtx = ctx as ToolContext; + const base = { + taskName: 'Research API', + taskPath: '/root/research_api', + ...(toolCtx.runId !== undefined ? { parentRunId: toolCtx.runId } : {}), + ...(toolCtx.toolCallId !== undefined ? { parentToolCallId: toolCtx.toolCallId } : {}), + }; + toolCtx.emitEvent?.({ + type: AgentEvent.SubAgentStarted, + ...base, + startedAt: 100, + }); + toolCtx.emitEvent?.({ + type: AgentEvent.SubAgentCompleted, + ...base, + status: 'completed', + startedAt: 100, + finishedAt: 200, + durationMs: 100, + runId: 'child-run-1', + finishReason: 'stop', + }); + return await Promise.resolve({ ok: true }); + }, + }; + const { runtime } = createRuntimeWithTools([lifecycleTool], 1); + + streamText + .mockReturnValueOnce({ + fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]), + finishReason: Promise.resolve('tool-calls'), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }), + response: Promise.resolve({ + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'delegate_subagent', + args: { value: 'a' }, + }, + ], + }, + ], + }), + toolCalls: Promise.resolve([ + { toolCallId: 'tc-1', toolName: 'delegate_subagent', input: { value: 'a' } }, + ]), + }) + .mockReturnValueOnce(makeStreamSuccess('done')); + + const { stream: readableStream } = await runtime.stream('run tools'); + const chunks = await collectChunks(readableStream); + + expect(chunks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'subagent-started', taskPath: '/root/research_api' }), + expect.objectContaining({ + type: 'subagent-completed', + status: 'completed', + runId: 'child-run-1', + }), + ]), + ); + }); }); // Structured output — generate() @@ -1518,6 +1646,80 @@ describe('AgentRuntime.generate() — structured output', () => { }); }); +// --------------------------------------------------------------------------- +// Provider-executed tool timing — stream() +// --------------------------------------------------------------------------- + +describe('AgentRuntime.stream() — provider-executed tool timing', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('emits tool-execution-start/end for a provider-executed tool result', async () => { + streamText.mockReturnValue( + makeStreamWithProviderTool({ + toolCallId: 'tc-ws', + toolName: 'web_search', + input: { query: 'n8n' }, + output: [{ url: 'https://n8n.io' }], + }), + ); + const { runtime } = createRuntime(); + + const { stream } = await runtime.stream('search please'); + const chunks = await collectChunks(stream); + + const start = chunks.find( + (c): c is Extract => + c.type === 'tool-execution-start' && c.toolCallId === 'tc-ws', + ); + const end = chunks.find( + (c): c is Extract => + c.type === 'tool-execution-end' && c.toolCallId === 'tc-ws', + ); + + expect(start).toBeDefined(); + expect(start?.toolName).toBe('web_search'); + expect(typeof start?.startTime).toBe('number'); + + expect(end).toBeDefined(); + expect(end?.isError).toBe(false); + expect(typeof end?.endTime).toBe('number'); + }); + + it('emits tool-execution-end with isError on a provider-executed tool error', async () => { + streamText.mockReturnValue( + makeStreamWithProviderTool({ + toolCallId: 'tc-ws-err', + toolName: 'web_search', + input: { query: 'n8n' }, + error: new Error('search failed'), + }), + ); + const { runtime } = createRuntime(); + + const { stream } = await runtime.stream('search please'); + const chunks = await collectChunks(stream); + + const end = chunks.find( + (c): c is Extract => + c.type === 'tool-execution-end' && c.toolCallId === 'tc-ws-err', + ); + + expect(end).toBeDefined(); + expect(end?.isError).toBe(true); + expect(typeof end?.endTime).toBe('number'); + + const toolResult = chunks.find( + (c): c is Extract => + c.type === 'tool-result' && c.toolCallId === 'tc-ws-err', + ); + expect(toolResult).toBeDefined(); + expect(toolResult?.isError).toBe(true); + expect(toolResult?.output).toEqual(new Error('search failed')); + }); +}); + // --------------------------------------------------------------------------- // Structured output — stream() // --------------------------------------------------------------------------- diff --git a/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts b/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts new file mode 100644 index 00000000000..2dbac8286db --- /dev/null +++ b/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts @@ -0,0 +1,290 @@ +import { AgentEvent, type AgentEventData } from '../../types/runtime/event'; +import type { GenerateResult } from '../../types/sdk/agent'; +import { + DELEGATE_SUB_AGENT_TOOL_NAME, + createDelegateSubAgentTool, + generateResultToDelegateSubAgentOutput, + renderDelegateSubAgentPrompt, + type DelegateSubAgentRequest, + type DelegateSubAgentToolOutput, +} from '../delegate-sub-agent-tool'; + +const input = { + taskName: 'Research API', + goal: 'Find the API behavior.', + context: 'Focus on auth endpoints.', + expectedOutput: 'A short summary.', +}; + +describe('createDelegateSubAgentTool', () => { + it('creates the delegate_subagent tool', () => { + const tool = createDelegateSubAgentTool({ + runSubAgent: async () => + await Promise.resolve({ + status: 'completed', + taskPath: '/root/research_api', + runId: 'child-run-1', + answer: 'done', + }), + }); + + expect(tool.name).toBe(DELEGATE_SUB_AGENT_TOOL_NAME); + expect(tool.description).toContain('focused child agent'); + expect(tool.inputSchema).toBeDefined(); + expect(tool.outputSchema).toBeDefined(); + }); + + it('passes model input and parent runtime context to the runner callback', async () => { + const runSubAgent = vi + .fn<(request: DelegateSubAgentRequest) => Promise>() + .mockResolvedValue({ + status: 'completed', + taskPath: '/root/research_api', + runId: 'child-run-1', + answer: 'done', + }); + const tool = createDelegateSubAgentTool({ + policy: { maxChildren: 2 }, + runSubAgent, + }); + + await tool.handler?.(input, { + runId: 'parent-run-1', + toolCallId: 'tool-call-1', + }); + + expect(runSubAgent).toHaveBeenCalledWith({ + ...input, + taskPath: '/root/research_api_0', + parentRunId: 'parent-run-1', + parentToolCallId: 'tool-call-1', + childCount: 0, + policy: { maxChildren: 2 }, + }); + }); + + it('forwards the parent persistence thread id and resource id', async () => { + const runSubAgent = vi + .fn<(request: DelegateSubAgentRequest) => Promise>() + .mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' }); + const tool = createDelegateSubAgentTool({ runSubAgent }); + + await tool.handler?.(input, { + runId: 'parent-run-1', + persistence: { threadId: 'parent-thread-1', resourceId: 'resource-1' }, + }); + + expect(runSubAgent).toHaveBeenCalledWith( + expect.objectContaining({ + parentThreadId: 'parent-thread-1', + parentResourceId: 'resource-1', + }), + ); + }); + + it('omits parent persistence fields when the parent run has no persistence scope', async () => { + const runSubAgent = vi + .fn<(request: DelegateSubAgentRequest) => Promise>() + .mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' }); + const tool = createDelegateSubAgentTool({ runSubAgent }); + + await tool.handler?.(input, { runId: 'parent-run-1' }); + + expect(runSubAgent.mock.calls[0]?.[0]).not.toHaveProperty('parentThreadId'); + expect(runSubAgent.mock.calls[0]?.[0]).not.toHaveProperty('parentResourceId'); + expect(runSubAgent.mock.calls[0]?.[0]).not.toHaveProperty('parentAbortSignal'); + }); + + it('forwards the parent run abort signal to the runner callback', async () => { + const runSubAgent = vi + .fn<(request: DelegateSubAgentRequest) => Promise>() + .mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' }); + const tool = createDelegateSubAgentTool({ runSubAgent }); + const controller = new AbortController(); + + await tool.handler?.(input, { runId: 'parent-run-1', abortSignal: controller.signal }); + + expect(runSubAgent).toHaveBeenCalledWith( + expect.objectContaining({ parentAbortSignal: controller.signal }), + ); + }); + + it('emits lifecycle events around runner callback execution', async () => { + const events: AgentEventData[] = []; + const tool = createDelegateSubAgentTool({ + runSubAgent: async () => + await Promise.resolve({ + status: 'completed', + taskPath: '/root/research_api', + runId: 'child-run-1', + threadId: 'child-thread-1', + answer: 'done', + usage: { + promptTokens: 3, + completionTokens: 2, + totalTokens: 5, + }, + finishReason: 'stop', + }), + }); + + await tool.handler?.(input, { + runId: 'parent-run-1', + toolCallId: 'tool-call-1', + emitEvent: (event) => events.push(event), + }); + + expect(events.map((event) => event.type)).toEqual([ + AgentEvent.SubAgentStarted, + AgentEvent.SubAgentCompleted, + ]); + expect(events[0]).toMatchObject({ + taskName: 'Research API', + taskPath: '/root/research_api_0', + parentRunId: 'parent-run-1', + parentToolCallId: 'tool-call-1', + }); + expect(events[1]).toMatchObject({ + status: 'completed', + runId: 'child-run-1', + threadId: 'child-thread-1', + usage: { totalTokens: 5 }, + finishReason: 'stop', + }); + }); + + it('tracks child count per parent run id', async () => { + const runSubAgent = vi + .fn<(request: DelegateSubAgentRequest) => Promise>() + .mockResolvedValue({ + status: 'completed', + taskPath: '/root/research_api', + runId: 'child-run-1', + answer: 'done', + }); + const tool = createDelegateSubAgentTool({ + policy: { maxChildren: 1 }, + runSubAgent, + }); + + await expect(tool.handler?.(input, { runId: 'parent-run-1' })).resolves.toMatchObject({ + status: 'completed', + }); + await expect(tool.handler?.(input, { runId: 'parent-run-1' })).resolves.toMatchObject({ + status: 'failed', + error: 'Sub-agent child count 2 exceeds maxChildren 1', + }); + await expect(tool.handler?.(input, { runId: 'parent-run-2' })).resolves.toMatchObject({ + status: 'completed', + }); + + expect(runSubAgent).toHaveBeenCalledTimes(2); + }); + + it('returns a failed output when the runner callback throws', async () => { + const events: AgentEventData[] = []; + const tool = createDelegateSubAgentTool({ + runSubAgent: async () => await Promise.reject(new Error('Runner failed')), + }); + + await expect( + tool.handler?.(input, { + runId: 'parent-run-1', + emitEvent: (event) => events.push(event), + }), + ).resolves.toMatchObject({ + status: 'failed', + taskPath: '/root/research_api_0', + answer: '', + error: 'Runner failed', + }); + expect(events[events.length - 1]).toMatchObject({ + type: AgentEvent.SubAgentCompleted, + status: 'failed', + error: 'Runner failed', + }); + }); + + it('returns a failed output for invalid task names', async () => { + const runSubAgent = vi.fn(); + const tool = createDelegateSubAgentTool({ runSubAgent }); + + await expect( + tool.handler?.({ ...input, taskName: '!!!' }, { runId: 'parent-run-1' }), + ).resolves.toMatchObject({ + status: 'failed', + answer: '', + error: 'Sub-agent task name must contain at least one alphanumeric character', + }); + expect(runSubAgent).not.toHaveBeenCalled(); + }); +}); + +describe('renderDelegateSubAgentPrompt', () => { + it('includes the goal and omits unset sections', () => { + const prompt = renderDelegateSubAgentPrompt({ goal: 'Find it.' }); + + expect(prompt).toContain('Goal:\nFind it.'); + expect(prompt).not.toContain('Context:'); + expect(prompt).not.toContain('Expected output:'); + }); + + it('includes context and expected output when provided', () => { + const prompt = renderDelegateSubAgentPrompt({ + goal: 'Find it.', + context: 'auth endpoints', + expectedOutput: 'a summary', + }); + + expect(prompt).toContain('Goal:\nFind it.'); + expect(prompt).toContain('Context:\nauth endpoints'); + expect(prompt).toContain('Expected output:\na summary'); + }); +}); + +describe('generateResultToDelegateSubAgentOutput', () => { + it('maps a successful GenerateResult to the tool output', () => { + const result: GenerateResult = { + runId: 'child-run-1', + messages: [ + { + role: 'assistant', + type: 'llm', + content: [ + { type: 'text', text: 'preamble' }, + { type: 'text', text: 'answer' }, + ], + }, + ], + finishReason: 'stop', + usage: { promptTokens: 3, completionTokens: 2, totalTokens: 5 }, + }; + + expect( + generateResultToDelegateSubAgentOutput('/root/research_api_0', result, 'child-thread-1'), + ).toEqual({ + status: 'completed', + taskPath: '/root/research_api_0', + runId: 'child-run-1', + threadId: 'child-thread-1', + answer: 'preamble\nanswer', + usage: { promptTokens: 3, completionTokens: 2, totalTokens: 5 }, + finishReason: 'stop', + }); + }); + + it('marks an errored result as failed', () => { + const result: GenerateResult = { + runId: 'child-run-2', + messages: [], + finishReason: 'error', + error: new Error('boom'), + }; + + expect(generateResultToDelegateSubAgentOutput('/root/x_0', result)).toMatchObject({ + status: 'failed', + answer: '', + error: 'boom', + }); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/__tests__/stream.test.ts b/packages/@n8n/agents/src/runtime/__tests__/stream.test.ts index 1e4bfd48f8b..868d8275bc3 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/stream.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/stream.test.ts @@ -3,6 +3,7 @@ import type { TextStreamPart, ToolSet } from 'ai'; import { convertChunk } from '../stream'; type ToolCallChunk = Extract, { type: 'tool-call' }>; +type ToolErrorChunk = Extract, { type: 'tool-error' }>; type ToolResultChunk = Extract, { type: 'tool-result' }>; describe('convertChunk — tool-call invalid/error handling', () => { @@ -138,3 +139,25 @@ describe('convertChunk — tool-result output passthrough', () => { }); }); }); + +describe('convertChunk — tool-error handling', () => { + it('maps provider-executed tool-error to tool-result with isError', () => { + const error = new Error('search failed'); + const chunk = { + type: 'tool-error', + toolCallId: 'tc-err', + toolName: 'web_search', + input: { query: 'n8n' }, + error, + providerExecuted: true, + } as unknown as ToolErrorChunk; + + expect(convertChunk(chunk)).toEqual({ + type: 'tool-result', + toolCallId: 'tc-err', + toolName: 'web_search', + output: error, + isError: true, + }); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/__tests__/sub-agent-task-path.test.ts b/packages/@n8n/agents/src/runtime/__tests__/sub-agent-task-path.test.ts new file mode 100644 index 00000000000..05f6c48feba --- /dev/null +++ b/packages/@n8n/agents/src/runtime/__tests__/sub-agent-task-path.test.ts @@ -0,0 +1,74 @@ +import { + ROOT_SUB_AGENT_TASK_PATH, + assertSubAgentPolicyAllowsChild, + assertSubAgentPolicyAllowsChildCount, + assertSubAgentTaskPath, + createChildSubAgentTaskPath, + isSubAgentTaskPath, + sanitizeSubAgentTaskName, +} from '../sub-agent-task-path'; + +describe('sub-agent task paths', () => { + it('sanitizes display task names into path segments', () => { + expect(sanitizeSubAgentTaskName('Research API')).toBe('research_api'); + expect(sanitizeSubAgentTaskName('Check tests!!!')).toBe('check_tests'); + expect(sanitizeSubAgentTaskName('__Already---Messy__')).toBe('already_messy'); + }); + + it('rejects task names without alphanumeric content', () => { + expect(() => sanitizeSubAgentTaskName(' ')).toThrow('task name'); + expect(() => sanitizeSubAgentTaskName('!!!')).toThrow('task name'); + }); + + it('recognizes valid flat task paths', () => { + expect(isSubAgentTaskPath(ROOT_SUB_AGENT_TASK_PATH)).toBe(true); + expect(isSubAgentTaskPath('/root/research')).toBe(true); + }); + + it('rejects nested task paths', () => { + expect(isSubAgentTaskPath('/root/research/check_tests')).toBe(false); + }); + + it('rejects malformed task paths', () => { + for (const path of [ + '', + 'root', + '/', + '/root/', + '/root//child', + '/root/../child', + '/Root/child', + '/root/child with spaces', + '/root/research/check_tests', + ]) { + expect(isSubAgentTaskPath(path)).toBe(false); + expect(() => assertSubAgentTaskPath(path)).toThrow('Invalid sub-agent task path'); + } + }); + + it('creates first-level child paths under root', () => { + expect(createChildSubAgentTaskPath('Research API', 0)).toBe('/root/research_api_0'); + }); + + it('disambiguates same-named siblings by child index', () => { + const first = createChildSubAgentTaskPath('research', 0); + const second = createChildSubAgentTaskPath('research', 1); + expect(first).toBe('/root/research_0'); + expect(second).toBe('/root/research_1'); + expect(first).not.toBe(second); + }); + + it('enforces spawn policy before creating a child', () => { + expect(() => assertSubAgentPolicyAllowsChild({ canSpawnSubAgents: false })).toThrow( + 'does not allow', + ); + expect(() => assertSubAgentPolicyAllowsChild(undefined)).not.toThrow(); + }); + + it('enforces max child count policy', () => { + expect(() => assertSubAgentPolicyAllowsChildCount(1, { maxChildren: 2 })).not.toThrow(); + expect(() => assertSubAgentPolicyAllowsChildCount(2, { maxChildren: 2 })).toThrow( + 'exceeds maxChildren', + ); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/__tests__/tool-adapter.test.ts b/packages/@n8n/agents/src/runtime/__tests__/tool-adapter.test.ts index 908c7cdc7e5..505540dcf6b 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/tool-adapter.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/tool-adapter.test.ts @@ -2,7 +2,7 @@ import type { JSONSchema7 } from 'json-schema'; import { z } from 'zod'; import type { BuiltTool } from '../../types'; -import { toAiSdkTools } from '../tool-adapter'; +import { executeTool, toAiSdkTools } from '../tool-adapter'; // --------------------------------------------------------------------------- // Module mocks @@ -191,3 +191,34 @@ describe('toAiSdkTools — description forwarding', () => { expect((result['myTool'] as { description: string }).description).toBe('Does something useful'); }); }); + +// --------------------------------------------------------------------------- +// executeTool — context propagation +// --------------------------------------------------------------------------- + +describe('executeTool — context propagation', () => { + it('passes the run abort signal to the tool handler', async () => { + const handler = vi.fn().mockResolvedValue('ok'); + const tool: BuiltTool = { name: 'cancellable', description: 'd', handler }; + const { signal } = new AbortController(); + + await executeTool({}, tool, undefined, undefined, 'call-1', { abortSignal: signal }); + + expect(handler).toHaveBeenCalledWith({}, expect.objectContaining({ abortSignal: signal })); + }); + + it('passes the run abort signal to interruptible tool handlers', async () => { + const handler = vi.fn().mockResolvedValue('ok'); + const tool: BuiltTool = { + name: 'interruptible', + description: 'd', + handler, + suspendSchema: z.object({}), + }; + const { signal } = new AbortController(); + + await executeTool({}, tool, undefined, undefined, 'call-1', { abortSignal: signal }); + + expect(handler).toHaveBeenCalledWith({}, expect.objectContaining({ abortSignal: signal })); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/agent-runtime.ts b/packages/@n8n/agents/src/runtime/agent-runtime.ts index c4bad9afa57..56dddf14b62 100644 --- a/packages/@n8n/agents/src/runtime/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/agent-runtime.ts @@ -30,7 +30,6 @@ import type { SerializableAgentState, StreamChunk, StreamResult, - SubAgentUsage, ThinkingConfig, TitleGenerationConfig, TokenUsage, @@ -66,7 +65,6 @@ import { hasObservationLogStore, hasObservationLogTaskLockStore } from './observ import { generateRunId, RunStateManager } from './run-state'; import { accumulateUsage, - applySubAgentUsage, extractSettledToolCalls, makeErrorStream, normalizeInput, @@ -78,7 +76,6 @@ import { generateThreadTitle } from './title-generation'; import { buildToolMap, executeTool, - isAgentToolResult, isSuspendedToolResult, toAiSdkProviderTools, toAiSdkTools, @@ -262,7 +259,6 @@ type ToolCallOutcome = * LLM saw (rather than the larger raw output). */ modelOutput: unknown; - subAgentUsage?: SubAgentUsage[]; customMessage?: AgentMessage; } | { @@ -280,7 +276,6 @@ interface ToolCallSuccess { input: JSONValue; toolEntry: ToolResultEntry; modelOutput: unknown; - subAgentUsage?: SubAgentUsage[]; customMessage?: AgentMessage; } @@ -803,10 +798,9 @@ export class AgentRuntime { result.runId = runId; result.usage = this.applyCost(result.usage); result.model = this.modelIdString; - const finalized = applySubAgentUsage(result); this.updateState({ status: 'success', messageList: list.serialize() }); - this.eventBus.emit({ type: AgentEvent.AgentEnd, messages: finalized.messages }); - return finalized; + this.eventBus.emit({ type: AgentEvent.AgentEnd, messages: result.messages }); + return result; } /** Resolve telemetry: own config wins, then inherited from options, then nothing. */ @@ -1011,7 +1005,6 @@ export class AgentRuntime { let lastFinishReason: FinishReason = 'stop'; let structuredOutput: unknown; const toolCallSummary: ToolResultEntry[] = []; - const collectedSubAgentUsage: SubAgentUsage[] = []; // Resolve pending tool calls from a resumed run before the first LLM call. const runTelemetry = this.resolveTelemetry(options); @@ -1044,7 +1037,6 @@ export class AgentRuntime { for (const r of batch.results) { toolCallSummary.push(r.toolEntry); - if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage); } if (Object.keys(batch.pending).length > 0) { @@ -1132,7 +1124,6 @@ export class AgentRuntime { for (const r of batch.results) { toolCallSummary.push(r.toolEntry); - if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage); } if (Object.keys(batch.pending).length > 0) { @@ -1195,7 +1186,6 @@ export class AgentRuntime { usage: totalUsage, ...(structuredOutput !== undefined && { structuredOutput }), ...(toolCallSummary.length > 0 && { toolCalls: toolCallSummary }), - ...(collectedSubAgentUsage.length > 0 && { subAgentUsage: collectedSubAgentUsage }), }; } @@ -1212,20 +1202,45 @@ export class AgentRuntime { // can show a mid-flight indicator between the LLM's tool-call message // and the eventual tool-result message. Writer queues writes in order // so the fire-and-forget is safe. - const onToolExecutionStart = (data: AgentEventData): void => { - if (data.type !== AgentEvent.ToolExecutionStart) return; + const writeEventChunk = (chunk: StreamChunk): void => { // Swallow rejections: if the writer is already closed/errored (e.g. // an abort raced ahead of the subscription cleanup) there is nothing // useful to do with the chunk. - writer - .write({ - type: 'tool-execution-start', - toolCallId: data.toolCallId, - toolName: data.toolName, - }) - .catch(() => {}); + writer.write(chunk).catch(() => {}); + }; + const onToolExecutionStart = (data: AgentEventData): void => { + if (data.type !== AgentEvent.ToolExecutionStart) return; + writeEventChunk({ + type: 'tool-execution-start', + toolCallId: data.toolCallId, + toolName: data.toolName, + startTime: Date.now(), + }); + }; + const onToolExecutionEnd = (data: AgentEventData): void => { + if (data.type !== AgentEvent.ToolExecutionEnd) return; + writeEventChunk({ + type: 'tool-execution-end', + toolCallId: data.toolCallId, + toolName: data.toolName, + isError: data.isError, + endTime: Date.now(), + }); + }; + const onSubAgentStarted = (data: AgentEventData): void => { + if (data.type !== AgentEvent.SubAgentStarted) return; + const { type: _type, ...payload } = data; + writeEventChunk({ type: 'subagent-started', ...payload }); + }; + const onSubAgentCompleted = (data: AgentEventData): void => { + if (data.type !== AgentEvent.SubAgentCompleted) return; + const { type: _type, ...payload } = data; + writeEventChunk({ type: 'subagent-completed', ...payload }); }; this.eventBus.on(AgentEvent.ToolExecutionStart, onToolExecutionStart); + this.eventBus.on(AgentEvent.ToolExecutionEnd, onToolExecutionEnd); + this.eventBus.on(AgentEvent.SubAgentStarted, onSubAgentStarted); + this.eventBus.on(AgentEvent.SubAgentCompleted, onSubAgentCompleted); this.withTelemetryRootSpan( 'stream', @@ -1246,6 +1261,9 @@ export class AgentRuntime { }) .finally(() => { this.eventBus.off(AgentEvent.ToolExecutionStart, onToolExecutionStart); + this.eventBus.off(AgentEvent.ToolExecutionEnd, onToolExecutionEnd); + this.eventBus.off(AgentEvent.SubAgentStarted, onSubAgentStarted); + this.eventBus.off(AgentEvent.SubAgentCompleted, onSubAgentCompleted); }); return readable; @@ -1265,7 +1283,6 @@ export class AgentRuntime { let totalUsage: TokenUsage | undefined; let lastFinishReason: FinishReason = 'stop'; let structuredOutput: unknown; - const collectedSubAgentUsage: SubAgentUsage[] = []; const maxIterations = options?.maxIterations ?? MAX_LOOP_ITERATIONS; let iterationCount = options?.iterationCount ?? 0; let reachedStopCondition = false; @@ -1312,7 +1329,6 @@ export class AgentRuntime { }); for (const r of batch.results) { - if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage); await writer.write({ type: 'tool-result', toolCallId: r.toolCallId, @@ -1398,8 +1414,37 @@ export class AgentRuntime { // `start-step` / `finish-step` are passed through so consumers // can use them as LLM-iteration boundaries. if (chunk.type === 'finish') continue; + + // Provider-executed tools (e.g. native web search) skip the + // local execution loop that emits tool-execution lifecycle + // events via the event bus. Stamp them here at chunk-arrival + // time so live chat and the persisted timeline both show a + // duration. A failed call arrives as a `tool-error` part + // (never a `tool-result`), so close its timing there too. + if ( + (chunk.type === 'tool-result' || chunk.type === 'tool-error') && + chunk.providerExecuted + ) { + await writeChunk({ + type: 'tool-execution-end', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName ?? '', + isError: chunk.type === 'tool-error', + endTime: Date.now(), + }); + } + const converted = convertChunk(chunk); if (converted) await writeChunk(converted); + + if (chunk.type === 'tool-call' && chunk.providerExecuted) { + await writeChunk({ + type: 'tool-execution-start', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName ?? '', + startTime: Date.now(), + }); + } } } catch (streamError) { if (await handleAbort()) return; @@ -1452,7 +1497,6 @@ export class AgentRuntime { if (await handleAbort()) return; for (const r of batch.results) { - if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage); await writer.write({ type: 'tool-result', toolCallId: r.toolCallId, @@ -1513,18 +1557,12 @@ export class AgentRuntime { } const costUsage = this.applyCost(totalUsage); - const parentCost = costUsage?.cost ?? 0; - const subCost = collectedSubAgentUsage.reduce((sum, s) => sum + (s.usage.cost ?? 0), 0); await writer.write({ type: 'finish', finishReason: lastFinishReason, ...(costUsage && { usage: costUsage }), model: this.modelIdString, ...(structuredOutput !== undefined && { structuredOutput }), - ...(collectedSubAgentUsage.length > 0 && { - subAgentUsage: collectedSubAgentUsage, - totalCost: parentCost + subCost, - }), }); try { @@ -1996,7 +2034,6 @@ export class AgentRuntime { input: toolInput, toolEntry: result.value.toolEntry, modelOutput: result.value.modelOutput, - subAgentUsage: result.value.subAgentUsage, customMessage: result.value.customMessage, }); } else if (result.value.outcome === 'error') { @@ -2099,7 +2136,6 @@ export class AgentRuntime { input: resumedEntry.input, toolEntry: processResult.toolEntry, modelOutput: processResult.modelOutput, - subAgentUsage: processResult.subAgentUsage, customMessage: processResult.customMessage, }); } else if (processResult.outcome === 'error') { @@ -2261,6 +2297,8 @@ export class AgentRuntime { await executeTool(toolInput, builtTool, resumeData, resolvedTelemetry, toolCallId, { runId, persistence, + emitEvent: (event) => this.eventBus.emit(event), + abortSignal: this.eventBus.signal, }), ); } catch (error) { @@ -2292,30 +2330,21 @@ export class AgentRuntime { }; } - let actualResult = toolResult; - let extractedSubAgentUsage: SubAgentUsage[] | undefined; - if (isAgentToolResult(toolResult)) { - actualResult = toolResult.output; - extractedSubAgentUsage = toolResult.subAgentUsage; - } - this.eventBus.emit({ type: AgentEvent.ToolExecutionEnd, toolCallId, toolName, - result: actualResult, + result: toolResult, isError: false, }); // Apply toModelOutput transform: the raw result goes to history/events, // but the transformed version is what the LLM sees as the tool result. - const modelResult = builtTool.toModelOutput - ? builtTool.toModelOutput(actualResult) - : actualResult; + const modelResult = builtTool.toModelOutput ? builtTool.toModelOutput(toolResult) : toolResult; list.setToolCallResult(toolCallId, toJsonValue(modelResult)); - const customMessage = builtTool?.toMessage?.(actualResult); + const customMessage = builtTool?.toMessage?.(toolResult); if (customMessage) { list.addResponse([customMessage]); } @@ -2325,11 +2354,10 @@ export class AgentRuntime { toolEntry: { tool: toolName, input: toolInput, - output: actualResult, + output: toolResult, transformed: !!builtTool.toModelOutput, }, modelOutput: modelResult, - subAgentUsage: extractedSubAgentUsage, customMessage, }; } @@ -2557,7 +2585,7 @@ export class AgentRuntime { /** * Configured telemetry handle (build-time). Run-time inheritance via - * `ExecutionOptions.parentTelemetry` only applies inside an active + * `ExecutionOptions.telemetry` only applies inside an active * agentic loop; out-of-band callers like `agent.reflect()` see the * builder-time value. */ diff --git a/packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts b/packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts new file mode 100644 index 00000000000..83ba3007e0a --- /dev/null +++ b/packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts @@ -0,0 +1,417 @@ +import { z } from 'zod'; + +import { + assertSubAgentPolicyAllowsChild, + assertSubAgentPolicyAllowsChildCount, + createChildSubAgentTaskPath, + type SubAgentTaskPath, + type SubAgentTaskPathPolicy, +} from './sub-agent-task-path'; +import { filterLlmMessages } from '../sdk/message'; +import { Tool } from '../sdk/tool'; +import { AgentEvent } from '../types/runtime/event'; +import type { FinishReason, GenerateResult, TokenUsage } from '../types/sdk/agent'; +import type { AgentMessage } from '../types/sdk/message'; +import type { ToolContext } from '../types/sdk/tool'; + +export const DELEGATE_SUB_AGENT_TOOL_NAME = 'delegate_subagent'; + +// Model-facing input: the arguments the LLM fills in when it calls the tool. +// The `.describe(...)` text is what the model reads, so keep it task-oriented. +const delegateSubAgentInputSchema = z.object({ + subAgentId: z + .string() + .min(1) + .optional() + .describe('Configured sub-agent ID to run. Use when multiple sub-agents are available.'), + taskName: z + .string() + .min(1) + .describe('Short human-readable name for this delegated task, e.g. "research_api".'), + goal: z.string().min(1).describe('The concrete goal the sub-agent should accomplish.'), + context: z + .string() + .optional() + .describe( + 'All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.', + ), + expectedOutput: z.string().optional().describe('The expected shape or contents of the answer.'), +}); + +// Documents the tool result shape for typing/introspection. Note: the handler's +// returned object (not this schema) is what is actually sent back to the model, +// so this is kept in sync with DelegateSubAgentToolOutput by hand. +const delegateSubAgentOutputSchema = z.object({ + status: z.enum(['completed', 'failed']), + taskPath: z.string().optional(), + runId: z.string().optional(), + threadId: z.string().optional(), + answer: z.string(), + structuredOutput: z.unknown().optional(), + usage: z + .object({ + promptTokens: z.number().optional(), + completionTokens: z.number().optional(), + totalTokens: z.number().optional(), + cost: z.number().optional(), + }) + .optional(), + finishReason: z.string().optional(), + error: z.string().optional(), +}); + +/** The arguments the LLM provides when calling delegate_subagent. */ +export type DelegateSubAgentInput = z.infer; + +/** + * Limits the delegate tool enforces structurally for a delegation: fan-out and + * the on/off switch (see {@link SubAgentTaskPathPolicy}). + * + * Per-run runtime constraints (e.g. a wall-clock timeout) are intentionally not + * here — they're a host concern, enforced inside the `runSubAgent` callback (as + * the n8n CLI runner does). + */ +export type DelegateSubAgentPolicy = SubAgentTaskPathPolicy; + +/** + * What a host's `runSubAgent` callback receives: the model's + * {@link DelegateSubAgentInput} plus runtime-derived context the host needs to + * run and link the child. All `parent*` fields come from the parent's tool + * execution context and are used for tracing/linkage, not required to run. + */ +export interface DelegateSubAgentRequest extends DelegateSubAgentInput { + /** Hierarchical id assigned to this delegation (e.g. `/root/research_api`). */ + taskPath: SubAgentTaskPath; + /** Parent run id (`ctx.runId`), e.g. for memory scoping / correlation. */ + parentRunId?: string; + /** Parent's persisted memory thread id (`ctx.persistence.threadId`). */ + parentThreadId?: string; + /** Parent's episodic-memory resource id (`ctx.persistence.resourceId`). */ + parentResourceId?: string; + /** Parent's tool-call id that triggered this delegation. */ + parentToolCallId?: string; + /** + * Parent run's abort signal (`ctx.abortSignal`). Forward it to the child so + * cancelling the parent run also cancels the delegated work. + */ + parentAbortSignal?: AbortSignal; + /** How many siblings the parent already spawned before this one (0-based). */ + childCount: number; + /** Effective policy for this delegation. */ + policy?: DelegateSubAgentPolicy; +} + +/** The result a delegation returns to the parent model and to lifecycle events. */ +export interface DelegateSubAgentToolOutput { + status: 'completed' | 'failed'; + /** Echoed back so consumers can correlate the result with the delegation. */ + taskPath?: SubAgentTaskPath; + /** The child run's id, when the executor produced one. */ + runId?: string; + /** + * The child run's memory thread id (`persistence.threadId`), when the + * executor used one. Surfaced so a consumer can correlate the child run or + * re-supply it to continue the same thread on a later delegation. + */ + threadId?: string; + /** The child's answer — the main payload the parent acts on. */ + answer: string; + structuredOutput?: unknown; + /** Child token usage + cost, surfaced so the parent can account for it. */ + usage?: Pick; + finishReason?: FinishReason; + /** Present when status is 'failed'. */ + error?: string; +} + +/** + * Options for the `delegate_subagent` tool. + * + * You supply `runSubAgent` — the host callback that actually runs the child for + * a delegation and returns its result. Everything else (input/output schema, + * system prompt, task-path bookkeeping, policy enforcement, and the + * `subagent-started` / `-completed` lifecycle events) is owned by + * the tool. + */ +export interface CreateDelegateSubAgentToolOptions { + /** + * Sub-agents the model may choose between. Listed in the system prompt; the + * model selects one by passing its id as `subAgentId`. + */ + availableSubAgents?: Array<{ id: string; name: string; description?: string }>; + /** Fan-out limits and spawn switch enforced before each delegation. */ + policy?: DelegateSubAgentPolicy; + /** Run the child for this delegation and return its result. */ + runSubAgent: (request: DelegateSubAgentRequest) => Promise; +} + +/** + * Build the generic `delegate_subagent` tool — lets a parent agent hand a + * bounded subtask to a child agent and get back a concise result. + * + * The tool owns the cross-cutting concerns: the model-facing input/output + * schema, the description + system instruction that teach the LLM when/how to + * delegate, task-path bookkeeping, policy enforcement (fan-out / + * canSpawnSubAgents), and the `subagent-started` / `-completed` + * lifecycle events. You only supply HOW to run the child, via `runSubAgent`. + * + * @example Host-controlled execution (what the n8n CLI does): + * agent.tool(createDelegateSubAgentTool({ + * runSubAgent: (request) => runner.run(request), + * availableSubAgents, + * policy: { maxChildren: 5 }, + * })); + */ +export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOptions) { + // Per-parent fan-out counter keyed by run/thread/task — drives maxChildren. + const childCounts = new Map(); + + return new Tool(DELEGATE_SUB_AGENT_TOOL_NAME) + .description( + 'Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context (it sees only what you pass in) and returns a concise result. ' + + 'Reach for it when a subtask needs substantial search/research/review/reasoning whose intermediate output would clutter your context, or clearly benefits from a fresh perspective. ' + + 'Do not use it for trivial work, for steps that depend on this conversation you cannot restate, or to forward the user request wholesale instead of decomposing it.', + ) + .systemInstruction( + [ + 'delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. The child cannot see this conversation, your tools, or your memory, so everything it needs must be in the call.', + 'Delegate only when all of these hold: the work is a concrete, self-contained subtask; it can be fully specified without unstated context from this conversation; and it is heavy enough (substantial search/research/review/reasoning) that doing it inline would clutter your context, or a fresh perspective clearly helps.', + 'Do not delegate when: the task is trivial or is one or two tool calls you can make directly; it is the core reasoning you are responsible for (never delegate the understanding); it depends on context you cannot restate; or you would just forward the user request without decomposing it. Wanting more thoroughness or research is not by itself a reason to delegate.', + 'Write the handoff for a smart colleague who just walked in and has seen none of this conversation: put the concrete outcome in goal; put every detail the child needs in context (constraints, paths, data, prior decisions, acceptance criteria, what you have already tried or ruled out); state exactly what you need back, and how concise, in expectedOutput; and give it a short descriptive taskName.', + ...formatAvailableSubAgents(options.availableSubAgents), + 'When the child returns: inspect the answer before relying on it, do not blindly trust self-reported success, synthesize it into your own response instead of copying it verbatim, and if it is incomplete or failed either retry with a sharper handoff or do the task yourself.', + ].join('\n'), + ) + .input(delegateSubAgentInputSchema) + .output(delegateSubAgentOutputSchema) + .handler(async (input, ctx) => await handleDelegateSubAgent(input, ctx, options, childCounts)) + .build(); +} + +function formatAvailableSubAgents( + availableSubAgents: CreateDelegateSubAgentToolOptions['availableSubAgents'], +): string[] { + if (!availableSubAgents?.length) return []; + + return [ + 'Configured subagents are available. Pick the most relevant one and pass its id as subAgentId:', + ...availableSubAgents.map((subAgent) => { + const description = subAgent.description ? ` - ${subAgent.description}` : ''; + return `- ${subAgent.id}: ${subAgent.name}${description}`; + }), + ]; +} + +/** + * Tool handler: enforce policy (fan-out), assign the child's task path, + * assemble the {@link DelegateSubAgentRequest} from the model input plus the + * parent tool context, then run the child via the host `runSubAgent` callback + * while emitting started/progress/completed lifecycle events. Any error is + * converted into a `status: 'failed'` output (never thrown) so one failed + * delegation can't abort the parent's run. + */ +async function handleDelegateSubAgent( + input: DelegateSubAgentInput, + ctx: ToolContext, + options: CreateDelegateSubAgentToolOptions, + childCounts: Map, +): Promise { + let taskPath: SubAgentTaskPath | undefined; + let request: DelegateSubAgentRequest | undefined; + let startedAt: number | undefined; + try { + assertSubAgentPolicyAllowsChild(options.policy); + const childCountKey = getChildCountKey(ctx); + const childCount = childCounts.get(childCountKey) ?? 0; + assertSubAgentPolicyAllowsChildCount(childCount, options.policy); + + taskPath = createChildSubAgentTaskPath(input.taskName, childCount); + childCounts.set(childCountKey, childCount + 1); + + request = { + ...input, + taskPath, + childCount, + ...(ctx.runId !== undefined ? { parentRunId: ctx.runId } : {}), + ...(ctx.persistence?.threadId !== undefined + ? { parentThreadId: ctx.persistence.threadId } + : {}), + ...(ctx.persistence?.resourceId !== undefined + ? { parentResourceId: ctx.persistence.resourceId } + : {}), + ...(ctx.abortSignal !== undefined ? { parentAbortSignal: ctx.abortSignal } : {}), + ...(ctx.toolCallId !== undefined ? { parentToolCallId: ctx.toolCallId } : {}), + ...(options.policy !== undefined ? { policy: options.policy } : {}), + }; + + startedAt = Date.now(); + emitSubAgentStarted(ctx, request, startedAt); + const output = await options.runSubAgent(request); + emitSubAgentCompleted(ctx, request, output, startedAt); + return output; + } catch (error) { + if (request !== undefined && startedAt !== undefined) { + emitSubAgentCompleted( + ctx, + request, + { + status: 'failed', + ...(taskPath !== undefined ? { taskPath } : {}), + answer: '', + error: stringifyUnknown(error), + }, + startedAt, + ); + } + return { + status: 'failed', + ...(taskPath !== undefined ? { taskPath } : {}), + answer: '', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function emitSubAgentStarted( + ctx: ToolContext, + request: DelegateSubAgentRequest, + startedAt: number, +): void { + ctx.emitEvent?.({ + type: AgentEvent.SubAgentStarted, + ...subAgentLifecycleBase(request), + startedAt, + }); +} + +function emitSubAgentCompleted( + ctx: ToolContext, + request: DelegateSubAgentRequest, + output: DelegateSubAgentToolOutput, + startedAt: number, +): void { + const finishedAt = Date.now(); + ctx.emitEvent?.({ + type: AgentEvent.SubAgentCompleted, + ...subAgentLifecycleBase(request), + status: output.status, + startedAt, + finishedAt, + durationMs: finishedAt - startedAt, + ...(output.runId !== undefined ? { runId: output.runId } : {}), + ...(output.threadId !== undefined ? { threadId: output.threadId } : {}), + ...(output.usage !== undefined ? { usage: output.usage } : {}), + ...(output.finishReason !== undefined ? { finishReason: output.finishReason } : {}), + ...(output.error !== undefined ? { error: output.error } : {}), + }); +} + +function subAgentLifecycleBase(request: DelegateSubAgentRequest) { + return { + taskName: request.taskName, + taskPath: request.taskPath, + ...(request.parentRunId !== undefined ? { parentRunId: request.parentRunId } : {}), + ...(request.parentToolCallId !== undefined + ? { parentToolCallId: request.parentToolCallId } + : {}), + ...(request.subAgentId !== undefined ? { subAgentId: request.subAgentId } : {}), + }; +} + +function getChildCountKey(ctx: ToolContext): string { + return ctx.runId ?? ctx.persistence?.threadId ?? ctx.persistence?.resourceId ?? 'adhoc'; +} + +function stringifyUnknown(value: unknown): string { + if (value instanceof Error) return value.message; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || value === null) { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return 'Unknown error'; + } +} + +/** + * Optional helpers for a `runSubAgent` implementation. + * + * A host that runs the child by calling `agent.generate(...)`/`stream(...)` can + * reuse these instead of hand-rolling the delegation prompt and the result + * mapping. They are NOT wired into the tool — call them from your `runSubAgent` + * (the n8n CLI runner does). + */ + +/** Render the default delegation prompt from a request's goal / context / expectedOutput. */ +export function renderDelegateSubAgentPrompt(request: { + goal: string; + context?: string; + expectedOutput?: string; +}): string { + const sections = [ + 'You are running as a delegated sub-agent on a single, self-contained task. You have no access to the parent conversation beyond what is written below, and you cannot ask follow-up questions during this run. Complete the task independently and reply with a concise, self-contained answer.', + `Goal:\n${request.goal}`, + ]; + + if (request.context) { + sections.push(`Context:\n${request.context}`); + } + + if (request.expectedOutput) { + sections.push(`Expected output:\n${request.expectedOutput}`); + } + + sections.push( + 'If the information above is insufficient, do your best with explicitly stated assumptions and note what was missing, rather than stopping to ask.', + ); + + return sections.join('\n\n'); +} + +/** Map an agent {@link GenerateResult} into the delegate tool's output shape. */ +export function generateResultToDelegateSubAgentOutput( + taskPath: SubAgentTaskPath, + result: GenerateResult, + threadId?: string, +): DelegateSubAgentToolOutput { + return { + status: result.finishReason === 'error' || result.error !== undefined ? 'failed' : 'completed', + taskPath, + runId: result.runId, + ...(threadId !== undefined ? { threadId } : {}), + answer: lastText(result.messages), + ...(result.structuredOutput !== undefined ? { structuredOutput: result.structuredOutput } : {}), + ...(result.usage !== undefined + ? { + usage: { + promptTokens: result.usage.promptTokens, + completionTokens: result.usage.completionTokens, + totalTokens: result.usage.totalTokens, + ...(result.usage.cost !== undefined ? { cost: result.usage.cost } : {}), + }, + } + : {}), + ...(result.finishReason !== undefined ? { finishReason: result.finishReason } : {}), + ...(result.error !== undefined ? { error: stringifyUnknown(result.error) } : {}), + }; +} + +/** Last non-empty assistant text across the run's messages. */ +function lastText(messages: AgentMessage[]): string { + const llmMessages = filterLlmMessages(messages); + for (let i = llmMessages.length - 1; i >= 0; i--) { + const message = llmMessages[i]; + if (!message) continue; + + const text = message.content + .filter((content) => content.type === 'text') + .map((content) => content.text) + .join('\n') + .trim(); + if (text) return text; + } + + return ''; +} diff --git a/packages/@n8n/agents/src/runtime/runtime-helpers.ts b/packages/@n8n/agents/src/runtime/runtime-helpers.ts index 268ac7eb391..52c6d209e05 100644 --- a/packages/@n8n/agents/src/runtime/runtime-helpers.ts +++ b/packages/@n8n/agents/src/runtime/runtime-helpers.ts @@ -2,7 +2,7 @@ * Pure utility functions used by AgentRuntime that require no class context. * These are extracted here to keep agent-runtime.ts focused on orchestration logic. */ -import type { GenerateResult, StreamChunk, TokenUsage } from '../types'; +import type { StreamChunk, TokenUsage } from '../types'; import { toTokenUsage } from './stream'; import type { AgentMessage, ContentToolCall } from '../types/sdk/message'; @@ -95,13 +95,3 @@ export function accumulateUsage( if (!raw) return current; return mergeUsage(current, toTokenUsage(raw)); } - -/** Compute totalCost from sub-agent usage already present on the result. */ -export function applySubAgentUsage(result: GenerateResult): GenerateResult { - if (!result.subAgentUsage || result.subAgentUsage.length === 0) return result; - - const parentCost = result.usage?.cost ?? 0; - const subCost = result.subAgentUsage.reduce((sum, s) => sum + (s.usage.cost ?? 0), 0); - - return { ...result, totalCost: parentCost + subCost }; -} diff --git a/packages/@n8n/agents/src/runtime/stream.ts b/packages/@n8n/agents/src/runtime/stream.ts index 4f29ecef104..35b9695c32c 100644 --- a/packages/@n8n/agents/src/runtime/stream.ts +++ b/packages/@n8n/agents/src/runtime/stream.ts @@ -108,6 +108,18 @@ export function convertChunk(c: TextStreamPart): StreamChunk | undefine output: c.output, }; + case 'tool-error': + // Provider-executed tools (e.g. native web search) surface failures + // as `tool-error` rather than `tool-result`. Map to our tool-result + // shape so stream consumers receive the error payload. + return { + type: 'tool-result', + toolCallId: c.toolCallId ?? '', + toolName: c.toolName ?? '', + output: c.error, + isError: true, + }; + case 'error': return { type: 'error', error: c.error }; diff --git a/packages/@n8n/agents/src/runtime/sub-agent-task-path.ts b/packages/@n8n/agents/src/runtime/sub-agent-task-path.ts new file mode 100644 index 00000000000..7e7aad6b60a --- /dev/null +++ b/packages/@n8n/agents/src/runtime/sub-agent-task-path.ts @@ -0,0 +1,149 @@ +/** + * Task paths for sub-agent delegation. + * + * A "task path" is a filesystem-like address that gives each delegated run a + * stable, human-readable identity, e.g.: + * + * /root ← the top-level (orchestrating) agent + * /root/research_api_0 ← a first-level delegated child + * + * Each child segment carries the parent's 0-based child index (`_0`, `_1`, …) so + * that delegations with the same task name stay distinct. + * + * Why this concept exists: + * - Identity: each delegated unit of work gets a unique, traceable name we can + * log and surface in the timeline — without the parent having to invent ids. + * (Memory/session ids are independent — a run gets its own thread id.) + * - Policy enforcement: together with {@link SubAgentTaskPathPolicy}, the path + * supports per-parent fan-out limits so a misbehaving agent cannot spawn + * hundreds of parallel children, which would blow up cost, latency, and + * resources. + * + * Nesting is not supported: only `/root` and single-segment child paths under + * `/root` are valid. Sub-agents cannot spawn other sub-agents. + * + * Everything in this file is pure (no I/O, no n8n-specific concepts), which is + * why it lives in the runtime SDK: it is shared verbatim by both the generic + * `delegate_subagent` tool and the n8n CLI runner. + */ + +/** + * A delegation task path: `/root` or `/root/` only. Modeled as a + * template-literal type so a plain string can be narrowed to a validated path + * via {@link assertSubAgentTaskPath}. + */ +export type SubAgentTaskPath = `/root${'' | `/${string}`}`; + +/** + * Guardrails applied when a parent tries to spawn a child sub-agent. Every limit + * is optional; an undefined field means "no limit for that dimension". + */ +export interface SubAgentTaskPathPolicy { + /** Maximum number of children a single parent may spawn. Bounds fan-out width. */ + maxChildren?: number; + /** Hard on/off switch: when false the parent may not delegate at all. */ + canSpawnSubAgents?: boolean; +} + +/** Top-level orchestrating agent path. */ +export const ROOT_SUB_AGENT_TASK_PATH = '/root' satisfies SubAgentTaskPath; + +/** Upper bound on a single path segment, so paths stay bounded and readable. */ +const MAX_TASK_NAME_LENGTH = 64; +/** Valid paths: `/root` or `/root/` — no nested delegation. */ +const SUB_AGENT_TASK_PATH_PATTERN = /^\/root(?:\/[a-z0-9_]+)?$/; + +/** + * Turn a free-text, model-supplied task name (e.g. "Research API pricing!") + * into a safe, deterministic path segment (e.g. "research_api_pricing"). + * + * The task name comes from the LLM, so it can contain anything. We normalize to + * lowercase, collapse each run of non-alphanumerics into a single underscore, + * strip leading/trailing underscores, and cap the length — producing segments + * that are collision-resistant, log/URL-safe, and accepted by + * {@link SUB_AGENT_TASK_PATH_PATTERN}. + * + * @throws if nothing alphanumeric survives (we refuse to build a nameless path). + */ +export function sanitizeSubAgentTaskName(taskName: string): string { + const sanitized = taskName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, MAX_TASK_NAME_LENGTH) + .replace(/_+$/g, ''); + + if (!sanitized) { + throw new Error('Sub-agent task name must contain at least one alphanumeric character'); + } + + return sanitized; +} + +/** Type guard: does this string match the flat `/root[/segment]?` shape? */ +export function isSubAgentTaskPath(value: string): value is SubAgentTaskPath { + return SUB_AGENT_TASK_PATH_PATTERN.test(value); +} + +/** + * Assert (and type-narrow) that a string is a valid task path. Used to validate + * paths that were constructed here or received from elsewhere before we rely on + * their shape. + */ +export function assertSubAgentTaskPath(value: string): asserts value is SubAgentTaskPath { + if (!isSubAgentTaskPath(value)) { + throw new Error(`Invalid sub-agent task path: ${value}`); + } +} + +/** + * Build a first-level child path: `/root/_`. + * + * `childCount` is the parent's 0-based index for this child (the number of + * children it had already spawned). Appending it disambiguates same-named + * siblings within a single parent run. + */ +export function createChildSubAgentTaskPath( + taskName: string, + childCount: number, +): SubAgentTaskPath { + const childPath = `${ROOT_SUB_AGENT_TASK_PATH}/${sanitizeSubAgentTaskName(taskName)}_${childCount}`; + assertSubAgentTaskPath(childPath); + + return childPath; +} + +/** + * Spawn gate, checked BEFORE a child is spawned. + * + * Rejects when delegation is switched off outright (`canSpawnSubAgents === false`). + */ +export function assertSubAgentPolicyAllowsChild(policy: SubAgentTaskPathPolicy | undefined): void { + if (policy?.canSpawnSubAgents === false) { + throw new Error('Sub-agent policy does not allow spawning child sub-agents'); + } +} + +/** + * Fan-out-dimension gate, checked BEFORE a child is spawned. + * + * `childCount` is how many children the parent has ALREADY spawned. If that has + * reached `maxChildren`, spawning one more (the `+ 1` in the message is the + * would-be new total) exceeds the limit, so we reject. This stops a single agent + * from fanning out to an unbounded number of parallel sub-agents. When + * `maxChildren` is undefined, fan-out is unbounded. + */ +export function assertSubAgentPolicyAllowsChildCount( + childCount: number, + policy: SubAgentTaskPathPolicy | undefined, +): void { + if (policy?.maxChildren === undefined) return; + + if (childCount >= policy.maxChildren) { + throw new Error( + `Sub-agent child count ${childCount + 1} exceeds maxChildren ${policy.maxChildren}`, + ); + } +} diff --git a/packages/@n8n/agents/src/runtime/tool-adapter.ts b/packages/@n8n/agents/src/runtime/tool-adapter.ts index c190ff3b3d5..0286f08613d 100644 --- a/packages/@n8n/agents/src/runtime/tool-adapter.ts +++ b/packages/@n8n/agents/src/runtime/tool-adapter.ts @@ -11,7 +11,6 @@ import { type ToolExecutionContext, type ToolContext, } from '../types'; -import type { SubAgentUsage } from '../types/sdk/agent'; import { isZodSchema } from '../utils/zod'; type AiSdkProviderTool = AiSdkTool & { @@ -24,13 +23,6 @@ type AiSdkProviderTool = AiSdkTool & { */ const SUSPEND_BRAND = Symbol('SuspendBrand'); -/** - * Branded symbol used to tag tool results from agent-as-tool calls. - * Carries sub-agent usage so the parent runtime can aggregate costs - * without any external state (WeakMap, mutable tool fields, etc.). - */ -const AGENT_TOOL_BRAND = Symbol('AgentToolBrand'); - export interface SuspendedToolResult { readonly [SUSPEND_BRAND]: true; payload: unknown; @@ -41,32 +33,6 @@ export function isSuspendedToolResult(value: unknown): value is SuspendedToolRes return typeof value === 'object' && value !== null && SUSPEND_BRAND in value; } -export interface AgentToolResult { - readonly [AGENT_TOOL_BRAND]: true; - /** The actual tool output (passed back to the LLM). */ - readonly output: unknown; - /** Sub-agent usage entries to aggregate into the parent's result. */ - readonly subAgentUsage: SubAgentUsage[]; -} - -/** Type guard: returns true when a tool result carries sub-agent usage. */ -export function isAgentToolResult(value: unknown): value is AgentToolResult { - return typeof value === 'object' && value !== null && AGENT_TOOL_BRAND in value; -} - -/** - * Create a branded agent-tool result that carries sub-agent usage alongside the output. - * The output properties are spread onto the object so it remains a valid tool output - * even when accessed directly (e.g. in tests). The runtime detects the brand via - * isAgentToolResult() and extracts the sub-agent usage. - * Typed as `never` so `return createAgentToolResult(...)` satisfies any handler return type - * (same pattern as ctx.suspend). - */ -export function createAgentToolResult(output: unknown, subAgentUsage: SubAgentUsage[]): never { - const base = typeof output === 'object' && output !== null ? output : {}; - return { ...base, [AGENT_TOOL_BRAND]: true, output, subAgentUsage } as never; -} - /** * Convert an array of BuiltProviderTools into a Record of AI SDK provider-defined tool objects. * Provider tools are executed on the provider's infrastructure (e.g. Anthropic web search, @@ -162,6 +128,8 @@ export async function executeTool( toolCallId, runId: executionContext.runId, persistence: executionContext.persistence, + emitEvent: executionContext.emitEvent, + abortSignal: executionContext.abortSignal, }; return await builtTool.handler(args, ctx); } @@ -171,6 +139,8 @@ export async function executeTool( toolCallId, runId: executionContext.runId, persistence: executionContext.persistence, + emitEvent: executionContext.emitEvent, + abortSignal: executionContext.abortSignal, }; return await builtTool.handler(args, ctx); } diff --git a/packages/@n8n/agents/src/sdk/agent.ts b/packages/@n8n/agents/src/sdk/agent.ts index 53c8204c39e..905eca6b297 100644 --- a/packages/@n8n/agents/src/sdk/agent.ts +++ b/packages/@n8n/agents/src/sdk/agent.ts @@ -1,15 +1,14 @@ import type { ProviderOptions } from '@ai-sdk/provider-utils'; -import { z } from 'zod'; +import type { z } from 'zod'; import type { Eval } from './eval'; import type { McpClient } from './mcp-client'; import { Memory, normalizeMemoryConfig, resolveMemoryConfigDefaults } from './memory'; import { Telemetry } from './telemetry'; -import { Tool, wrapToolForApproval } from './tool'; +import { wrapToolForApproval } from './tool'; import { AgentRuntime } from '../runtime/agent-runtime'; import { LOAD_TOOL_TOOL_NAME, SEARCH_TOOLS_TOOL_NAME } from '../runtime/deferred-tool-manager'; import { AgentEventBus } from '../runtime/event-bus'; -import { createAgentToolResult } from '../runtime/tool-adapter'; import { appendSkillCatalogToInstructions, createRuntimeSkillSource, @@ -36,7 +35,6 @@ import type { RunOptions, SerializableAgentState, StreamResult, - SubAgentUsage, ThinkingConfig, ThinkingConfigFor, ResumeOptions, @@ -491,67 +489,6 @@ export class Agent implements BuiltAgent, AgentBuilder { this.eventBus.off(event, handler); } - /** - * Wrap this agent as a tool for use in multi-agent composition. - * The tool sends a text prompt to this agent and returns the text of the response. - * - * @example - * ```typescript - * const coordinatorAgent = new Agent('coordinator') - * .model('anthropic/claude-sonnet-4-5') - * .instructions('Route tasks to specialist agents.') - * .tool(writerAgent.asTool('Write content given a topic')); - * ``` - */ - asTool(description: string): BuiltTool { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const agent = this; - - const tool = new Tool(this.name) - .description(description) - .input( - z.object({ - input: z.string().describe('The input to send to the agent'), - }), - ) - .output( - z.object({ - result: z.string().describe('The result of the agent'), - }), - ) - .handler(async (rawInput, ctx) => { - const { input } = rawInput as { input: string }; - const result = await agent.generate(input, { - telemetry: ctx.parentTelemetry, - } as RunOptions & ExecutionOptions); - - const text = result.messages - .filter((m) => 'role' in m && m.role === 'assistant') - .flatMap((m) => ('content' in m ? m.content : [])) - .filter((c) => c.type === 'text') - .map((c) => ('text' in c ? c.text : '')) - .join(''); - - // Collect sub-agent usage: this agent's own + any nested sub-agents - const subAgentUsage: SubAgentUsage[] = []; - if (result.usage) { - subAgentUsage.push({ agent: agent.name, model: result.model, usage: result.usage }); - } - if (result.subAgentUsage) { - subAgentUsage.push(...result.subAgentUsage); - } - - // Return branded result — the runtime unwraps it to extract sub-agent usage. - // createAgentToolResult returns `never`, same pattern as ctx.suspend(). - if (subAgentUsage.length > 0) { - return createAgentToolResult({ result: text }, subAgentUsage); - } - return { result: text }; - }); - - return tool.build(); - } - /** * Return a lightweight read-only snapshot of the agent's configured state. * Useful for testing and debugging — does not trigger a build. diff --git a/packages/@n8n/agents/src/sdk/network.ts b/packages/@n8n/agents/src/sdk/network.ts deleted file mode 100644 index e4cbb951605..00000000000 --- a/packages/@n8n/agents/src/sdk/network.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Agent } from './agent'; -import type { GenerateResult, RunOptions } from '../types'; -import type { Message } from '../types/sdk/message'; - -interface BuiltNetwork { - readonly name: string; - run(prompt: string, options?: RunOptions): Promise; -} - -/** - * Builder for creating multi-agent networks with a coordinator. - * - * Usage: - * ```typescript - * const network = new Network('content-team') - * .coordinator(coordinatorAgent) - * .agent(researcher) - * .agent(writer); - * - * const result = await network.run('Research and write about RAG'); - * ``` - */ -export class Network { - private networkName: string; - - private coordinatorAgent?: Agent; - - private agents: Agent[] = []; - - private built?: BuiltNetwork; - - constructor(name: string) { - this.networkName = name; - } - - /** Set the coordinator agent that routes tasks to specialists. */ - coordinator(a: Agent): this { - this.coordinatorAgent = a; - return this; - } - - /** Add a specialist agent to the network. */ - agent(a: Agent): this { - this.agents.push(a); - return this; - } - - /** @internal Lazy-build the network on first use. */ - private ensureBuilt(): BuiltNetwork { - this.built ??= this.build(); - return this.built; - } - - /** The network name. */ - get name(): string { - return this.networkName; - } - - /** Run the network with a prompt. Lazy-builds on first call. */ - async run(prompt: string, options?: RunOptions): Promise { - return await this.ensureBuilt().run(prompt, options); - } - - /** @internal */ - protected build(): BuiltNetwork { - if (!this.coordinatorAgent) { - throw new Error(`Network "${this.networkName}" requires a coordinator`); - } - if (this.agents.length === 0) { - throw new Error(`Network "${this.networkName}" requires at least one agent`); - } - - // TODO: Specialist agents are stored for validation but not yet wired - // to the coordinator automatically. For now, specialists must be added - // as tools on the coordinator agent manually (via agent.asTool()). - // Multi-agent routing will be implemented in a future iteration. - - const coordinator = this.coordinatorAgent; - const name = this.networkName; - - return { - name, - - async run(prompt: string, options?: RunOptions): Promise { - const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: prompt }] }]; - return await coordinator.generate(messages, options); - }, - }; - } -} diff --git a/packages/@n8n/agents/src/types/index.ts b/packages/@n8n/agents/src/types/index.ts index 757fb0aaba9..a669cf4bd3d 100644 --- a/packages/@n8n/agents/src/types/index.ts +++ b/packages/@n8n/agents/src/types/index.ts @@ -41,7 +41,6 @@ export type { ResumeOptions, GenerateResult, StreamResult, - SubAgentUsage, BuiltAgent, AgentRunState, AgentResumeData, diff --git a/packages/@n8n/agents/src/types/runtime/event.ts b/packages/@n8n/agents/src/types/runtime/event.ts index 12251899eab..ac82779cbc3 100644 --- a/packages/@n8n/agents/src/types/runtime/event.ts +++ b/packages/@n8n/agents/src/types/runtime/event.ts @@ -1,5 +1,36 @@ +import type { FinishReason, TokenUsage } from '../sdk/agent'; import type { AgentMessage, ContentToolCall } from '../sdk/message'; +export type SubAgentLifecycleUsage = Pick< + TokenUsage, + 'promptTokens' | 'completionTokens' | 'totalTokens' | 'cost' +>; + +export interface SubAgentLifecycleBase { + taskName: string; + taskPath: string; + parentRunId?: string; + parentToolCallId?: string; + subAgentId?: string; +} + +export interface SubAgentStartedPayload extends SubAgentLifecycleBase { + startedAt: number; +} + +export interface SubAgentCompletedPayload extends SubAgentLifecycleBase { + status: 'completed' | 'failed'; + startedAt: number; + finishedAt: number; + durationMs: number; + runId?: string; + /** The child run's memory thread id (`persistence.threadId`), so consumers can correlate or continue it. */ + threadId?: string; + usage?: SubAgentLifecycleUsage; + finishReason?: FinishReason; + error?: string; +} + export const enum AgentEvent { AgentStart = 'agent_start', AgentEnd = 'agent_end', @@ -7,6 +38,8 @@ export const enum AgentEvent { TurnEnd = 'turn_end', ToolExecutionStart = 'tool_execution_start', ToolExecutionEnd = 'tool_execution_end', + SubAgentStarted = 'subagent_started', + SubAgentCompleted = 'subagent_completed', Error = 'error', } @@ -23,6 +56,8 @@ export type AgentEventData = result: unknown; isError: boolean; } + | ({ type: AgentEvent.SubAgentStarted } & SubAgentStartedPayload) + | ({ type: AgentEvent.SubAgentCompleted } & SubAgentCompletedPayload) | { type: AgentEvent.Error; message: string; diff --git a/packages/@n8n/agents/src/types/sdk/agent.ts b/packages/@n8n/agents/src/types/sdk/agent.ts index 5d977baa39c..c8e617af1cd 100644 --- a/packages/@n8n/agents/src/types/sdk/agent.ts +++ b/packages/@n8n/agents/src/types/sdk/agent.ts @@ -3,9 +3,13 @@ import type { LanguageModel } from 'ai'; import type { JsonSchema7Type } from 'zod-to-json-schema'; import type { AgentMessage, ContentMetadata } from './message'; -import type { BuiltTool } from './tool'; import type { ProviderId, ProviderCredentials } from '../../runtime/provider-credentials'; -import type { AgentEvent, AgentEventHandler } from '../runtime/event'; +import type { + AgentEvent, + AgentEventHandler, + SubAgentCompletedPayload, + SubAgentStartedPayload, +} from '../runtime/event'; import type { SerializedMessageList } from '../runtime/message-list'; import type { BuiltTelemetry } from '../telemetry'; import type { JSONValue } from '../utils/json'; @@ -90,6 +94,22 @@ export type StreamChunk = ContentMetadata & type: 'tool-execution-start'; toolCallId: string; toolName: string; + /** Epoch ms when the handler started, measured on the runtime. */ + startTime: number; + } + | { + /** + * Emitted as soon as an individual tool handler settles, bridged from + * the runtime event bus. Lets consumers flip a concurrent tool call to + * its terminal state immediately, instead of waiting for the batched + * `tool-result` chunks emitted only after the whole batch settles. + */ + type: 'tool-execution-end'; + toolCallId: string; + toolName: string; + isError: boolean; + /** Epoch ms when the handler settled, measured on the runtime. */ + endTime: number; } | { type: 'tool-result'; @@ -110,14 +130,14 @@ export type StreamChunk = ContentMetadata & } // `message` is reserved for sub-agent / app-defined `CustomAgentMessage` | { type: 'message'; message: AgentMessage } + | ({ type: 'subagent-started' } & SubAgentStartedPayload) + | ({ type: 'subagent-completed' } & SubAgentCompletedPayload) | { type: 'finish'; finishReason: FinishReason; usage?: TokenUsage; model?: string; structuredOutput?: unknown; - subAgentUsage?: SubAgentUsage[]; - totalCost?: number; } | { type: 'error'; error: unknown } ); @@ -136,7 +156,7 @@ export interface ExecutionOptions { maxIterations?: number; abortSignal?: AbortSignal; providerOptions?: ProviderOptions; - /** Inherited telemetry from a parent agent. Used internally by asTool(). */ + /** Inherited telemetry from a host runtime. */ telemetry?: BuiltTelemetry; /** Inherited execution counter from the host runtime. Used for aggregate heartbeat telemetry. */ executionCounter?: AgentExecutionCounter; @@ -153,16 +173,6 @@ export interface ToolResultEntry { transformed?: boolean; } -/** Token usage from a sub-agent called via .asTool(). */ -export interface SubAgentUsage { - /** Name of the sub-agent. */ - agent: string; - /** Model used by the sub-agent. */ - model?: string; - /** Token usage for the sub-agent call. */ - usage: TokenUsage; -} - export interface GenerateResult { /** Unique identifier for this run. Useful for HITL resume and correlation/logging. */ runId: string; @@ -175,10 +185,6 @@ export interface GenerateResult { providerMetadata?: Record; /** Tool calls made during the run (with merged results when available). */ toolCalls?: ToolResultEntry[]; - /** Token usage from sub-agents called via .asTool(). */ - subAgentUsage?: SubAgentUsage[]; - /** Total cost (USD) including this agent + all sub-agents. */ - totalCost?: number; /** * Present when the run suspended awaiting tool resume (HITL). * Call `agent.resume('generate', data, { runId, toolCallId })` to resume. @@ -226,8 +232,6 @@ export interface BuiltAgent { on(event: AgentEvent, handler: AgentEventHandler): void; - asTool(description: string): BuiltTool; - getState(): SerializableAgentState; /** Cancel the currently running agent. Synchronous — sets an abort flag that the agentic loop checks asynchronously. */ diff --git a/packages/@n8n/agents/src/types/sdk/tool.ts b/packages/@n8n/agents/src/types/sdk/tool.ts index 79b93a33fc0..6b8ed4e3288 100644 --- a/packages/@n8n/agents/src/types/sdk/tool.ts +++ b/packages/@n8n/agents/src/types/sdk/tool.ts @@ -2,6 +2,7 @@ import type { JSONSchema7 } from 'json-schema'; import type { ZodType } from 'zod'; import type { AgentMessage } from './message'; +import type { AgentEventData } from '../runtime/event'; import type { BuiltTelemetry } from '../telemetry'; import type { JSONObject } from '../utils/json'; @@ -18,6 +19,14 @@ export interface ToolExecutionContext { threadId: string; resourceId: string; }; + /** Internal runtime event bridge for platform-managed tools. */ + emitEvent?: (event: AgentEventData) => void; + /** + * The current run's abort signal. Long-running tools (e.g. ones that spawn a + * child agent) should forward it so cancelling the parent run also cancels + * the work they started. + */ + abortSignal?: AbortSignal; } export interface ToolContext { @@ -27,8 +36,12 @@ export interface ToolContext { runId?: string; /** Current persisted thread scope when the run is backed by memory. */ persistence?: ToolExecutionContext['persistence']; - /** Telemetry config from the parent agent, for sub-agent propagation. */ + /** Telemetry config from the parent agent. */ parentTelemetry?: BuiltTelemetry; + /** Internal runtime event bridge for platform-managed tools. */ + emitEvent?: ToolExecutionContext['emitEvent']; + /** The current run's abort signal, for tools that start cancellable work. */ + abortSignal?: ToolExecutionContext['abortSignal']; } export interface InterruptibleToolContext { @@ -46,8 +59,12 @@ export interface InterruptibleToolContext { runId?: string; /** Current persisted thread scope when the run is backed by memory. */ persistence?: ToolExecutionContext['persistence']; - /** Telemetry config from the parent agent, for sub-agent propagation. */ + /** Telemetry config from the parent agent. */ parentTelemetry?: BuiltTelemetry; + /** Internal runtime event bridge for platform-managed tools. */ + emitEvent?: ToolExecutionContext['emitEvent']; + /** The current run's abort signal, for tools that start cancellable work. */ + abortSignal?: ToolExecutionContext['abortSignal']; } export interface BuiltTool { diff --git a/packages/@n8n/api-types/src/agent-builder-interactive.ts b/packages/@n8n/api-types/src/agent-builder-interactive.ts index d1bc9e4b3d8..33a6e1180ee 100644 --- a/packages/@n8n/api-types/src/agent-builder-interactive.ts +++ b/packages/@n8n/api-types/src/agent-builder-interactive.ts @@ -83,7 +83,7 @@ export const askQuestionInputSchema = z.object({ options: z .array(askQuestionOptionSchema) .describe( - 'Choices to present. Pass an empty array for an open-ended question (the card shows only a freeform input). With a single option the tool auto-resolves to that option without rendering a card.', + 'Choices to present. Pass an empty array for an open-ended question (the card shows only a freeform input). With a single non-multiple option the tool auto-resolves to that option without rendering a card.', ), allowMultiple: z .boolean() diff --git a/packages/@n8n/api-types/src/agent-sse.ts b/packages/@n8n/api-types/src/agent-sse.ts index 8d115e8f45c..788338c9880 100644 --- a/packages/@n8n/api-types/src/agent-sse.ts +++ b/packages/@n8n/api-types/src/agent-sse.ts @@ -69,6 +69,21 @@ export type AgentSseEvent = type: 'tool-execution-start'; toolCallId: string; toolName: string; + /** Epoch ms when the handler started, measured on the backend. */ + startTime: number; + } + | { + /** + * Emitted as soon as an individual tool settles, so the FE can flip a + * concurrent tool call to its terminal state immediately instead of + * waiting for the batched `tool-result` events. + */ + type: 'tool-execution-end'; + toolCallId: string; + toolName: string; + isError: boolean; + /** Epoch ms when the handler settled, measured on the backend. */ + endTime: number; } | { type: 'tool-result'; diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 9633f887123..94f5b4dca87 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -77,6 +77,16 @@ const WebSearchConfigSchema = z.object({ credential: z.string().optional(), }); +const SubAgentConfigSchema = z.object({ + agentId: z.string().trim().min(1), +}); + +const SubAgentsConfigSchema = z + .object({ + agents: z.array(SubAgentConfigSchema).optional(), + }) + .strict(); + const NodeToolCredentialSchema = z.object({ id: z.string(), name: z.string(), @@ -240,6 +250,7 @@ export const AgentJsonConfigSchema = z.object({ credential: z.string().optional(), instructions: z.string(), memory: MemoryConfigSchema.optional(), + subAgents: SubAgentsConfigSchema.optional(), tools: z.array(AgentJsonToolConfigSchema).optional(), skills: z.array(AgentJsonSkillConfigSchema).optional(), tasks: z.array(AgentJsonTaskConfigSchema).optional(), @@ -285,6 +296,7 @@ export const RunnableAgentJsonConfigSchema = AgentJsonConfigSchema.extend({ export const AgentJsonConfigPartialSchema = AgentJsonConfigSchema.partial(); export type AgentJsonConfig = z.infer; +export type RunnableAgentJsonConfig = z.infer; export type AgentJsonToolConfig = z.infer; export type AgentJsonWorkflowToolConfig = Extract; export type AgentJsonNodeToolConfig = Extract; @@ -326,3 +338,7 @@ export function formatZodErrors(error: ZodError): ConfigValidationError[] { export function isNodeToolsEnabled(config: AgentJsonConfig['config']): boolean { return config?.nodeTools?.enabled === true; } + +export function isSubAgentsEnabled(subAgents: AgentJsonConfig['subAgents']): boolean { + return (subAgents?.agents?.length ?? 0) > 0; +} diff --git a/packages/@n8n/api-types/src/agents/index.ts b/packages/@n8n/api-types/src/agents/index.ts index 2bbf5f1be0b..ada4e32445e 100644 --- a/packages/@n8n/api-types/src/agents/index.ts +++ b/packages/@n8n/api-types/src/agents/index.ts @@ -5,6 +5,7 @@ export * from './agent-task.schema'; export * from './dto'; export * from './model-providers'; export * from './provider-capabilities'; +export type * from './sub-agent.schema'; export * from './types'; export type { AgentSseEvent, AgentSseMessage, ToolSuspendedPayload } from '../agent-sse'; export { diff --git a/packages/@n8n/api-types/src/agents/sub-agent.schema.ts b/packages/@n8n/api-types/src/agents/sub-agent.schema.ts new file mode 100644 index 00000000000..d948925113b --- /dev/null +++ b/packages/@n8n/api-types/src/agents/sub-agent.schema.ts @@ -0,0 +1,52 @@ +import type { RunnableAgentJsonConfig } from './agent-json-config.schema'; + +/** + * A sub-agent is always a saved n8n agent — optionally pinned to a published + * version. + */ +export type SubAgentSource = { + agentId: string; + versionId?: string; +}; + +export interface ResolvedSubAgentSource { + config: RunnableAgentJsonConfig; + sourceId: string; + versionId?: string; +} + +export interface SubAgentRunPolicy { + maxChildren?: number; + /** Host-enforced wall-clock timeout for a child run — the n8n runner aborts the child. */ + timeoutMs?: number; +} + +/** + * In-process contract for spawning a child agent. Built by the SDK delegate tool + * from the model's already-validated input and handed straight to the runner, so + * it never crosses an untrusted boundary and needs no runtime schema. + */ +export interface SubAgentSpawnRequest { + goal: string; + context?: string; + expectedOutput?: string; + source: SubAgentSource; + /** + * 'foreground' blocks the parent turn until the subagent completes — the only + * mode implemented today. 'background' (dispatch, return a receipt, reconcile + * the result later) is not yet implemented and is a consumer/product concern, + * not an SDK one. Tracked in AGENT-186: + * https://linear.app/n8n/issue/AGENT-186 + */ + executionMode?: 'foreground' | 'background'; + policy?: SubAgentRunPolicy; + parentThreadId?: string; + /** Parent's episodic-memory resource id, inherited so the child shares its scope. */ + parentResourceId?: string; + /** + * This delegation's task path — already assigned and policy-checked by the SDK + * delegate tool, then validated by `@n8n/agents` (`assertSubAgentTaskPath`) + * before the child runs, so a plain string suffices here. + */ + taskPath: string; +} diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index 29c530903be..ab427d07226 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -139,6 +139,10 @@ export interface AgentPersistedMessageContentPart { state?: string; output?: unknown; error?: string; + /** Epoch ms when the tool handler started executing. */ + startTime?: number; + /** Epoch ms when the tool handler settled. */ + endTime?: number; } export interface AgentPersistedMessageDto { diff --git a/packages/@n8n/config/src/configs/agents.config.ts b/packages/@n8n/config/src/configs/agents.config.ts index 1daa5aa0760..1e53bfc1dd2 100644 --- a/packages/@n8n/config/src/configs/agents.config.ts +++ b/packages/@n8n/config/src/configs/agents.config.ts @@ -30,6 +30,14 @@ export class AgentsConfig { @Env('N8N_AGENTS_CHECKPOINT_TTL') checkpointTtlSeconds: number = 345600; // 96 hours + /** Maximum number of sub-agents a single parent run may spawn. Bounds fan-out width. */ + @Env('N8N_AGENTS_SUBAGENT_MAX_CHILDREN') + subAgentMaxChildren: number = 5; + + /** Abort an individual sub-agent run after this many milliseconds. */ + @Env('N8N_AGENTS_SUBAGENT_TIMEOUT_MS') + subAgentTimeoutMs: number = 300000; // 5 minutes + /** * Comma-separated list of agent sub-feature modules to enable. Each entry * gates a specific frontend/runtime capability inside the agents module. diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 847d00f6c26..b20ec8bc495 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -560,6 +560,8 @@ describe('GlobalConfig', () => { }, agents: { checkpointTtlSeconds: 345600, + subAgentMaxChildren: 5, + subAgentTimeoutMs: 300000, modules: [], }, } satisfies GlobalConfigShape; diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts index dc066215b45..30ed99171c7 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts @@ -54,10 +54,38 @@ describe('AgentExecutionThreadRepository', () => { taskId: null, taskVersionId: null, sessionNumber: 8, + parentThreadId: null, + parentAgentId: null, }); expect(result).toEqual({ thread: saved, created: true }); }); + it('stores subagent origin metadata when creating a thread', async () => { + const saved = mock({ id: 'thread-1', sessionNumber: 8 }); + const scopedRepository = makeScopedRepository(saved); + const trx = { getRepository: jest.fn().mockReturnValue(scopedRepository) }; + entityManager.transaction.mockImplementationOnce(async (_isolation, callback) => { + return await callback(trx as never); + }); + + await repository.findOrCreate('thread-1', 'agent-1', 'Support agent', 'project-1', { + parentThreadId: 'parent-thread-1', + parentAgentId: 'parent-agent-1', + }); + + expect(scopedRepository.create).toHaveBeenCalledWith({ + id: 'thread-1', + agentId: 'agent-1', + agentName: 'Support agent', + projectId: 'project-1', + taskId: null, + taskVersionId: null, + sessionNumber: 8, + parentThreadId: 'parent-thread-1', + parentAgentId: 'parent-agent-1', + }); + }); + it('stores the published task snapshot version when supplied', async () => { const saved = mock({ id: 'thread-1', sessionNumber: 8 }); const scopedRepository = makeScopedRepository(saved); @@ -71,6 +99,7 @@ describe('AgentExecutionThreadRepository', () => { 'agent-1', 'Support agent', 'project-1', + undefined, 'task-1', 'version-1', ); diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts index 60063467883..a3d426a0e75 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts @@ -19,6 +19,8 @@ function makeThread(overrides: Partial = {}): AgentExecuti projectId: 'project-1', title: null, emoji: null, + parentThreadId: null, + parentAgentId: null, sessionNumber: 1, totalPromptTokens: 0, totalCompletionTokens: 0, @@ -71,6 +73,52 @@ describe('AgentExecutionService', () => { }); describe('recordMessage', () => { + it('passes thread metadata when creating a subagent execution session', async () => { + const thread = makeThread({ parentThreadId: 'parent-thread-1' }); + const record: MessageRecord = { + assistantResponse: 'Done', + model: 'anthropic/claude-sonnet-4-5', + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + totalCost: 0.01, + toolCalls: [], + timeline: [], + startTime: Date.parse('2026-05-07T10:00:00Z'), + duration: 1234, + error: null, + }; + agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ thread, created: true }); + agentExecutionRepository.create.mockImplementation((entity) => entity as AgentExecution); + agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + + await service.recordMessage({ + threadId: 'thread-1', + agentId: 'agent-1', + agentName: 'Agent', + projectId: 'project-1', + userMessage: 'Goal:\nResearch API behavior.', + record, + source: 'subagent', + threadMetadata: { + parentThreadId: 'parent-thread-1', + parentAgentId: 'parent-agent-1', + }, + }); + + expect(agentExecutionThreadRepository.findOrCreate).toHaveBeenCalledWith( + 'thread-1', + 'agent-1', + 'Agent', + 'project-1', + { + parentThreadId: 'parent-thread-1', + parentAgentId: 'parent-agent-1', + }, + undefined, + undefined, + ); + }); + it('stamps the task snapshot version on newly created task sessions', async () => { agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ thread: makeThread({ title: 'Task run' }), @@ -96,6 +144,7 @@ describe('AgentExecutionService', () => { 'agent-1', 'Agent', 'project-1', + undefined, 'task-1', 'version-1', ); diff --git a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts index b43361e9f03..dfce85f59b4 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts @@ -1,4 +1,9 @@ -import { AgentJsonConfigSchema, isNodeToolsEnabled, type AgentJsonConfig } from '@n8n/api-types'; +import { + AgentJsonConfigSchema, + isNodeToolsEnabled, + isSubAgentsEnabled, + type AgentJsonConfig, +} from '@n8n/api-types'; const baseConfig: AgentJsonConfig = { name: 'Test Agent', @@ -90,6 +95,33 @@ describe('isNodeToolsEnabled', () => { }); }); +describe('AgentJsonConfigSchema — subAgents', () => { + it('accepts saved agent references', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + subAgents: { agents: [{ agentId: 'agent-1' }] }, + }); + expect(parsed.success).toBe(true); + }); + + it('rejects the removed subAgents.enabled flag', () => { + expect( + AgentJsonConfigSchema.safeParse({ ...baseConfig, subAgents: { enabled: true } }).success, + ).toBe(false); + }); +}); + +describe('isSubAgentsEnabled', () => { + it('returns false when subAgents is undefined', () => { + expect(isSubAgentsEnabled(undefined)).toBe(false); + }); + + it('returns true only when at least one subagent ref exists', () => { + expect(isSubAgentsEnabled({ agents: [] })).toBe(false); + expect(isSubAgentsEnabled({ agents: [{ agentId: 'agent-1' }] })).toBe(true); + }); +}); + describe('AgentJsonConfigSchema — memory.observationalMemory', () => { const memoryBase = { enabled: true, storage: 'n8n' as const }; diff --git a/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts b/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts index 8df20b741ab..5d14a189722 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts @@ -65,3 +65,47 @@ describe('agent-sse-stream — stringifyError (via pumpChunks error chunk)', () expect(events).toEqual([{ type: 'error', message: 'null' }]); }); }); + +describe('agent-sse-stream — tool execution lifecycle chunks', () => { + it('forwards tool-execution-start with its server startTime', async () => { + const events = await collectEvents([ + { + type: 'tool-execution-start', + toolCallId: 'tc-1', + toolName: 'delegate_subagent', + startTime: 1_000, + }, + ]); + + expect(events).toEqual([ + { + type: 'tool-execution-start', + toolCallId: 'tc-1', + toolName: 'delegate_subagent', + startTime: 1_000, + }, + ]); + }); + + it('forwards tool-execution-end with its server endTime', async () => { + const events = await collectEvents([ + { + type: 'tool-execution-end', + toolCallId: 'tc-1', + toolName: 'delegate_subagent', + isError: false, + endTime: 1_014, + }, + ]); + + expect(events).toEqual([ + { + type: 'tool-execution-end', + toolCallId: 'tc-1', + toolName: 'delegate_subagent', + isError: false, + endTime: 1_014, + }, + ]); + }); +}); 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 1b4bc538813..949912f7b5c 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 @@ -152,6 +152,53 @@ describe('AgentsBuilderToolsService', () => { ]); }); + it('list_sub_agents returns published same-project agents except the target agent', async () => { + const { service, agentsService } = makeService(); + agentsService.findByProjectId.mockResolvedValue([ + { + id: agentId, + name: 'Current Agent', + description: 'The agent being edited', + activeVersionId: 'active-current', + }, + { + id: 'agent-research', + name: 'Research Agent', + description: 'Finds information on the web', + activeVersionId: 'active-research', + }, + { + id: 'agent-draft', + name: 'Draft Agent', + description: 'Not published yet', + activeVersionId: null, + }, + { + id: 'agent-risk', + name: 'Risk Agent', + description: null, + activeVersionId: 'active-risk', + }, + ] as Agent[]); + + const result = await getJsonTool(service, BUILDER_TOOLS.LIST_SUB_AGENTS).handler!({}, ctx); + + expect(agentsService.findByProjectId).toHaveBeenCalledWith(projectId); + expect(result).toEqual({ + agents: [ + { + agentId: 'agent-research', + name: 'Research Agent', + description: 'Finds information on the web', + }, + { + agentId: 'agent-risk', + name: 'Risk Agent', + }, + ], + }); + }); + it('patch_config applies a patch when baseConfigHash matches', async () => { const { service, agentsService } = makeService(); const currentConfig = { ...baseConfig, integrations: [] }; diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 0c0249cf4c6..1b2073cf2b8 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -558,6 +558,59 @@ describe('AgentsService', () => { // description column on the entity also stays untouched. expect(savedEntity.description).toBe(agent.description); }); + + it('stores subAgents when the inbound config provides saved agent refs', async () => { + const agent = makeAgent(); + const subAgent = makeAgent({ id: 'agent-2', activeVersionId: 'published-version-2' }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + agentRepository.findByIdAndProjectId.mockImplementation(async (id) => { + if (id === agentId) return agent; + if (id === 'agent-2') return subAgent; + return null; + }); + + const configWithSubAgents = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + subAgents: { agents: [{ agentId: 'agent-2' }] }, + } as AgentJsonConfig; + jest.spyOn(service, 'validateConfig').mockResolvedValue({ + valid: true, + config: configWithSubAgents, + }); + + await service.updateConfig(agentId, projectId, configWithSubAgents); + + const savedEntity = agentRepository.save.mock.calls[0][0] as Agent; + expect(savedEntity.schema?.subAgents).toEqual({ agents: [{ agentId: 'agent-2' }] }); + }); + + it('rejects unpublished subagent references', async () => { + const agent = makeAgent(); + const unpublishedSubAgent = makeAgent({ id: 'agent-2', activeVersionId: null }); + agentRepository.findByIdAndProjectId.mockImplementation(async (id) => { + if (id === agentId) return agent; + if (id === 'agent-2') return unpublishedSubAgent; + return null; + }); + + const configWithSubAgents = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + subAgents: { agents: [{ agentId: 'agent-2' }] }, + } as AgentJsonConfig; + jest.spyOn(service, 'validateConfig').mockResolvedValue({ + valid: true, + config: configWithSubAgents, + }); + + await expect(service.updateConfig(agentId, projectId, configWithSubAgents)).rejects.toThrow( + 'Invalid agent config: Subagent "agent-2" must be published', + ); + expect(agentRepository.save).not.toHaveBeenCalled(); + }); }); describe('publishAgent', () => { diff --git a/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts b/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts index 4a16016bf02..e105c70c822 100644 --- a/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts +++ b/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts @@ -11,6 +11,82 @@ function makeToolResultChunk(toolName: string, output: unknown, toolCallId = 'tc } describe('ExecutionRecorder', () => { + describe('per-tool execution timing', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('records distinct per-tool end times from tool-execution-end for concurrent tools', () => { + jest.useFakeTimers(); + jest.setSystemTime(1_000); + const recorder = new ExecutionRecorder(); + + // Two concurrent tool calls emitted together by the model. + recorder.record(makeToolCallChunk('a', {}, 'tc-1')); + recorder.record(makeToolCallChunk('b', {}, 'tc-2')); + + // Both start executing together (server-stamped on the chunk). + recorder.record({ + type: 'tool-execution-start', + toolCallId: 'tc-1', + toolName: 'a', + startTime: 1_100, + }); + recorder.record({ + type: 'tool-execution-start', + toolCallId: 'tc-2', + toolName: 'b', + startTime: 1_100, + }); + + // They finish at different real times (server-stamped on the chunk). + recorder.record({ + type: 'tool-execution-end', + toolCallId: 'tc-1', + toolName: 'a', + isError: false, + endTime: 1_500, + }); + recorder.record({ + type: 'tool-execution-end', + toolCallId: 'tc-2', + toolName: 'b', + isError: false, + endTime: 3_000, + }); + + // The batched tool-results arrive together, after the slowest finished. + jest.setSystemTime(3_001); + recorder.record(makeToolResultChunk('a', 'ra', 'tc-1')); + recorder.record(makeToolResultChunk('b', 'rb', 'tc-2')); + recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const { timeline } = recorder.getMessageRecord(); + const a = timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-1'); + const b = timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-2'); + + // startTime from tool-execution-start, endTime from tool-execution-end — + // NOT the shared batched tool-result timestamp (3001). + expect(a).toMatchObject({ startTime: 1_100, endTime: 1_500, output: 'ra', success: true }); + expect(b).toMatchObject({ startTime: 1_100, endTime: 3_000, output: 'rb', success: true }); + }); + + it('falls back to the tool-result time when tool-execution-end is absent', () => { + jest.useFakeTimers(); + jest.setSystemTime(1_000); + const recorder = new ExecutionRecorder(); + + recorder.record(makeToolCallChunk('a', {}, 'tc-1')); + jest.setSystemTime(2_000); + recorder.record(makeToolResultChunk('a', 'ra', 'tc-1')); + recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const { timeline } = recorder.getMessageRecord(); + const a = timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-1'); + expect(a).toMatchObject({ endTime: 2_000, output: 'ra', success: true }); + }); + }); + describe('timeline ordering', () => { it('captures text → tool call → text in order', () => { const recorder = new ExecutionRecorder(); diff --git a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts index eab8cce2b44..abd79dd0296 100644 --- a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts +++ b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts @@ -57,6 +57,8 @@ describe('execution-to-message-mapper', () => { toolName: 'search_tool', toolCallId: 'call-1', input: { query: 'n8n' }, + startTime: 111, + endTime: 120, state: 'resolved', output: { items: [1] }, }, @@ -100,6 +102,8 @@ describe('execution-to-message-mapper', () => { toolName: 'failing_tool', toolCallId: 'call-1', input: { id: '123' }, + startTime: 100, + endTime: 120, state: 'rejected', error: 'Tool failed', }, diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index 30ee787e365..f92a99a8361 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -6,6 +6,7 @@ import { AgentExecution } from './entities/agent-execution.entity'; import type { MessageRecord } from './execution-recorder'; import { N8nMemory } from './integrations/n8n-memory'; import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository'; +import type { AgentExecutionThreadMetadata } from './repositories/agent-execution-thread.repository'; import { AgentExecutionRepository } from './repositories/agent-execution.repository'; export interface RecordMessageParams { @@ -19,6 +20,8 @@ export interface RecordMessageParams { hitlStatus?: 'suspended' | 'resumed'; /** Where the message originated from, e.g. 'chat', 'slack', 'task'. */ source?: string; + /** Optional metadata persisted on the thread when it is first created. */ + threadMetadata?: AgentExecutionThreadMetadata; /** When the run was triggered by a scheduled task, the task's id (stamped on the session). */ taskId?: string; /** Published agent_history version that supplied the scheduled task snapshot. */ @@ -56,6 +59,7 @@ export class AgentExecutionService { record, source, hitlStatus, + threadMetadata, taskId, taskVersionId, } = params; @@ -66,6 +70,7 @@ export class AgentExecutionService { agentId, agentName, projectId, + threadMetadata, taskId, taskVersionId, ); diff --git a/packages/cli/src/modules/agents/agent-sse-stream.ts b/packages/cli/src/modules/agents/agent-sse-stream.ts index cfbcf972dbc..d69f81c5d47 100644 --- a/packages/cli/src/modules/agents/agent-sse-stream.ts +++ b/packages/cli/src/modules/agents/agent-sse-stream.ts @@ -113,6 +113,7 @@ function emitToolChunk( | 'tool-input-delta' | 'tool-call' | 'tool-execution-start' + | 'tool-execution-end' | 'tool-result' | 'tool-call-suspended'; } @@ -149,6 +150,16 @@ function emitToolChunk( type: 'tool-execution-start', toolCallId: chunk.toolCallId, toolName: chunk.toolName, + startTime: chunk.startTime, + }); + break; + case 'tool-execution-end': + send({ + type: 'tool-execution-end', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + isError: chunk.isError, + endTime: chunk.endTime, }); break; case 'tool-result': @@ -202,6 +213,7 @@ function emitChunkEvents(chunk: StreamChunk, ctx: ChunkHandlerCtx): { suspended: case 'tool-input-delta': case 'tool-call': case 'tool-execution-start': + case 'tool-execution-end': case 'tool-result': case 'tool-call-suspended': return emitToolChunk(chunk, ctx); diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 75c48454629..923cd64fcef 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -12,6 +12,7 @@ import { AgentIntegrationSchema, AgentJsonConfigSchema, isNodeToolsEnabled, + isSubAgentsEnabled, AgentModelSchema, type AgentIntegrationConfig, type AgentJsonConfig, @@ -23,6 +24,8 @@ import { type AgentVersionListItemDto, type ChatIntegrationDescriptor, AgentPersistedMessageDto, + type SubAgentSource, + type SubAgentRunPolicy, } from '@n8n/api-types'; import { extractFromAIParameters } from '@n8n/ai-utilities/fromai-helpers'; import { Logger } from '@n8n/backend-common'; @@ -106,6 +109,8 @@ import { buildToolRegistry, type ToolRegistry } from './tool-registry'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { AgentKnowledgeCommandService } from './agent-knowledge-command.service'; import { AgentKnowledgeService } from './agent-knowledge.service'; +import { createN8nDelegateSubAgentTool } from './sub-agents/delegate-sub-agent-tool'; +import { SubAgentForegroundRunner } from './sub-agents/sub-agent-foreground-runner'; type AgentToolEntries = Agent['tools']; @@ -115,11 +120,17 @@ interface InjectRuntimeDependenciesParams { projectId: string; credentialProvider: CredentialProvider; nodeToolsEnabled: boolean; + subAgentDelegation?: SubAgentDelegationConfig; credentialIntegrations: AgentIntegrationConfig[]; /** Chat platform the runtime is being reconstructed for — drives the rich_interaction tool's capability profile. */ integrationType?: string; } +interface SubAgentDelegationConfig { + sourcesById: Record; + availableSubAgents: Array<{ id: string; name: string; description?: string }>; +} + /** Derive a stable thread ID for the test-chat of a given agent and user. */ export function chatThreadId(agentId: string, userId?: string): string { const baseThreadId = `${AGENT_THREAD_PREFIX.TEST}${agentId}`; @@ -398,6 +409,10 @@ export class AgentsService { return this.isNodeToolsModuleEnabled() && isNodeToolsEnabled(config); } + private shouldAttachSubAgents(config: AgentJsonConfig): boolean { + return isSubAgentsEnabled(config.subAgents); + } + /** * Return the list of registered chat platform integrations with their * FE display metadata. Used by `GET /agents/integrations`. @@ -982,6 +997,7 @@ export class AgentsService { projectId, credentialProvider, nodeToolsEnabled, + subAgentDelegation, credentialIntegrations, integrationType, } = params; @@ -1062,6 +1078,16 @@ export class AgentsService { this.attachNodeToolChain(agent, credentialProvider, projectId); } + if (subAgentDelegation !== undefined) { + this.attachSubAgentDelegationTool({ + agent, + agentId, + projectId, + credentialProvider, + delegation: subAgentDelegation, + }); + } + // Inject checkpoint storage if (!agent.hasCheckpointStorage()) { agent.checkpoint(this.n8nCheckpointStorage); @@ -1082,6 +1108,38 @@ export class AgentsService { agent.tool(this.agentsToolsService.getRuntimeTools(credentialProvider, projectId)); } + private attachSubAgentDelegationTool(params: { + agent: RuntimeAgent; + agentId: string; + projectId: string; + credentialProvider: CredentialProvider; + delegation: SubAgentDelegationConfig; + }): void { + const { agent, agentId, projectId, credentialProvider, delegation } = params; + agent.tool( + createN8nDelegateSubAgentTool({ + runner: Container.get(SubAgentForegroundRunner), + ...delegation, + projectId, + parentAgentId: agentId, + credentialProvider, + policy: this.buildSubAgentPolicy(), + createToolExecutor: (toolCodeByName) => + this.secureRuntime.createToolExecutor(toolCodeByName), + createMemoryFactory: (memoryOwnerAgentId) => this.getMemoryFactory(memoryOwnerAgentId), + }), + ); + this.logger.debug('Injected delegate_subagent tool', { agentId }); + } + + /** Delegation guardrails sourced from {@link AgentsConfig} (env-configurable). */ + private buildSubAgentPolicy(): SubAgentRunPolicy { + return { + maxChildren: this.agentsConfig.subAgentMaxChildren, + timeoutMs: this.agentsConfig.subAgentTimeoutMs, + }; + } + /** * Resume a suspended tool call and yield the resulting stream chunks. * Used by chat integration handlers to continue an agent run after @@ -1781,6 +1839,7 @@ export class AgentsService { throw new UserError(`Invalid agent config: ${result.error}`); } + await this.validateSubAgentRefs(result.config, entity); this.validateConfigRefs(result.config, entity); // Task refs resolve against task definition bodies (a separate table), so @@ -1820,6 +1879,7 @@ export class AgentsService { const descriptionProvided = result.config.description !== undefined; const credentialProvided = result.config.credential !== undefined; const memoryProvided = result.config.memory !== undefined; + const subAgentsProvided = result.config.subAgents !== undefined; const providerToolsProvided = result.config.providerTools !== undefined; const configBlockProvided = result.config.config !== undefined; const mcpServersProvided = result.config.mcpServers !== undefined; @@ -1840,6 +1900,7 @@ export class AgentsService { ...(descriptionProvided ? { description: decomposedSchema.description } : {}), ...(credentialProvided ? { credential: decomposedSchema.credential } : {}), ...(memoryProvided ? { memory: decomposedSchema.memory } : {}), + ...(subAgentsProvided ? { subAgents: decomposedSchema.subAgents } : {}), ...(toolsProvided ? { tools: decomposedSchema.tools } : {}), ...(skillsProvided ? { skills: decomposedSchema.skills } : {}), ...(tasksProvided ? { tasks: decomposedSchema.tasks } : {}), @@ -1847,6 +1908,7 @@ export class AgentsService { ...(configBlockProvided ? { config: decomposedSchema.config } : {}), ...(mcpServersProvided ? { mcpServers: decomposedSchema.mcpServers } : {}), }; + nextSchema.subAgents = normalizeSubAgentsConfig(nextSchema.subAgents); entity.schema = nextSchema; entity.name = result.config.name; @@ -2144,6 +2206,46 @@ export class AgentsService { } } + /** + * Resolve configured sub-agent refs to their saved agents, de-duplicated + * (order preserved) and fetched once each. `agent` is null when the id no + * longer resolves in the project. Shared by save-time validation and + * runtime delegation config. + */ + private async fetchUniqueSubAgents( + refs: Array<{ agentId: string }>, + projectId: string, + ): Promise> { + const seen = new Set(); + const resolved: Array<{ agentId: string; agent: Agent | null }> = []; + for (const { agentId } of refs) { + if (seen.has(agentId)) continue; + seen.add(agentId); + resolved.push({ + agentId, + agent: await this.agentRepository.findByIdAndProjectId(agentId, projectId), + }); + } + return resolved; + } + + private async validateSubAgentRefs(config: AgentJsonConfig, entity: Agent) { + const refs = config.subAgents?.agents ?? []; + if (refs.length === 0) return; + + for (const { agentId, agent } of await this.fetchUniqueSubAgents(refs, entity.projectId)) { + if (agentId === entity.id) { + throw new UserError('Invalid agent config: An agent cannot use itself as a subagent'); + } + if (!agent) { + throw new UserError(`Invalid agent config: Subagent "${agentId}" was not found`); + } + if (!agent.activeVersionId) { + throw new UserError(`Invalid agent config: Subagent "${agentId}" must be published`); + } + } + } + private getMissingCustomToolIds( config: AgentJsonConfig | null, tools: AgentToolEntries, @@ -2316,12 +2418,17 @@ export class AgentsService { buildMcpClient, }); + const subAgentDelegation = this.shouldAttachSubAgents(config) + ? await this.createSubAgentDelegationConfig(config, agentEntity.projectId) + : undefined; + await this.injectRuntimeDependencies({ agent: reconstructed, agentId: agentEntity.id, projectId: agentEntity.projectId, credentialProvider, nodeToolsEnabled: this.shouldAttachNodeTools(config.config), + ...(subAgentDelegation !== undefined ? { subAgentDelegation } : {}), credentialIntegrations: agentEntity.integrations ?? [], integrationType, }); @@ -2329,9 +2436,40 @@ export class AgentsService { const toolRegistry = buildToolRegistry(resolvedTools); return { agent: reconstructed, toolRegistry }; } + + private async createSubAgentDelegationConfig( + config: AgentJsonConfig, + projectId: string, + ): Promise { + const configuredAgents = config.subAgents?.agents ?? []; + if (configuredAgents.length === 0) return undefined; + + const sourcesById: Record = {}; + const availableSubAgents: SubAgentDelegationConfig['availableSubAgents'] = []; + + for (const { agentId, agent } of await this.fetchUniqueSubAgents(configuredAgents, projectId)) { + if (!agent?.activeVersionId) continue; + + sourcesById[agentId] = { agentId, versionId: agent.activeVersionId }; + availableSubAgents.push({ + id: agentId, + name: agent.name, + ...(agent.description ? { description: agent.description } : {}), + }); + } + + return availableSubAgents.length > 0 ? { sourcesById, availableSubAgents } : undefined; + } } function getProviderPrefix(modelId: string): string { const slashIdx = modelId.indexOf('/'); return slashIdx === -1 ? '' : modelId.slice(0, slashIdx); } + +function normalizeSubAgentsConfig( + subAgents: AgentJsonConfig['subAgents'], +): AgentJsonConfig['subAgents'] { + if (!subAgents) return undefined; + return { agents: subAgents.agents ?? [] }; +} diff --git a/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts b/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts index fa67c7b1b0f..cc87b6370c4 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts @@ -118,6 +118,7 @@ describe('builder model recommendations', () => { expect(prompt).toContain('## LLM Selection Guidance'); expect(prompt).toContain('## Memory Guidance'); expect(prompt).toContain('## Tool Guidance'); + expect(prompt).toContain('## Sub Agents'); expect(prompt).toContain('Additional specialized builder guidance is available'); expect(prompt).toContain('chat integration/trigger or a node/workflow tool'); expect(prompt).toContain('use Linear node tools for ordinary issue search/create/update'); @@ -127,6 +128,31 @@ describe('builder model recommendations', () => { expect(prompt).not.toContain('agent-builder-tools'); }); + it('tells the builder to write target agent descriptions', () => { + const prompt = buildPrompt(null); + + expect(prompt).toContain('Fresh agents must include a brief `description`'); + expect(prompt).toContain( + 'Requires `name`, `description`, `model`, `credential`, and `instructions`', + ); + expect(prompt).toContain( + '"description": "Answers support questions and helps triage customer issues."', + ); + }); + + it('teaches the builder how to configure subagent delegation', () => { + const prompt = buildPrompt(null); + + expect(prompt).toContain('`subAgents: { "agents": [{ "agentId": "" }] }`'); + expect(prompt).toContain('the runtime injects'); + expect(prompt).toContain('`delegate_subagent`'); + expect(prompt).toContain('If no saved agents'); + expect(prompt).toContain('no subagent tool is available'); + expect(prompt).toContain('Use `list_sub_agents` to discover published same-project agents'); + expect(prompt).toContain('call `ask_question` with `allowMultiple: true`'); + expect(prompt).toContain('If no published agents are available'); + }); + it('tells the builder to preserve fallback web search on model switches', () => { const prompt = buildPrompt(null); 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 1b94f29f925..291d2cd6f33 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -114,6 +114,9 @@ Converse". choices from a known small set, or an empty \`options\` array for an open-ended question (renders a freeform card). Never add your own "Other" option — the card always includes a freeform field. +- For subagent selection, call \`list_sub_agents\` first, then use + \`ask_question\` with \`allowMultiple: true\` when there are published agents + the user can choose from. - Never call two interactive tools in parallel. The run suspends on the first. - Never re-ask a question the user already answered in this thread. - After resume, continue with the next concrete tool action. Do not narrate the @@ -134,6 +137,35 @@ Prefer \`$fromAI\` whenever the target agent should decide a value at runtime. Always wrap expressions in \`={{ }}\`. Never pipe AI-chosen node-tool fields through \`$json\`; use \`$fromAI\` for those fields instead.`; +export const SUB_AGENTS_SECTION = `\ +## Sub Agents + +The target agent supports optional subagent delegation through +\`subAgents: { "agents": [{ "agentId": "" }] }\`. + +When \`subAgents.agents\` has at least one entry, the runtime injects +\`delegate_subagent\` and extra target-agent system guidance. If no saved agents +are configured, no subagent tool is available. + +- Configure subagents only when the user asks for subagents, delegation, helper + agents, independent review, or research-style task decomposition. +- Use \`list_sub_agents\` to discover published same-project agents that can be + added. Do not write agent ids from memory, prose, or user-entered free text. +- If published agents are available and the user has not named exact agents, + call \`ask_question\` with \`allowMultiple: true\`. Use each option's + \`value\` as the returned \`agentId\`, and include descriptions when present. +- If no published agents are available, do not configure subagents. Tell the + user they need to publish an agent in this project first. +- Patch selected agents into \`subAgents.agents\` as + \`{ "agentId": "" }\`. Avoid duplicates. +- Never write \`subAgents.enabled\`; saved agent refs alone enable delegation. +- If the resumed values include text that is not one of the listed agent ids, + do not persist it as an agent id; ask a follow-up. +- Do not add custom tools, custom instructions, or custom schema fields to + simulate subagents. +- Preserve existing \`subAgents\` settings unless the user explicitly asks to + change them.`; + export const READ_CONFIG_FRESHNESS_SECTION = `\ ## Config Freshness @@ -259,6 +291,14 @@ export const FEW_SHOT_FLOWS_SECTION = `\ 4. \`patch_config(...)\` adding the tool and omitting only the skipped credential slot. Do not abort the tool addition. +### Enable subagents with saved agents +1. \`list_sub_agents()\`. +2. If it returns one or more agents and the user has not named exact ones, call + \`ask_question({ allowMultiple: true, ... })\` with those agents as options. +3. \`read_config()\`. +4. \`patch_config(...)\` adding selected \`{ "agentId": "" }\` + refs to \`/subAgents/agents\`. + ### Add MCP integration: "Connect Notion MCP" 1. \`load_skill({ "skillId": "agent-builder-mcp" })\`. 2. \`search_mcp_servers({ queries: ["notion"] })\`. @@ -305,6 +345,7 @@ export function buildBuilderPrompt(ctx: BuilderPromptContext): string { getBuilderSkillRoutingSection(), INTERACTIVE_TOOLS_SECTION, N8N_EXPRESSIONS_SECTION, + SUB_AGENTS_SECTION, READ_CONFIG_FRESHNESS_SECTION, WORKFLOW_SECTION, FEW_SHOT_FLOWS_SECTION, 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 b4c1a755b91..0af3cb9f0b9 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 @@ -423,6 +423,27 @@ export class AgentsBuilderToolsService { .handler(async () => this.agentsService.listChatIntegrations()) .build(); + const listSubAgentsTool = new Tool(BUILDER_TOOLS.LIST_SUB_AGENTS) + .description( + 'List published agents in the same project that can be added to the target agent as subagents. ' + + 'Excludes the target agent itself and unpublished agents. Use before asking the user which ' + + 'subagents to add. Returned `agentId` values are the only valid values to write into `subAgents.agents[].agentId`.', + ) + .input(z.object({})) + .handler(async () => { + const agents = await this.agentsService.findByProjectId(projectId); + return { + agents: agents + .filter((agent) => agent.id !== agentId && agent.activeVersionId !== null) + .map((agent) => ({ + agentId: agent.id, + name: agent.name, + ...(agent.description ? { description: agent.description } : {}), + })), + }; + }) + .build(); + const modelLookup: ModelLookup = { list: async (credentialId, credentialType, lookup) => await this.builderModelLookupService.list(user, credentialId, credentialType, lookup), @@ -433,6 +454,7 @@ export class AgentsBuilderToolsService { writeConfigTool, patchConfigTool, listIntegrationTypesTool, + listSubAgentsTool, buildResolveLlmTool({ credentialProvider, modelLookup }), buildAskCredentialTool({ credentialProvider, 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 56743c5a71b..aa6acce3745 100644 --- a/packages/cli/src/modules/agents/builder/builder-tool-names.ts +++ b/packages/cli/src/modules/agents/builder/builder-tool-names.ts @@ -10,6 +10,7 @@ export const BUILDER_TOOLS = { CREATE_SKILL: 'create_skill', CREATE_TASK: 'create_task', LIST_INTEGRATION_TYPES: 'list_integration_types', + LIST_SUB_AGENTS: 'list_sub_agents', 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/interactive/__tests__/ask-question.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-question.tool.test.ts index faa820dd92c..eb2d61787b5 100644 --- a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-question.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-question.tool.test.ts @@ -25,6 +25,20 @@ describe('ask_question tool', () => { expect(result).toEqual({ values: ['slack'] }); }); + it('suspends for a multi-select question with one option', async () => { + const ctx = makeCtx(); + await tool.handler!( + { + question: 'Pick subagents', + options: [{ label: 'Research Agent', value: 'agent-research' }], + allowMultiple: true, + }, + ctx as never, + ); + + expect(ctx.suspend).toHaveBeenCalledTimes(1); + }); + it('suspends when there are multiple options', async () => { const ctx = makeCtx(); await tool.handler!( diff --git a/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts b/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts index 0e4fb901929..18d83ee8735 100644 --- a/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts @@ -27,11 +27,12 @@ export function buildAskQuestionTool(): BuiltTool { ctx: InterruptibleToolContext, ) => { if (ctx.resumeData !== undefined) return ctx.resumeData; - // A single concrete option has no real choice — auto-pick it so the - // LLM doesn't render a card the user can only confirm. Open-ended - // questions use an empty options array and still suspend (the card - // renders a freeform-only input). - if (input.options.length === 1) { + // A single single-select option has no real choice — auto-pick it so + // the LLM doesn't render a card the user can only confirm. Multi-select + // questions still render so the user can decide whether to select the + // one option, and open-ended questions (empty options array) still + // suspend to show the freeform-only card. + if (input.options.length === 1 && input.allowMultiple !== true) { return { values: [input.options[0].value] }; } return await ctx.suspend(input); diff --git a/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts index 5f646f25777..aeafc349fa5 100644 --- a/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts @@ -29,7 +29,7 @@ ${getSchemaReferenceSection()} - Follow the Config schema reference exactly; do not invent top-level fields. - Keep each feature in the schema path where it belongs. - Preserve unrelated existing config unless the user asked to change it. -- Never write placeholder instructions. +- Never write placeholder instructions or descriptions. - Never copy credential IDs from \`list_credentials\`; use \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`. - Valid provider tool keys are complete provider tool IDs documented in the Tool Guidance section. - \`providerTools\` keys must be complete provider tool IDs from the valid key list. @@ -38,13 +38,15 @@ ${getSchemaReferenceSection()} #### Create Or Replace A Fresh Runnable Agent -- Requires \`name\`, \`model\`, \`credential\`, and \`instructions\`. +- Requires \`name\`, \`description\`, \`model\`, \`credential\`, and \`instructions\`. +- \`description\` must be a brief, specific summary of what the agent does. - Keep \`tools\` and \`skills\` arrays if present. Good minimal shape: \`\`\`json { "name": "Support assistant", + "description": "Answers support questions and helps triage customer issues.", "model": "openrouter/openai/gpt-5.5", "credential": "", "instructions": "Help the user with support questions.", @@ -66,6 +68,20 @@ Use \`patch_config\` with: - If \`skills\` is missing, add \`/skills\` with an array. - Ref shape: \`{ "type": "skill", "id": "" }\`. +#### Add Saved Agents As Subagents + +- Call \`list_sub_agents\` before asking or writing. Only persist agent IDs + returned by that tool. +- If \`subAgents\` is missing, add it as + \`{ "agents": [{ "agentId": "" }] }\`. +- If \`subAgents\` exists without \`agents\`, add \`/subAgents/agents\` with the + selected refs. +- If \`subAgents.agents\` exists, append new refs to \`/subAgents/agents/-\`. +- Avoid duplicate refs. Ref shape: \`{ "agentId": "" }\`. +- Never write \`subAgents.enabled\`; saved agent refs alone enable delegation. +- If an \`ask_question\` resume value is not one of the listed agent IDs, do not + write it into config. + #### Configure Native Provider Features - Thinking lives under \`config.thinking\`. @@ -124,7 +140,7 @@ Bad: replacing \`config\` while dropping unrelated settings - \`patch_config\` cannot create a config when none exists; use \`write_config\` first. - \`/array/-\` appends to an array; \`/array/0\` inserts before the current first item. - Model-only changes must preserve existing Brave or SearXNG \`config.webSearch\`. -- Empty, placeholder, or guessed \`instructions\` are rejected; ask for details instead. +- Empty, placeholder, or guessed \`instructions\` or \`description\` values are rejected; ask for details instead. ### Verify diff --git a/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts index 3c881b40f0d..3ad2e22aa8c 100644 --- a/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts @@ -52,6 +52,8 @@ export function getConfigRulesSection(): string { - \`model\` must be "provider/model-name". - \`credential\` must be the id returned by \`resolve_llm\` or \`ask_llm\`. +- Fresh agents must include a brief \`description\` explaining what the agent + does. Keep it specific and user-facing; never write placeholder copy. - Fresh agents must include \`memory: { "enabled": true, "storage": "n8n" }\` unless the user explicitly asks to disable memory. @@ -60,6 +62,11 @@ export function getConfigRulesSection(): string { \`credentialType: "openAiApi"\`. - Memory worker model fields use \`{ "model": "provider/model-name", "credential": "" }\`; use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`. +- Subagent delegation lives at top level as + \`subAgents: { "agents": [{ "agentId": "" }] }\`. Only + use \`agentId\` values returned by \`list_sub_agents\`. If \`agents\` is + omitted or empty, no subagent tool is enabled. Never write + \`subAgents.enabled\`. - Web search lives under \`config.webSearch\`. Only OpenAI and Anthropic models support native web search; for those providers, use \`{ "enabled": true, "provider": "native" }\` or omit \`provider\`. Every @@ -70,8 +77,8 @@ export function getConfigRulesSection(): string { - Preserve existing Brave/SearXNG \`config.webSearch\` on model switches unless the user explicitly asks to change web-search method. - \`config.maxIterations\` caps the number of agent loop iterations per run. Do not set or change this unless the user explicitly asks. -- Fresh agents need a real model, credential, and instructions before config - is written.`; +- Fresh agents need a real model, credential, description, and instructions + before config is written.`; } export function getSchemaReferenceSection(): string { diff --git a/packages/cli/src/modules/agents/execution-recorder.ts b/packages/cli/src/modules/agents/execution-recorder.ts index e9d69cdd30f..359b6432034 100644 --- a/packages/cli/src/modules/agents/execution-recorder.ts +++ b/packages/cli/src/modules/agents/execution-recorder.ts @@ -246,6 +246,12 @@ export class ExecutionRecorder { case 'tool-call': this.recordToolCall(chunk.toolCallId, chunk.toolName, chunk.input); break; + case 'tool-execution-start': + this.recordToolExecutionStart(chunk.toolCallId, chunk.startTime); + break; + case 'tool-execution-end': + this.recordToolExecutionEnd(chunk.toolCallId, chunk.isError, chunk.endTime); + break; case 'tool-result': this.recordToolResult( chunk.toolCallId, @@ -265,7 +271,7 @@ export class ExecutionRecorder { }; } this.model = chunk.model ?? null; - this.totalCost = chunk.totalCost ?? chunk.usage?.cost ?? null; + this.totalCost = chunk.usage?.cost ?? null; break; case 'tool-call-suspended': this.flushTextBuffer(); @@ -367,6 +373,46 @@ export class ExecutionRecorder { }); } + /** + * Real per-tool execution start, bridged from the runtime event bus. The + * `tool-call` chunk only marks when the model emitted the call; this marks + * when the handler actually started. Uses the server-stamped `startTime` + * carried on the chunk so the persisted duration matches the live one + * exactly (the FE reads the same value off the stream). + */ + private recordToolExecutionStart(toolCallId: string, startTime: number): void { + if (!toolCallId) return; + const entry = this.findOpenTimelineToolCall(toolCallId); + if (entry) entry.startTime = startTime; + } + + /** + * Real per-tool execution end, bridged from the runtime event bus. Closes + * the timeline entry with the server-stamped finish time so concurrently- + * executed tools keep distinct durations — the batched `tool-result` chunks + * all arrive together and would otherwise share a single end timestamp. + */ + private recordToolExecutionEnd(toolCallId: string, isError: boolean, endTime: number): void { + if (!toolCallId) return; + const entry = this.findOpenTimelineToolCall(toolCallId); + if (entry) { + entry.endTime = endTime; + entry.success = !isError; + } + } + + /** Most recent not-yet-closed timeline tool-call entry for a tool call id. */ + private findOpenTimelineToolCall( + toolCallId: string, + ): (TimelineEvent & { type: 'tool-call' }) | undefined { + return [...this.timeline] + .reverse() + .find( + (e): e is TimelineEvent & { type: 'tool-call' } => + e.type === 'tool-call' && e.toolCallId === toolCallId && e.endTime === 0, + ); + } + /** * Find the still-open flat tool-call entry to attach a result to. Prefers * an exact match on `toolCallId`; when the stream omits the id (empty @@ -410,13 +456,16 @@ export class ExecutionRecorder { .find( (e): e is TimelineEvent & { type: 'tool-call' } => e.type === 'tool-call' && - (toolCallId ? e.toolCallId === toolCallId : e.name === name) && - e.endTime === 0, + (toolCallId ? e.toolCallId === toolCallId : e.name === name && e.endTime === 0), ); if (pendingTimeline) { pendingTimeline.output = recordedOutput; - pendingTimeline.endTime = Date.now(); pendingTimeline.success = !isError; + // `tool-execution-end` (real per-tool finish) normally closed this entry + // already; only fall back to the batched result time if it never fired. + if (pendingTimeline.endTime === 0) { + pendingTimeline.endTime = Date.now(); + } if (pendingTimeline.kind === 'workflow' && isRecord(recordedOutput)) { const execId = recordedOutput.executionId; diff --git a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts index 1927eb1a11c..a79ca74c3ce 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts @@ -5,6 +5,11 @@ import { AgentExecutionThread } from '../entities/agent-execution-thread.entity' const SESSION_NUMBER_RETRY_ATTEMPTS = 3; +export interface AgentExecutionThreadMetadata { + parentThreadId?: string; + parentAgentId?: string; +} + export interface AgentExecutionThreadPage { threads: AgentExecutionThread[]; nextCursor: string | null; @@ -25,6 +30,7 @@ export class AgentExecutionThreadRepository extends Repository { @@ -35,6 +41,7 @@ export class AgentExecutionThreadRepository extends Repository { @@ -77,6 +85,8 @@ export class AgentExecutionThreadRepository extends Repository { + let runner: jest.Mocked; + let credentialProvider: jest.Mocked; + let toolExecutor: jest.Mocked; + let createToolExecutor: jest.Mock; + let createMemoryFactory: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + runner = mock(); + runner.runForeground.mockResolvedValue(foregroundResult); + credentialProvider = mock(); + toolExecutor = mock(); + createToolExecutor = jest.fn().mockReturnValue(toolExecutor); + createMemoryFactory = jest.fn().mockReturnValue(jest.fn()); + }); + + it('builds a delegate tool that calls the foreground runner with a configured source', async () => { + const tool = createN8nDelegateSubAgentTool({ + runner, + sourcesById: { 'agent-2': source }, + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + policy: { maxChildren: 2, timeoutMs: 1000 }, + }); + + await expect( + tool.handler?.( + { + taskName: 'Research API', + goal: 'Find the API behavior.', + context: 'Focus on auth endpoints.', + expectedOutput: 'A short summary.', + }, + { + runId: 'parent-run-1', + toolCallId: 'tool-call-1', + }, + ), + ).resolves.toMatchObject({ + status: 'completed', + taskPath: '/root/research_api_0', + runId: 'child-run-1', + threadId: 'child-thread-1', + answer: 'Preamble\nChild answer', + }); + + expect(runner.runForeground).toHaveBeenCalledWith( + { + goal: 'Find the API behavior.', + context: 'Focus on auth endpoints.', + expectedOutput: 'A short summary.', + source, + executionMode: 'foreground', + policy: { maxChildren: 2, timeoutMs: 1000 }, + taskPath: '/root/research_api_0', + }, + expect.objectContaining({ + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }), + ); + }); + + it('forwards the parent persistence thread id and resource id to the runner', async () => { + const tool = createN8nDelegateSubAgentTool({ + runner, + sourcesById: { 'agent-2': source }, + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }); + + await tool.handler?.( + { taskName: 'Research API', goal: 'Find behavior.' }, + { + runId: 'parent-run-1', + persistence: { threadId: 'parent-thread-1', resourceId: 'resource-1' }, + }, + ); + + expect(runner.runForeground).toHaveBeenCalledWith( + expect.objectContaining({ + parentThreadId: 'parent-thread-1', + parentResourceId: 'resource-1', + }), + expect.any(Object), + ); + }); + + it('selects a configured n8n agent source by subAgentId', async () => { + const selectedSource: SubAgentSource = { agentId: 'agent-2' }; + const tool = createN8nDelegateSubAgentTool({ + runner, + sourcesById: { + 'agent-2': selectedSource, + }, + availableSubAgents: [{ id: 'agent-2', name: 'Research Agent' }], + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }); + + await tool.handler?.( + { subAgentId: 'agent-2', taskName: 'Research API', goal: 'Find behavior.' }, + { runId: 'parent-run-1' }, + ); + + expect(runner.runForeground).toHaveBeenCalledWith( + expect.objectContaining({ + source: selectedSource, + }), + expect.any(Object), + ); + }); + + it('returns a failed tool output when the foreground runner throws', async () => { + runner.runForeground.mockRejectedValue(new Error('child failed')); + const tool = createN8nDelegateSubAgentTool({ + runner, + sourcesById: { 'agent-2': source }, + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }); + + await expect( + tool.handler?.( + { taskName: 'Research API', goal: 'Find behavior.' }, + { runId: 'parent-run-1' }, + ), + ).resolves.toMatchObject({ + status: 'failed', + taskPath: '/root/research_api_0', + answer: '', + error: 'child failed', + }); + }); +}); + +describe('formatSubAgentToolOutput', () => { + it('keeps child metadata compact for the parent model', () => { + expect(formatSubAgentToolOutput(foregroundResult)).toEqual({ + status: 'completed', + taskPath: '/root/research_api_0', + runId: 'child-run-1', + threadId: 'child-thread-1', + answer: 'Preamble\nChild answer', + usage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + cost: 0.01, + }, + finishReason: 'stop', + }); + }); +}); diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts new file mode 100644 index 00000000000..720a929d21f --- /dev/null +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts @@ -0,0 +1,425 @@ +import type { + BuiltAgent, + CredentialProvider, + StreamChunk, + StreamResult, + ToolDescriptor, +} from '@n8n/agents'; +import type { Logger } from '@n8n/backend-common'; +import type { + ResolvedSubAgentSource, + RunnableAgentJsonConfig, + SubAgentSpawnRequest, +} from '@n8n/api-types'; +import { mock } from 'jest-mock-extended'; + +import type { AgentExecutionService } from '../../agent-execution.service'; +import { buildFromJson, type ToolExecutor } from '../../json-config/from-json-config'; +import { SubAgentForegroundRunner } from '../sub-agent-foreground-runner'; +import type { + ResolvedSubAgentRuntimeSource, + SubAgentSourceResolver, +} from '../sub-agent-source-resolver'; + +jest.mock('../../json-config/from-json-config', () => ({ + buildFromJson: jest.fn(), +})); + +const projectId = 'project-1'; +const parentThreadId = 'parent-thread-1'; +const parentAgentId = 'parent-agent-1'; + +const runnableConfig: RunnableAgentJsonConfig = { + name: 'Helper Agent', + model: 'anthropic/claude-sonnet-4-5', + credential: 'credential-1', + instructions: 'Help with delegated work.', +}; + +const source: ResolvedSubAgentSource = { + sourceId: 'agent-1', + config: runnableConfig, +}; + +const toolDescriptor: ToolDescriptor = { + name: 'lookup_customer', + description: 'Look up a customer', + systemInstruction: null, + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: null, + hasSuspend: false, + hasResume: false, + hasToMessage: false, + requireApproval: false, + providerOptions: null, +}; + +const runtimeSource: ResolvedSubAgentRuntimeSource = { + source, + toolDescriptors: { + tool_1: toolDescriptor, + }, + toolCodeByName: { + lookup_customer: 'return input;', + }, + skills: { + skill_1: { + name: 'Skill 1', + description: 'Helps with tests', + instructions: 'Skill body', + }, + }, +}; + +const spawnRequest: SubAgentSpawnRequest = { + goal: 'Find the relevant API behavior.', + context: 'Focus on auth endpoints.', + expectedOutput: 'A concise summary.', + source: { + agentId: 'agent-1', + }, + executionMode: 'foreground', + parentThreadId, + taskPath: '/root/research_api_0', +}; + +const defaultStreamChunks: StreamChunk[] = [ + { type: 'text-delta', id: 'text-1', delta: 'Child answer' }, + { + type: 'finish', + finishReason: 'stop', + model: 'anthropic/claude-sonnet-4-5', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cost: 0.01 }, + }, +]; + +describe('SubAgentForegroundRunner', () => { + let sourceResolver: jest.Mocked; + let runner: SubAgentForegroundRunner; + let childAgent: jest.Mocked; + let agentExecutionService: jest.Mocked; + let logger: jest.Mocked; + let credentialProvider: jest.Mocked; + let toolExecutor: jest.Mocked; + let createToolExecutor: jest.Mock; + let createMemoryFactory: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + sourceResolver = mock(); + sourceResolver.resolveForRuntime.mockResolvedValue(runtimeSource); + agentExecutionService = mock(); + logger = mock(); + runner = new SubAgentForegroundRunner(sourceResolver, agentExecutionService, logger); + + childAgent = mock(); + childAgent.stream.mockResolvedValue(makeStreamResult(defaultStreamChunks)); + childAgent.close.mockResolvedValue(undefined); + jest.mocked(buildFromJson).mockResolvedValue(childAgent as never); + + credentialProvider = mock(); + toolExecutor = mock(); + createToolExecutor = jest.fn().mockReturnValue(toolExecutor); + createMemoryFactory = jest.fn().mockReturnValue(jest.fn()); + }); + + it('builds an isolated child agent and runs it with a fresh prompt', async () => { + const result = await runner.runForeground(spawnRequest, { + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }); + + expect(result).toMatchObject({ + taskPath: '/root/research_api_0', + threadId: expect.any(String), + status: 'completed', + result: expect.objectContaining({ + runId: 'child-run-1', + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cost: 0.01 }, + }), + }); + expect(createToolExecutor).toHaveBeenCalledWith(runtimeSource.toolCodeByName); + expect(childAgent.close).toHaveBeenCalledTimes(1); + expect(buildFromJson).toHaveBeenCalledWith( + runnableConfig, + runtimeSource.toolDescriptors, + expect.objectContaining({ + toolExecutor, + credentialProvider, + skills: runtimeSource.skills, + memoryFactory: expect.any(Function), + }), + ); + // A delegated run gets an ordinary uuid thread id (no special structure). + // With no parent resource scope, memory isolates to the run's own thread, + // so resourceId === threadId. + expect(childAgent.stream).toHaveBeenCalledWith( + expect.stringContaining('Goal:\nFind the relevant API behavior.'), + expect.objectContaining({ + persistence: { + resourceId: result.threadId, + threadId: result.threadId, + }, + }), + ); + const childPrompt = childAgent.stream.mock.calls[0]?.[0] as string; + expect(childPrompt).toContain('Context:\nFocus on auth endpoints.'); + expect(childPrompt).toContain('Expected output:\nA concise summary.'); + // Every sub-agent run is a saved n8n agent, so it records under its run + // thread id, owned by the sub-agent's own id. + expect(agentExecutionService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: result.threadId, + agentId: 'agent-1', + source: 'subagent', + }), + ); + }); + + it('omits subAgents from the child config so delegated runs cannot spawn sub-agents', async () => { + sourceResolver.resolveForRuntime.mockResolvedValue({ + ...runtimeSource, + source: { + ...runtimeSource.source, + config: { + ...runnableConfig, + subAgents: { agents: [{ agentId: 'agent-nested' }] }, + }, + }, + }); + + await runner.runForeground(spawnRequest, { + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }); + + expect(buildFromJson).toHaveBeenCalledWith( + expect.not.objectContaining({ + subAgents: expect.anything(), + }), + runtimeSource.toolDescriptors, + expect.any(Object), + ); + }); + + it('inherits the parent resource id as the child memory scope when provided', async () => { + const result = await runner.runForeground( + { ...spawnRequest, parentResourceId: 'draft-chat:user-1' }, + { + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }, + ); + + expect(childAgent.stream).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + persistence: { + resourceId: 'draft-chat:user-1', + threadId: result.threadId, + }, + }), + ); + expect(result.threadId).toEqual(expect.any(String)); + }); + + it('uses the saved n8n agent id as memory owner and records the session under the run thread id', async () => { + sourceResolver.resolveForRuntime.mockResolvedValue({ + ...runtimeSource, + source: { + sourceId: 'agent-2', + versionId: 'version-1', + config: { + ...runnableConfig, + memory: { enabled: true, storage: 'n8n' }, + }, + }, + }); + jest.mocked(buildFromJson).mockImplementation(async (_config, _toolDescriptors, options) => { + await options.memoryFactory({ enabled: true, storage: 'n8n' }); + return childAgent as never; + }); + + const result = await runner.runForeground( + { + ...spawnRequest, + source: { agentId: 'agent-2', versionId: 'version-1' }, + }, + { + projectId, + parentAgentId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }, + ); + + // Memory is owned by the sub-agent's own id, exactly like a normal agent. + expect(createMemoryFactory).toHaveBeenCalledWith('agent-2'); + expect(childAgent.stream).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + persistence: { + resourceId: result.threadId, + threadId: result.threadId, + }, + }), + ); + expect(agentExecutionService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + // Same id as the SDK memory thread above, so title sync + deletion line up. + threadId: result.threadId, + agentId: 'agent-2', + agentName: 'Helper Agent', + projectId, + source: 'subagent', + // Parent linkage lives in columns, not in the thread id. + threadMetadata: { + parentThreadId, + parentAgentId, + }, + }), + ); + }); + + it('marks the run as failed when the child result contains an error', async () => { + childAgent.stream.mockResolvedValue( + makeStreamResult([ + { type: 'error', error: new Error('failed') }, + { type: 'finish', finishReason: 'error' }, + ]), + ); + + await expect( + runner.runForeground(spawnRequest, { + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }), + ).resolves.toMatchObject({ + status: 'failed', + }); + expect(childAgent.close).toHaveBeenCalledTimes(1); + }); + + it('passes an abort signal when timeout policy is configured', async () => { + await runner.runForeground( + { + ...spawnRequest, + policy: { timeoutMs: 1000 }, + }, + { + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }, + ); + + expect(childAgent.stream).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + abortSignal: expect.any(AbortSignal), + }), + ); + }); + + it('aborts the child run when the parent run is cancelled', async () => { + const parentAbort = new AbortController(); + childAgent.stream.mockImplementation( + async (_input, options) => + await new Promise((resolve) => { + const settle = () => + resolve( + makeStreamResult([ + { type: 'error', error: new Error('aborted') }, + { type: 'finish', finishReason: 'error' }, + ]), + ); + if (options?.abortSignal?.aborted) settle(); + else options?.abortSignal?.addEventListener('abort', settle, { once: true }); + }), + ); + + const run = runner.runForeground(spawnRequest, { + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + abortSignal: parentAbort.signal, + }); + + parentAbort.abort(); + + await expect(run).resolves.toMatchObject({ status: 'failed' }); + expect(childAgent.stream).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ abortSignal: expect.any(AbortSignal) }), + ); + }); + + it('returns failed status when timeout aborts the child run', async () => { + jest.useFakeTimers(); + childAgent.stream.mockImplementation( + async (_input, options) => + await new Promise((resolve) => { + options?.abortSignal?.addEventListener('abort', () => { + resolve( + makeStreamResult([ + { type: 'error', error: new Error('aborted') }, + { type: 'finish', finishReason: 'error' }, + ]), + ); + }); + }), + ); + + try { + const run = runner.runForeground( + { + ...spawnRequest, + policy: { timeoutMs: 1000 }, + }, + { + projectId, + credentialProvider, + createToolExecutor, + createMemoryFactory, + }, + ); + + await jest.advanceTimersByTimeAsync(1000); + + await expect(run).resolves.toMatchObject({ + status: 'failed', + }); + } finally { + jest.useRealTimers(); + } + }); +}); + +function makeStreamResult(chunks: StreamChunk[]): StreamResult { + return { + runId: 'child-run-1', + stream: new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }), + }; +} diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts new file mode 100644 index 00000000000..361df81db94 --- /dev/null +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts @@ -0,0 +1,180 @@ +import { type AgentJsonConfig } from '@n8n/api-types'; +import type { ToolDescriptor } from '@n8n/agents'; +import { mock } from 'jest-mock-extended'; + +import type { AgentHistory } from '../../entities/agent-history.entity'; +import type { Agent } from '../../entities/agent.entity'; +import type { AgentHistoryRepository } from '../../repositories/agent-history.repository'; +import type { AgentRepository } from '../../repositories/agent.repository'; +import { SubAgentSourceResolver } from '../sub-agent-source-resolver'; + +const projectId = 'project-1'; +const agentId = 'agent-1'; +const versionId = 'version-1'; + +const runnableConfig: AgentJsonConfig = { + name: 'Helper Agent', + description: 'Helps with delegated work', + model: 'anthropic/claude-sonnet-4-5', + credential: 'credential-1', + instructions: 'Be useful.', + config: { + maxIterations: 5, + }, +}; + +const customToolDescriptor: ToolDescriptor = { + name: 'lookup_customer', + description: 'Look up a customer', + systemInstruction: null, + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: null, + hasSuspend: false, + hasResume: false, + hasToMessage: false, + requireApproval: false, + providerOptions: null, +}; + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: agentId, + projectId, + versionId, + schema: runnableConfig, + integrations: [], + tools: {}, + skills: {}, + ...overrides, + } as unknown as Agent; +} + +function makeAgentHistory(overrides: Partial = {}): AgentHistory { + return { + agentId, + versionId, + schema: runnableConfig, + tools: {}, + skills: {}, + ...overrides, + } as unknown as AgentHistory; +} + +describe('SubAgentSourceResolver', () => { + let agentRepository: jest.Mocked; + let agentHistoryRepository: jest.Mocked; + let resolver: SubAgentSourceResolver; + + beforeEach(() => { + jest.clearAllMocks(); + agentRepository = mock(); + agentHistoryRepository = mock(); + resolver = new SubAgentSourceResolver(agentRepository, agentHistoryRepository); + }); + + it('resolves a saved draft n8n agent in the same project', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + + await expect(resolver.resolveForRuntime({ agentId }, { projectId })).resolves.toMatchObject({ + source: { + sourceId: agentId, + versionId, + config: { + ...runnableConfig, + integrations: [], + }, + }, + }); + }); + + it('resolves a saved n8n agent version', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(makeAgentHistory()); + + await expect( + resolver.resolveForRuntime({ agentId, versionId }, { projectId }), + ).resolves.toMatchObject({ + source: { + sourceId: agentId, + versionId, + config: runnableConfig, + }, + }); + }); + + it('resolves runtime assets for saved n8n agent drafts', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue( + makeAgent({ + tools: { + tool_1: { + code: 'return input;', + descriptor: customToolDescriptor, + }, + }, + skills: { + skill_1: { + name: 'Skill 1', + description: 'Helps with tests', + instructions: 'Skill body', + }, + }, + }), + ); + + await expect(resolver.resolveForRuntime({ agentId }, { projectId })).resolves.toMatchObject({ + source: { + sourceId: agentId, + }, + toolDescriptors: { + tool_1: customToolDescriptor, + }, + toolCodeByName: { + lookup_customer: 'return input;', + }, + skills: { + skill_1: { + name: 'Skill 1', + description: 'Helps with tests', + instructions: 'Skill body', + }, + }, + }); + }); + + it('rejects missing or inaccessible n8n agents', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(null); + + await expect(resolver.resolveForRuntime({ agentId }, { projectId })).rejects.toThrow( + `Agent "${agentId}" not found`, + ); + }); + + it('rejects n8n agents with no config', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ schema: null })); + + await expect(resolver.resolveForRuntime({ agentId }, { projectId })).rejects.toThrow( + `Agent "${agentId}" has no config`, + ); + }); + + it('rejects a pinned version that does not exist', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(null); + + await expect(resolver.resolveForRuntime({ agentId, versionId }, { projectId })).rejects.toThrow( + `Version "${versionId}" not found for agent "${agentId}"`, + ); + }); + + it('rejects a resolved config that is not runnable', async () => { + const { credential: _credential, ...invalidConfig } = runnableConfig; + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ schema: invalidConfig })); + + await expect(resolver.resolveForRuntime({ agentId }, { projectId })).rejects.toThrow( + 'Invalid sub-agent config', + ); + }); +}); diff --git a/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts b/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts new file mode 100644 index 00000000000..fcb9b14400a --- /dev/null +++ b/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts @@ -0,0 +1,86 @@ +import { + createDelegateSubAgentTool, + generateResultToDelegateSubAgentOutput, + type DelegateSubAgentToolOutput, +} from '@n8n/agents'; +import type { SubAgentRunPolicy, SubAgentSource } from '@n8n/api-types'; + +import type { + SubAgentForegroundRunContext, + SubAgentForegroundResult, + SubAgentForegroundRunner, +} from './sub-agent-foreground-runner'; + +export interface CreateN8nDelegateSubAgentToolOptions extends SubAgentForegroundRunContext { + runner: SubAgentForegroundRunner; + sourcesById: Record; + availableSubAgents?: Array<{ id: string; name: string; description?: string }>; + policy?: SubAgentRunPolicy; +} + +export function createN8nDelegateSubAgentTool(options: CreateN8nDelegateSubAgentToolOptions) { + const { runner, sourcesById, availableSubAgents, policy, ...runContext } = options; + + return createDelegateSubAgentTool({ + ...(availableSubAgents !== undefined ? { availableSubAgents } : {}), + ...(policy !== undefined ? { policy } : {}), + runSubAgent: async (request) => { + const selectedSource = selectSubAgentSource({ + sourcesById, + subAgentId: request.subAgentId, + }); + if (!selectedSource) { + return { + status: 'failed', + answer: + 'No subagent matched this request. Provide subAgentId when multiple configured subagents are available.', + }; + } + + const result = await runner.runForeground( + { + goal: request.goal, + source: selectedSource, + executionMode: 'foreground', + ...(request.context !== undefined ? { context: request.context } : {}), + ...(request.expectedOutput !== undefined + ? { expectedOutput: request.expectedOutput } + : {}), + ...(policy !== undefined ? { policy } : {}), + ...(request.parentThreadId !== undefined + ? { parentThreadId: request.parentThreadId } + : {}), + ...(request.parentResourceId !== undefined + ? { parentResourceId: request.parentResourceId } + : {}), + taskPath: request.taskPath, + }, + { + ...runContext, + ...(request.parentAbortSignal !== undefined + ? { abortSignal: request.parentAbortSignal } + : {}), + }, + ); + + return formatSubAgentToolOutput(result); + }, + }); +} + +function selectSubAgentSource(options: { + sourcesById: Record; + subAgentId?: string; +}): SubAgentSource | undefined { + const { sourcesById, subAgentId } = options; + if (subAgentId) return sourcesById?.[subAgentId]; + + const sources = Object.values(sourcesById); + return sources.length === 1 ? sources[0] : undefined; +} + +export function formatSubAgentToolOutput( + result: SubAgentForegroundResult, +): DelegateSubAgentToolOutput { + return generateResultToDelegateSubAgentOutput(result.taskPath, result.result, result.threadId); +} diff --git a/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts b/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts new file mode 100644 index 00000000000..a6803ed89fb --- /dev/null +++ b/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts @@ -0,0 +1,287 @@ +import { + assertSubAgentTaskPath, + renderDelegateSubAgentPrompt, + type AgentExecutionCounter, + type AgentMessage, + type CredentialProvider, + type GenerateResult, + type SubAgentTaskPath, +} from '@n8n/agents'; +import { Logger } from '@n8n/backend-common'; +import type { ResolvedSubAgentSource, SubAgentSpawnRequest } from '@n8n/api-types'; +import { Service } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; +import { v4 as uuid } from 'uuid'; + +import { AgentExecutionService } from '../agent-execution.service'; +import { ExecutionRecorder } from '../execution-recorder'; +import type { MessageRecord } from '../execution-recorder'; +import { + buildFromJson, + type MemoryFactory, + type ToolExecutor, + type ToolResolver, +} from '../json-config/from-json-config'; +import { SubAgentSourceResolver } from './sub-agent-source-resolver'; + +export interface SubAgentForegroundRunContext { + projectId: string; + /** Saved n8n agent id of the delegating parent agent, used to link the child session back. */ + parentAgentId?: string; + credentialProvider: CredentialProvider; + createToolExecutor(toolCodeByName: Record): ToolExecutor; + createMemoryFactory(memoryOwnerAgentId: string): MemoryFactory; + resolveTool?: ToolResolver; + executionCounter?: AgentExecutionCounter; + /** Parent run's abort signal — cancelling the parent cancels this child. */ + abortSignal?: AbortSignal; +} + +export interface SubAgentForegroundResult { + taskPath: SubAgentTaskPath; + /** The child run's memory/session thread id, so callers can link or continue it. */ + threadId: string; + status: 'completed' | 'failed'; + result: GenerateResult; +} + +@Service() +export class SubAgentForegroundRunner { + constructor( + private readonly sourceResolver: SubAgentSourceResolver, + private readonly agentExecutionService: AgentExecutionService, + private readonly logger: Logger, + ) {} + + async runForeground( + request: SubAgentSpawnRequest, + context: SubAgentForegroundRunContext, + ): Promise { + // Background execution (dispatch, return a receipt, reconcile the result + // later) is not yet implemented. Tracked in AGENT-186: + // https://linear.app/n8n/issue/AGENT-186 + if (request.executionMode !== undefined && request.executionMode !== 'foreground') { + throw new UserError('Foreground sub-agent runner only supports foreground execution mode'); + } + + // The SDK delegate tool already assigned this delegation's task path and + // enforced fan-out policy before invoking the runner. Just validate the + // forwarded shape — don't recompute it or re-run the gates. + const taskPath = request.taskPath; + assertSubAgentTaskPath(taskPath); + + const runtimeSource = await this.sourceResolver.resolveForRuntime(request.source, { + projectId: context.projectId, + }); + + const toolExecutor = context.createToolExecutor(runtimeSource.toolCodeByName); + // A delegated run is a fresh conversation, so it gets an ordinary thread id + // (a uuid) — exactly like any other agent run, with no special structure. + // The parent linkage is persisted as columns on the session record + // (origin / parentThreadId / parentAgentId), never encoded into the id. + // The same id is shared by the SDK memory thread and the session record so + // title sync, recall, and deletion all resolve to the same thread, and it is + // returned on the result so a caller can re-supply it to continue the thread. + const threadId = uuid(); + // Inherit the parent's episodic-memory scope. When the parent has none, + // isolate this run to its own thread rather than widening to the project. + const resourceId = request.parentResourceId ?? threadId; + const { subAgents: _subAgents, ...childConfig } = runtimeSource.source.config; + const agent = await buildFromJson(childConfig, runtimeSource.toolDescriptors, { + toolExecutor, + credentialProvider: context.credentialProvider, + resolveTool: context.resolveTool, + skills: runtimeSource.skills, + memoryFactory: createSubAgentMemoryFactory(runtimeSource.source, context), + }); + + const timeoutController = request.policy?.timeoutMs ? new AbortController() : undefined; + const timeout = timeoutController + ? setTimeout(() => timeoutController.abort(), request.policy?.timeoutMs) + : undefined; + // Abort the child when the parent run is cancelled or the timeout fires. + const abortSignal = combineAbortSignals(context.abortSignal, timeoutController?.signal); + + const prompt = renderDelegateSubAgentPrompt(request); + try { + const resultStream = await agent.stream(prompt, { + ...(abortSignal !== undefined ? { abortSignal } : {}), + persistence: { + resourceId, + threadId, + }, + executionCounter: context.executionCounter, + }); + const recorder = new ExecutionRecorder(); + let structuredOutput: unknown; + + const reader = resultStream.stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + recorder.record(value); + if (value.type === 'finish' && value.structuredOutput !== undefined) { + structuredOutput = value.structuredOutput; + } + } + } finally { + reader.releaseLock(); + } + + const messageRecord = recorder.getMessageRecord(); + await this.recordSubAgentExecution({ + runtimeSource: runtimeSource.source, + projectId: context.projectId, + threadId, + parentThreadId: request.parentThreadId, + parentAgentId: context.parentAgentId, + taskPath, + prompt, + record: messageRecord, + }); + const result = buildGenerateResultFromRecord( + resultStream.runId, + messageRecord, + structuredOutput, + ); + + return { + taskPath, + threadId, + status: + result.finishReason === 'error' || result.error !== undefined ? 'failed' : 'completed', + result, + }; + } finally { + if (timeout) clearTimeout(timeout); + // Each delegation builds its own child agent, so release it here: + // dispose the runtime's background tasks and disconnect any MCP + // transports instead of leaking them per delegated run. + await agent.close().catch((error) => { + this.logger.warn('Failed to close subagent after run', { + taskPath, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + } + + private async recordSubAgentExecution(params: { + runtimeSource: ResolvedSubAgentSource; + projectId: string; + /** Unified thread id, shared with the SDK memory thread. */ + threadId: string; + parentThreadId?: string; + parentAgentId?: string; + taskPath: SubAgentTaskPath; + prompt: string; + record: MessageRecord; + }): Promise { + const { + runtimeSource, + projectId, + threadId, + parentThreadId, + parentAgentId, + taskPath, + prompt, + record, + } = params; + + try { + await this.agentExecutionService.recordMessage({ + threadId, + agentId: runtimeSource.sourceId, + agentName: runtimeSource.config.name, + projectId, + userMessage: prompt, + record, + source: 'subagent', + threadMetadata: { + ...(parentThreadId !== undefined ? { parentThreadId } : {}), + ...(parentAgentId !== undefined ? { parentAgentId } : {}), + }, + }); + } catch (error) { + this.logger.warn('Failed to record subagent execution', { + agentId: runtimeSource.sourceId, + taskPath, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +function buildGenerateResultFromRecord( + runId: string, + record: MessageRecord, + structuredOutput: unknown, +): GenerateResult { + const messages = createAssistantMessages(record.assistantResponse); + const finishReason = toKnownFinishReason(record.finishReason); + const result: GenerateResult = { + runId, + messages, + ...(record.model !== null ? { model: record.model } : {}), + ...(finishReason !== undefined ? { finishReason } : {}), + ...(record.usage !== null + ? { + usage: { + ...record.usage, + ...(record.totalCost !== null ? { cost: record.totalCost } : {}), + }, + } + : {}), + ...(structuredOutput !== undefined ? { structuredOutput } : {}), + ...(record.error !== null ? { error: record.error } : {}), + }; + return result; +} + +function createAssistantMessages(text: string): AgentMessage[] { + if (!text.trim()) return []; + + return [ + { + role: 'assistant', + content: [{ type: 'text', text }], + }, + ]; +} + +function toKnownFinishReason( + value: string, +): NonNullable | undefined { + if ( + value === 'stop' || + value === 'length' || + value === 'content-filter' || + value === 'tool-calls' || + value === 'error' || + value === 'other' || + value === 'max-iterations' + ) { + return value; + } + return undefined; +} + +function createSubAgentMemoryFactory( + source: ResolvedSubAgentSource, + context: SubAgentForegroundRunContext, +): MemoryFactory { + return async (params) => { + return await context.createMemoryFactory(source.sourceId)(params); + }; +} + +/** Merge up to two abort signals: cancellation of either cancels the child run. */ +function combineAbortSignals( + a: AbortSignal | undefined, + b: AbortSignal | undefined, +): AbortSignal | undefined { + const signals = [a, b].filter((signal): signal is AbortSignal => signal !== undefined); + if (signals.length <= 1) return signals[0]; + return AbortSignal.any(signals); +} diff --git a/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts b/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts new file mode 100644 index 00000000000..84898af1f6a --- /dev/null +++ b/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts @@ -0,0 +1,123 @@ +import { + type AgentSkill, + RunnableAgentJsonConfigSchema, + type AgentJsonConfig, + type ResolvedSubAgentSource, + type SubAgentSource, +} from '@n8n/api-types'; +import type { ToolDescriptor } from '@n8n/agents'; +import { Service } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; + +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import type { AgentHistory } from '../entities/agent-history.entity'; +import type { Agent } from '../entities/agent.entity'; +import { composeJsonConfig } from '../json-config/agent-config-composition'; +import { AgentHistoryRepository } from '../repositories/agent-history.repository'; +import { AgentRepository } from '../repositories/agent.repository'; + +export interface ResolveSubAgentSourceContext { + projectId: string; +} + +export interface ResolvedSubAgentRuntimeSource { + source: ResolvedSubAgentSource; + toolDescriptors: Record; + toolCodeByName: Record; + skills: Record; +} + +@Service() +export class SubAgentSourceResolver { + constructor( + private readonly agentRepository: AgentRepository, + private readonly agentHistoryRepository: AgentHistoryRepository, + ) {} + + /** + * Resolve a saved n8n agent (its current draft, or a pinned published + * version) into a runnable config plus its tool/skill assets. + */ + async resolveForRuntime( + source: SubAgentSource, + context: ResolveSubAgentSourceContext, + ): Promise { + const agent = await this.agentRepository.findByIdAndProjectId( + source.agentId, + context.projectId, + ); + if (!agent) { + throw new NotFoundError(`Agent "${source.agentId}" not found`); + } + + if (source.versionId) { + const version = await this.agentHistoryRepository.findByVersionAndAgentId( + source.versionId, + source.agentId, + ); + if (!version) { + throw new NotFoundError( + `Version "${source.versionId}" not found for agent "${source.agentId}"`, + ); + } + if (!version.schema) { + throw new UserError( + `Agent "${source.agentId}" version "${source.versionId}" has no config`, + ); + } + + return { + source: { + sourceId: source.agentId, + versionId: source.versionId, + config: this.toRunnableConfig(version.schema), + }, + ...getAgentRuntimeAssets(version), + }; + } + + const config = composeJsonConfig(agent); + if (!config) { + throw new UserError(`Agent "${source.agentId}" has no config`); + } + + return { + source: { + sourceId: source.agentId, + versionId: agent.versionId ?? undefined, + config: this.toRunnableConfig(config), + }, + ...getAgentRuntimeAssets(agent), + }; + } + + private toRunnableConfig(config: AgentJsonConfig): ResolvedSubAgentSource['config'] { + const result = RunnableAgentJsonConfigSchema.safeParse(config); + if (!result.success) { + throw new UserError( + `Invalid sub-agent config: ${result.error.issues[0]?.message ?? 'Invalid config'}`, + ); + } + + return result.data; + } +} + +function getAgentRuntimeAssets( + agent: Pick, +): Omit { + const toolDescriptors: Record = {}; + const toolCodeByName: Record = {}; + + for (const [toolId, toolEntry] of Object.entries(agent.tools ?? {})) { + toolDescriptors[toolId] = toolEntry.descriptor; + toolCodeByName[toolEntry.descriptor.name] = toolEntry.code; + } + + return { + toolDescriptors, + toolCodeByName, + skills: agent.skills ?? {}, + }; +} diff --git a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts index af36970bad0..3cf7d5cba7c 100644 --- a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts +++ b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts @@ -66,6 +66,8 @@ function timelineToolCallToPart(event: ToolCallTimelineEvent): AgentPersistedMes toolName: event.name, toolCallId: event.toolCallId, input: event.input, + ...(event.startTime > 0 ? { startTime: event.startTime } : {}), + ...(event.endTime > 0 ? { endTime: event.endTime } : {}), }; if (state === undefined) return base; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 72f4347c335..7c82661d95a 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1347,6 +1347,10 @@ "agentSessions.lastMessage": "Last Message", "agentSessions.tokenUsage": "Token Usage", "agentSessions.testChat": "Test chat", + "agentSessions.origin": "Origin", + "agentSessions.origin.agent": "Agent", + "agentSessions.origin.subAgent": "Sub-agent", + "agentSessions.goToParentRun": "Go to parent run", "agentSessions.actions": "Actions", "agentSessions.empty": "No agent sessions", "agentSessions.loadMore": "Load more", @@ -1358,8 +1362,7 @@ "agentSessions.showError.load": "Problem loading sessions", "agentSessions.status": "Status", "agentSessions.duration": "Duration", - "agentSessions.origin": "Origin", - "agentSessions.origin.agent": "Agent", + "agentSessions.sessionId": "Session ID", "agentSessions.origin.task": "Task", "agentSessions.success": "Success", "agentSessions.detail.backToSessions": "Back to sessions", @@ -1409,6 +1412,7 @@ "agentSessions.timeline.suspended": "Suspended", "agentSessions.timeline.idle": "Idle", "agentSessions.timeline.agent": "Agent", + "agentSessions.timeline.subAgent": "Sub-agent", "agentSessions.timeline.tool.richInteraction": "Feedback requested from user", "agentSessions.timeline.tool.richInteractionDisplay": "Card sent to user", "agentSessions.timeline.waitingForUser": "Waiting for user response", @@ -6107,6 +6111,9 @@ "agents.chat.askCredential.skip": "Skip", "agents.chat.toolNames.webSearch": "Web search", "agents.chat.toolNames.searchKnowledge": "Search knowledge", + "agents.chat.delegate.label": "Sub-agent · {name}", + "agents.chat.delegate.labelFallback": "Sub-agent", + "agents.chat.toolStep.waitingForInput": "Waiting for your input", "agents.chat.askQuestion.otherLabel": "Other", "agents.chat.askQuestion.otherPlaceholder": "Type another answer", "agents.chat.askQuestion.answerLabel": "Your answer", @@ -6281,6 +6288,18 @@ "agents.builder.files.size.bytes": "{bytes} B", "agents.builder.files.size.kilobytes": "{kilobytes} KB", "agents.builder.files.size.megabytes": "{megabytes} MB", + "agents.builder.subAgents.title": "Sub-agents", + "agents.builder.subAgents.description": "Let this agent delegate focused subtasks to selected agents in this project. Add at least one published agent to enable delegation.", + "agents.builder.subAgents.add": "Add agent", + "agents.builder.subAgents.loadError": "Could not load project agents", + "agents.builder.subAgents.remove": "Remove {name}", + "agents.builder.subAgents.modal.title": "Add sub-agents", + "agents.builder.subAgents.modal.description": "Select published agents from this project to let this agent delegate work to them.", + "agents.builder.subAgents.modal.selectAgent": "Select {name}", + "agents.builder.subAgents.modal.empty.title": "No agents to add", + "agents.builder.subAgents.modal.empty.description": "Published agents from this project show here. Already added agents are hidden.", + "agents.builder.subAgents.modal.cancel": "Cancel", + "agents.builder.subAgents.modal.add": "Add agents", "agents.builder.memory.recallModel.label": "Memory model", "agents.builder.memory.recallModel.hint": "Choose the model that creates, reviews, and retrieves memories. Uses the agent model by default.", "agents.builder.episodicMemoryCredentialModal.title": "Episodic Memory", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts index f0d1cf48048..6a2ffd46e6f 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts @@ -2,12 +2,20 @@ import { describe, it, expect, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { ref } from 'vue'; +import { createTestingPinia } from '@pinia/testing'; vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string) => key }), i18n: { baseText: (key: string) => key }, })); +vi.mock('../composables/useProjectAgentsList', () => ({ + useProjectAgentsList: () => ({ + list: { value: [] }, + ensureLoaded: vi.fn().mockResolvedValue([]), + }), +})); + // First mount of these SFCs eats the Vite transform cost; give them headroom. vi.setConfig({ testTimeout: 30_000 }); @@ -36,11 +44,20 @@ describe('AgentBuilderEditorColumn — childrenDisabled composes streaming and c executionsDescription: '', }, global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], stubs: { N8nCard: { template: '
' }, N8nHeading: { template: '
' }, + N8nButton: { template: '' }, + N8nIcon: { template: '', props: ['icon', 'size'] }, + N8nIconButton: { template: '' }, N8nCard: { template: '
', props: ['variant'] }, N8nHeading: { template: '

', props: ['size'] }, N8nIcon: { template: '', props: ['icon', 'size'] }, - N8nIconButton: { template: '' }, + N8nIconButton: { + template: '', + props: ['disabled', 'ariaLabel'], + }, N8nLoading: { template: '
', props: ['rows', 'variant'] }, N8nRadioButtons: { template: '
', props: ['modelValue', 'options'] }, N8nScrollArea: { template: '
', props: ['maxHeight', 'type'] }, N8nSwitch: { template: '' }, + N8nSwitch2: { template: '