diff --git a/packages/@n8n/agents/AGENTS.md b/packages/@n8n/agents/AGENTS.md index b674e60fba7..7f152401aa3 100644 --- a/packages/@n8n/agents/AGENTS.md +++ b/packages/@n8n/agents/AGENTS.md @@ -117,7 +117,6 @@ class EngineAgent extends Agent { Always assume it exists when running integration tests. Never commit it. - Required keys: - `ANTHROPIC_API_KEY` — all integration tests - - `OPENAI_API_KEY` — semantic recall tests (embeddings) - Tests skip automatically when the required API key is not set - Run from the package directory: `cd packages/@n8n/agents && pnpm test` diff --git a/packages/@n8n/agents/docs/agent-runtime-architecture.md b/packages/@n8n/agents/docs/agent-runtime-architecture.md index 72ffae56b76..3d5e59767e4 100644 --- a/packages/@n8n/agents/docs/agent-runtime-architecture.md +++ b/packages/@n8n/agents/docs/agent-runtime-architecture.md @@ -12,7 +12,7 @@ final response. for a single agent turn. It uses the Vercel AI SDK directly (`generateText` / `streamText`) and is responsible for: -- Building the LLM message context (memory history, semantic recall, working +- Building the LLM message context (memory history, working memory in the system prompt, user input) - Stripping orphaned tool-call/tool-result pairs before LLM calls (`stripOrphanedToolMessages`) @@ -23,8 +23,7 @@ for a single agent turn. It uses the Vercel AI SDK directly (`generateText` / in parallel) - Suspending and resuming runs for Human-in-the-Loop (HITL) **and** for tools that return a branded suspend result (`suspendSchema` / `resumeSchema`) -- Persisting new messages to a memory store at the end of each completed turn, - optionally saving **embeddings** for semantic recall +- Persisting new messages to a memory store at the end of each completed turn - Extracting and persisting **working memory** from assistant output when configured - Optional **structured output** (`Output.object` + Zod), **thinking** / @@ -72,6 +71,40 @@ well as `agent.abort()`. --- +## Inline Sub-Agent Delegation + +`createDelegateSubAgentTool()` can be registered directly on an `Agent` without +a host `runSubAgent` callback. In that mode, `Agent.build()` completes the tool +with the SDK's inline child runner after the parent model and effective tool +surface have been resolved. + +```typescript +const agent = new Agent('parent') + .model('anthropic/claude-sonnet-4-5') + .instructions('...') + .tool(searchTool) + .tool(createDelegateSubAgentTool()); +``` + +The model selects the default inline path by passing `subAgentId: "inline"`. +When a host supplies a `runSubAgent` callback, `Agent.build()` routes every +delegation (including `"inline"`) through that callback and passes +`helpers.runInlineSubAgent` so the host can reuse the SDK inline runner. Without a +host callback, `"inline"` is handled by the SDK inline runner directly. Both paths +return the same `DelegateSubAgentToolOutput` shape and emit the same sub-agent +lifecycle events. + +Inline children: + +- reuse the parent model config for this first implementation +- start from the parent agent's effective local/deferred tool list +- always drop SDK-blocked tools such as `delegate_subagent`, `write_todos`, and memory recall +- may drop additional host-blocked local/deferred tool names configured on the delegate tool +- inherit parent provider tools after the same blocklist filtering +- run in a fresh context using the shared delegated-task prompt + +--- + ## Event system ### AgentEventBus @@ -351,8 +384,7 @@ implement TTL or eviction as needed. ## Memory persistence At end of turn, `saveToMemory()` uses `list.turnDelta()` and -`saveMessagesToThread`. If **semantic recall** is configured with an embedder -and `memory.saveEmbeddings`, new messages are embedded and stored. +`saveMessagesToThread`. **Working memory:** when configured, the runtime injects an `update_working_memory` tool into the agent's tool set. The current state is included in the system prompt diff --git a/packages/@n8n/agents/examples/basic-agent.ts b/packages/@n8n/agents/examples/basic-agent.ts index b3ec7b1c04f..4f1e2e55ef7 100644 --- a/packages/@n8n/agents/examples/basic-agent.ts +++ b/packages/@n8n/agents/examples/basic-agent.ts @@ -10,7 +10,7 @@ */ import { z } from 'zod'; -import { Agent, Guardrail, Memory, Tool } from '../src'; +import { Agent, Guardrail, Memory, Tool, createDelegateSubAgentTool } from '../src'; // --------------------------------------------------------------------------- // Tools @@ -64,10 +64,7 @@ const writeFileTool = new Tool('write-file') // Memory // --------------------------------------------------------------------------- -const memory = new Memory().semanticRecall({ - topK: 4, - messageRange: { before: 1, after: 1 }, -}); +const memory = new Memory(); // --------------------------------------------------------------------------- // Agents @@ -79,6 +76,10 @@ const researcher = new Agent('researcher') 'You are a research assistant. Search for information and return structured findings.', ) .tool(searchTool) + // No runSubAgent callback: the SDK creates an inline child that reuses this + // agent's model and filtered tools whenever the model calls delegate_subagent + // with subAgentId: "inline". + .tool(createDelegateSubAgentTool({ policy: { maxChildren: 2 } })) .memory(memory) .inputGuardrail( new Guardrail('injection-detector').type('prompt-injection').strategy('block').threshold(0.8), 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 index 58592aac017..b2205432189 100644 --- a/packages/@n8n/agents/src/__tests__/integration/delegate-sub-agent.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/delegate-sub-agent.test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; -import { describeIf, getModel } from './helpers'; +import { describeIf } from './helpers'; import { Agent, createDelegateSubAgentTool, @@ -14,49 +14,16 @@ 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 delegateTool = createDelegateSubAgentTool({ policy: { maxChildren: 1 } }); const parent = new Agent('sub-agent-parent-integration') - .model(getModel('anthropic')) + .model('anthropic/claude-sonnet-4-5') .instructions( [ 'You are a parent test agent.', - 'You must call delegate_subagent exactly once before answering.', + 'This is a delegation wiring test: you must call delegate_subagent exactly once before answering.', + 'Treat the child task as a bounded independent workstream that only the child should complete.', + 'Set subAgentId to "inline" in that tool call.', '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(' '), @@ -65,7 +32,7 @@ describe('delegate_subagent integration', () => { try { const result = await parent.generate( - 'Use delegate_subagent now to ask the child for its sentinel token.', + `Complete this two-part verification task. Delegate the token-production workstream to a child agent, and make the delegated goal instruct the child to answer with exactly this token and nothing else: ${SENTINEL}. Then synthesize only from the child result.`, ); expect(result.toolCalls?.map((toolCall) => toolCall.tool) ?? []).toContain( @@ -88,7 +55,6 @@ describe('delegate_subagent integration', () => { expect(delegateOutput.taskPath).toMatch(/^\/root\/[a-z0-9_]+$/); } finally { await parent.close(); - await child.close(); } }, 60_000); }); @@ -107,7 +73,7 @@ function lastText(messages: AgentMessage[]): string { } function isDelegateOutput(value: unknown): value is { - status: 'completed' | 'failed'; + status: 'completed' | 'failed' | 'suspended'; taskPath: string; runId: string; answer: string; diff --git a/packages/@n8n/agents/src/__tests__/integration/memory/memory-semantic.test.ts b/packages/@n8n/agents/src/__tests__/integration/memory/memory-semantic.test.ts deleted file mode 100644 index bffe38893cf..00000000000 --- a/packages/@n8n/agents/src/__tests__/integration/memory/memory-semantic.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, it, afterEach, describe as _describe } from 'vitest'; - -import { Agent, Memory } from '../../../index'; -import { findLastTextContent, getModel, createInMemoryAgentMemory } from '../helpers'; - -// Only run when both API keys are present -const describe = - process.env.ANTHROPIC_API_KEY && process.env.OPENAI_API_KEY ? _describe : _describe.skip; - -const cleanups: Array<() => void> = []; -afterEach(() => { - cleanups.forEach((fn) => fn()); - cleanups.length = 0; -}); - -describe('semantic recall', () => { - it('recalls relevant info from earlier in the thread via semantic search', async () => { - const { memory, cleanup } = createInMemoryAgentMemory(); - cleanups.push(cleanup); - - const mem = new Memory() - .storage(memory) - .semanticRecall({ topK: 3, embedder: 'openai/text-embedding-3-small' }); - - const agent = new Agent('semantic-test') - .model(getModel('anthropic')) - .instructions('You are a helpful assistant. Be concise. Answer from your context.') - .memory(mem); - - const threadId = `semantic-${Date.now()}`; - const resourceId = 'test-user'; - const options = { persistence: { threadId, resourceId } }; - - // Turn 1: unique fact recalled later via semantic search - await agent.generate( - 'The annual rainfall in Timbuktu is approximately 200mm. Just acknowledge.', - options, - ); - - // Filler turns between the fact and the later question - await agent.generate('What is 2 + 2?', options); - await agent.generate('Tell me a one-word synonym for happy.', options); - await agent.generate('What color is the sky?', options); - - // Ask about the fact from turn 1 — should be recalled via semantic search - const result = await agent.generate('What is the annual rainfall in Timbuktu?', options); - - expect(findLastTextContent(result.messages)?.toLowerCase()).toContain('200'); - }); -}); diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index 2bdf232d90e..2aecfc8db15 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -62,7 +62,6 @@ export type { NewEpisodicMemoryEntrySource, NewEpisodicMemoryEntrySourceForEntry, RetrievedEpisodicMemoryEntry, - SemanticRecallConfig, ResumeOptions, McpServerConfig, McpVerifyResult, @@ -200,7 +199,6 @@ export type { ToolDescriptor } from './types/sdk/tool-descriptor'; export { createModel } from './runtime/model-factory'; export { ROOT_SUB_AGENT_TASK_PATH, - assertSubAgentPolicyAllowsChild, assertSubAgentPolicyAllowsChildCount, assertSubAgentTaskPath, createChildSubAgentTaskPath, @@ -210,8 +208,12 @@ export { export type { SubAgentTaskPath, SubAgentTaskPathPolicy } from './runtime/sub-agent-task-path'; export { DELEGATE_SUB_AGENT_TOOL_NAME, + DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + INLINE_SUB_AGENT_ID, createDelegateSubAgentTool, + failedDelegatedChildSuspendOutput, generateResultToDelegateSubAgentOutput, + getInlineDelegateSubAgentToolOptions, renderDelegateSubAgentPrompt, } from './runtime/delegate-sub-agent-tool'; export type { @@ -219,8 +221,11 @@ export type { DelegateSubAgentInput, DelegateSubAgentPolicy, DelegateSubAgentRequest, + DelegateSubAgentRunner, + DelegateSubAgentRunnerHelpers, DelegateSubAgentToolOutput, } from './runtime/delegate-sub-agent-tool'; +export { WRITE_TODOS_TOOL_NAME, createWriteTodosTool } from './runtime/write-todos-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 7402fe89d83..39d9ad1246b 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -8,7 +8,6 @@ import { Tool, Tool as ToolBuilder } from '../../sdk/tool'; import { AgentEvent } from '../../types/runtime/event'; 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, ToolContext } from '../../types/sdk/tool'; import type { BuiltTelemetry } from '../../types/telemetry'; @@ -2869,8 +2868,7 @@ describe('AgentRuntime — observation log jobs', () => { it('schedules observation after a persisted stream turn', async () => { streamText.mockReturnValue(makeStreamSuccess('Remembered response')); - const memory = new InMemoryMemory() as InMemoryMemory & - Required>; + const memory = new InMemoryMemory(); await memory.saveThread({ id: 'thread-1', resourceId: 'resource-1' }); const runtime = new AgentRuntime({ @@ -2910,8 +2908,7 @@ describe('AgentRuntime — observation log jobs', () => { it('schedules observation after a persisted generate turn', async () => { generateText.mockResolvedValue(makeGenerateSuccess('Remembered response')); - const memory = new InMemoryMemory() as InMemoryMemory & - Required>; + const memory = new InMemoryMemory(); await memory.saveThread({ id: 'thread-1', resourceId: 'resource-1' }); const runtime = new AgentRuntime({ @@ -2949,8 +2946,7 @@ describe('AgentRuntime — observation log jobs', () => { generateText.mockResolvedValue(makeGenerateSuccess('Remembered response')); embed.mockResolvedValue({ embedding: [1, 0], usage: { tokens: 1 } }); embedMany.mockResolvedValue({ embeddings: [[1, 0]], usage: { tokens: 1 } }); - const memory = new InMemoryMemory() as InMemoryMemory & - Required>; + const memory = new InMemoryMemory(); const fakeEmbedder = { specificationVersion: 'v2' } as never; const observationLockSpy = vi.spyOn(memory, 'acquireObservationLogTaskLock'); const episodicLockSpy = vi.spyOn(memory.episodic.taskLock!, 'acquire'); @@ -3116,55 +3112,6 @@ describe('AgentRuntime — observation log jobs', () => { expect(embed).not.toHaveBeenCalled(); }); - it('counts semantic recall query and saved message embedding tokens', async () => { - generateText.mockResolvedValue(makeGenerateSuccess('Remembered response')); - embed.mockResolvedValue({ embedding: [1, 0], usage: { tokens: 5 } }); - embedMany.mockResolvedValue({ - embeddings: [ - [1, 0], - [0, 1], - ], - usage: { tokens: 13 }, - }); - const counter = makeExecutionCounter(); - const memory = new InMemoryMemory() as InMemoryMemory & - Required>; - await memory.saveThread({ id: 'thread-1', resourceId: 'resource-1' }); - await memory.saveMessages({ - threadId: 'thread-1', - resourceId: 'resource-1', - messages: [ - { - id: 'old-1', - createdAt: new Date('2026-05-12T10:00:00.000Z'), - role: 'user', - content: [{ type: 'text', text: 'Earlier Postgres decision.' }], - }, - ], - }); - memory.queryEmbeddings = async () => await Promise.resolve([{ id: 'old-1', score: 1 }]); - memory.saveEmbeddings = async () => await Promise.resolve(); - - const runtime = new AgentRuntime({ - name: 'semantic-agent', - model: 'openai/gpt-4o-mini', - instructions: 'You are a test assistant.', - memory, - semanticRecall: { - embedder: 'openai/text-embedding-3-small', - topK: 1, - }, - }); - - await runtime.generate('What did we decide?', { - persistence: { threadId: 'thread-1', resourceId: 'resource-1' }, - executionCounter: counter, - }); - - expect(counter.incrementTokenCount).toHaveBeenCalledWith(5); - expect(counter.incrementTokenCount).toHaveBeenCalledWith(13); - }); - it('counts recall_memory query embedding tokens', async () => { generateText .mockResolvedValueOnce( @@ -3877,7 +3824,6 @@ describe('AgentRuntime — telemetry propagation', () => { }); }); -// --------------------------------------------------------------------------- // Cancellation (Feature 1: cancel suspended tool via user message) // --------------------------------------------------------------------------- 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 index 2dbac8286db..079f5ef6df4 100644 --- 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 @@ -1,15 +1,18 @@ +import { vi } from 'vitest'; + import { AgentEvent, type AgentEventData } from '../../types/runtime/event'; import type { GenerateResult } from '../../types/sdk/agent'; import { DELEGATE_SUB_AGENT_TOOL_NAME, + INLINE_SUB_AGENT_ID, createDelegateSubAgentTool, generateResultToDelegateSubAgentOutput, renderDelegateSubAgentPrompt, - type DelegateSubAgentRequest, - type DelegateSubAgentToolOutput, + type DelegateSubAgentRunner, } from '../delegate-sub-agent-tool'; const input = { + subAgentId: INLINE_SUB_AGENT_ID, taskName: 'Research API', goal: 'Find the API behavior.', context: 'Focus on auth endpoints.', @@ -30,19 +33,29 @@ describe('createDelegateSubAgentTool', () => { expect(tool.name).toBe(DELEGATE_SUB_AGENT_TOOL_NAME); expect(tool.description).toContain('focused child agent'); + expect(tool.description).toContain('independent workstreams'); expect(tool.inputSchema).toBeDefined(); expect(tool.outputSchema).toBeDefined(); }); + it('can be created without a host runner for SDK inline execution', async () => { + const tool = createDelegateSubAgentTool(); + + await expect(tool.handler?.(input, { runId: 'parent-run-1' })).resolves.toMatchObject({ + status: 'failed', + answer: '', + error: + 'delegate_subagent was registered without a runSubAgent callback, and no host runner was provided. Register it on an Agent (for inline delegation) or pass runSubAgent.', + }); + }); + 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 runSubAgent = vi.fn().mockResolvedValue({ + status: 'completed', + taskPath: '/root/research_api', + runId: 'child-run-1', + answer: 'done', + }); const tool = createDelegateSubAgentTool({ policy: { maxChildren: 2 }, runSubAgent, @@ -53,19 +66,41 @@ describe('createDelegateSubAgentTool', () => { 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 }, + expect(runSubAgent).toHaveBeenCalledWith( + { + ...input, + taskPath: '/root/research_api_0', + parentRunId: 'parent-run-1', + parentToolCallId: 'tool-call-1', + childCount: 0, + policy: { maxChildren: 2 }, + }, + expect.objectContaining({ + runInlineSubAgent: expect.any(Function), + }), + ); + }); + + it('passes runInlineSubAgent helpers to the host runner callback', async () => { + const runSubAgent = vi.fn(async (_request, helpers) => { + expect(helpers.runInlineSubAgent).toEqual(expect.any(Function)); + await Promise.resolve(); + return { + status: 'completed', + taskPath: '/root/research_api_0', + answer: 'routed', + }; }); + const tool = createDelegateSubAgentTool({ runSubAgent }); + + await tool.handler?.(input, { runId: 'parent-run-1' }); + + expect(runSubAgent).toHaveBeenCalledOnce(); }); it('forwards the parent persistence thread id and resource id', async () => { const runSubAgent = vi - .fn<(request: DelegateSubAgentRequest) => Promise>() + .fn() .mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' }); const tool = createDelegateSubAgentTool({ runSubAgent }); @@ -79,12 +114,15 @@ describe('createDelegateSubAgentTool', () => { parentThreadId: 'parent-thread-1', parentResourceId: 'resource-1', }), + expect.objectContaining({ + runInlineSubAgent: expect.any(Function), + }), ); }); it('omits parent persistence fields when the parent run has no persistence scope', async () => { const runSubAgent = vi - .fn<(request: DelegateSubAgentRequest) => Promise>() + .fn() .mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' }); const tool = createDelegateSubAgentTool({ runSubAgent }); @@ -97,7 +135,7 @@ describe('createDelegateSubAgentTool', () => { it('forwards the parent run abort signal to the runner callback', async () => { const runSubAgent = vi - .fn<(request: DelegateSubAgentRequest) => Promise>() + .fn() .mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' }); const tool = createDelegateSubAgentTool({ runSubAgent }); const controller = new AbortController(); @@ -106,6 +144,9 @@ describe('createDelegateSubAgentTool', () => { expect(runSubAgent).toHaveBeenCalledWith( expect.objectContaining({ parentAbortSignal: controller.signal }), + expect.objectContaining({ + runInlineSubAgent: expect.any(Function), + }), ); }); @@ -154,14 +195,12 @@ describe('createDelegateSubAgentTool', () => { }); 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 runSubAgent = vi.fn().mockResolvedValue({ + status: 'completed', + taskPath: '/root/research_api', + runId: 'child-run-1', + answer: 'done', + }); const tool = createDelegateSubAgentTool({ policy: { maxChildren: 1 }, runSubAgent, @@ -224,9 +263,9 @@ 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:'); + expect(prompt).toContain('YOUR TASK:\nFind it.'); + expect(prompt).not.toContain('CONTEXT:'); + expect(prompt).not.toContain('EXPECTED OUTPUT:'); }); it('includes context and expected output when provided', () => { @@ -236,9 +275,24 @@ describe('renderDelegateSubAgentPrompt', () => { expectedOutput: 'a summary', }); - expect(prompt).toContain('Goal:\nFind it.'); - expect(prompt).toContain('Context:\nauth endpoints'); - expect(prompt).toContain('Expected output:\na summary'); + expect(prompt).toContain('YOUR TASK:\nFind it.'); + expect(prompt).toContain('CONTEXT:\nauth endpoints'); + expect(prompt).toContain('EXPECTED OUTPUT:\na summary'); + }); + + it('uses generic summary guidance for delegated work', () => { + const prompt = renderDelegateSubAgentPrompt({ goal: 'Find it.' }); + + expect(prompt).toContain('- What you did'); + expect(prompt).toContain('- What you found or accomplished'); + expect(prompt).toContain('- Important outputs, decisions, or evidence'); + expect(prompt).toContain('- Any issues, assumptions, or limitations'); + expect(prompt).toContain( + 'If the information above is insufficient, do your best with explicitly stated assumptions and note what was missing, rather than stopping to ask.', + ); + expect(prompt).toContain( + 'Be thorough but concise -- your response is returned to the parent agent as a summary.', + ); }); }); @@ -287,4 +341,73 @@ describe('generateResultToDelegateSubAgentOutput', () => { error: 'boom', }); }); + + it('returns a failed delegate output for delegated child suspension stopgap', async () => { + const { failedDelegatedChildSuspendOutput } = await import('../delegate-sub-agent-tool'); + + expect(failedDelegatedChildSuspendOutput('/root/x_0')).toEqual({ + status: 'failed', + taskPath: '/root/x_0', + answer: '', + error: 'agents.chat.delegate.childSuspendUnsupported', + }); + }); + + it('maps a suspended child result to suspended with pendingSuspend metadata', () => { + const result: GenerateResult = { + runId: 'child-run-3', + messages: [ + { + role: 'assistant', + type: 'llm', + content: [{ type: 'text', text: 'awaiting approval' }], + }, + ], + finishReason: 'tool-calls', + pendingSuspend: [ + { + runId: 'child-run-3', + toolCallId: 'tool-call-1', + toolName: 'delete_file', + input: { path: '/tmp/foo.txt' }, + suspendPayload: { message: 'Delete file?' }, + }, + ], + }; + + expect(generateResultToDelegateSubAgentOutput('/root/x_0', result)).toEqual({ + status: 'suspended', + taskPath: '/root/x_0', + runId: 'child-run-3', + answer: 'awaiting approval', + finishReason: 'tool-calls', + pendingSuspend: result.pendingSuspend, + }); + }); + + it('prefers failed over suspended when the child result also has pendingSuspend', () => { + const result: GenerateResult = { + runId: 'child-run-4', + messages: [], + finishReason: 'error', + error: new Error('child failed'), + pendingSuspend: [ + { + runId: 'child-run-4', + toolCallId: 'tool-call-1', + toolName: 'delete_file', + input: {}, + suspendPayload: {}, + }, + ], + }; + + expect(generateResultToDelegateSubAgentOutput('/root/x_0', result)).toMatchObject({ + status: 'failed', + error: 'child failed', + }); + expect( + generateResultToDelegateSubAgentOutput('/root/x_0', result).pendingSuspend, + ).toBeUndefined(); + }); }); 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 index 05f6c48feba..787f2992552 100644 --- 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 @@ -1,6 +1,5 @@ import { ROOT_SUB_AGENT_TASK_PATH, - assertSubAgentPolicyAllowsChild, assertSubAgentPolicyAllowsChildCount, assertSubAgentTaskPath, createChildSubAgentTaskPath, @@ -20,13 +19,10 @@ describe('sub-agent task paths', () => { expect(() => sanitizeSubAgentTaskName('!!!')).toThrow('task name'); }); - it('recognizes valid flat task paths', () => { + it('recognizes root and first-level child 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); + expect(isSubAgentTaskPath('/root/research_api_0')).toBe(true); }); it('rejects malformed task paths', () => { @@ -46,8 +42,9 @@ describe('sub-agent task paths', () => { } }); - it('creates first-level child paths under root', () => { + it('creates child paths with the parent child index appended', () => { expect(createChildSubAgentTaskPath('Research API', 0)).toBe('/root/research_api_0'); + expect(createChildSubAgentTaskPath('Check tests', 1)).toBe('/root/check_tests_1'); }); it('disambiguates same-named siblings by child index', () => { @@ -58,13 +55,6 @@ describe('sub-agent task paths', () => { 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( diff --git a/packages/@n8n/agents/src/runtime/__tests__/write-todos-tool.test.ts b/packages/@n8n/agents/src/runtime/__tests__/write-todos-tool.test.ts new file mode 100644 index 00000000000..2220e81f331 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/__tests__/write-todos-tool.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { isZodSchema } from '../../utils/zod'; +import { WRITE_TODOS_TOOL_NAME, createWriteTodosTool } from '../write-todos-tool'; + +const sampleTodos = [ + { + id: 'research', + content: 'Research API authentication options', + status: 'in_progress' as const, + delegateHint: { + subAgentId: 'inline', + expectedOutput: 'Short comparison of auth methods', + }, + }, + { + id: 'synthesize', + content: 'Synthesize findings into a recommendation', + status: 'pending' as const, + }, +]; + +describe('createWriteTodosTool', () => { + it('creates the write_todos tool with planner guidance', () => { + const tool = createWriteTodosTool(); + + expect(tool.name).toBe(WRITE_TODOS_TOOL_NAME); + expect(tool.description).toContain('structured task list'); + expect(tool.description).toContain('delegate_subagent'); + expect(tool.inputSchema).toBeDefined(); + expect(tool.outputSchema).toBeDefined(); + }); + + it('returns the provided todo list with a count', async () => { + const tool = createWriteTodosTool(); + + await expect( + tool.handler?.( + { todos: sampleTodos }, + { runId: 'parent-run-1', persistence: { threadId: 'thread-1', resourceId: 'res-1' } }, + ), + ).resolves.toEqual({ + status: 'ok', + todoCount: 2, + todos: sampleTodos, + }); + }); + + it('rejects duplicate todo ids in a single update', () => { + const tool = createWriteTodosTool(); + expect(tool.inputSchema).toBeDefined(); + expect(isZodSchema(tool.inputSchema)).toBe(true); + if (!isZodSchema(tool.inputSchema)) { + throw new Error('Expected Zod input schema'); + } + + const result = tool.inputSchema.safeParse({ + todos: [ + { id: 'dup', content: 'First', status: 'pending' }, + { id: 'dup', content: 'Second', status: 'pending' }, + ], + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect( + result.error.issues.some((issue) => issue.message.includes('Duplicate todo id "dup"')), + ).toBe(true); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/agent-runtime.ts b/packages/@n8n/agents/src/runtime/agent-runtime.ts index 937dcdebf39..ba29920e402 100644 --- a/packages/@n8n/agents/src/runtime/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/agent-runtime.ts @@ -26,7 +26,6 @@ import type { OpenAIThinkingConfig, PendingToolCall, RunOptions, - SemanticRecallConfig, SerializableAgentState, StreamChunk, StreamResult, @@ -54,7 +53,7 @@ import { createFilteredLogger } from './logger'; import { saveMessagesToThread } from './memory-store'; import { AgentMessageList, type SerializedMessageList } from './message-list'; import { fromAiFinishReason, fromAiMessages } from './messages'; -import { createEmbeddingModel, createModel } from './model-factory'; +import { createModel } from './model-factory'; import { runObservationLogObserver, type ObservationLogObserverMemory, @@ -195,7 +194,6 @@ export interface AgentRuntimeConfig { observationLog?: ObservationLogMemoryConfig; observationalMemory?: ObservationalMemoryConfig; episodicMemory?: EpisodicMemoryConfig; - semanticRecall?: SemanticRecallConfig; structuredOutput?: z.ZodType; checkpointStorage?: 'memory' | CheckpointStore; thinking?: ThinkingConfig; @@ -624,11 +622,6 @@ export class AgentRuntime { } } - // Semantic recall — retrieve relevant past messages beyond the history window - if (this.config.semanticRecall && options?.persistence?.threadId) { - await this.performSemanticRecall(list, input, options.persistence, options.executionCounter); - } - await this.setListObservationLogMemory(list, options?.persistence); list.addInput(input); @@ -663,117 +656,6 @@ export class AgentRuntime { }); } - /** - * Perform semantic recall: embed the user's query, search for relevant past messages, - * expand by messageRange, deduplicate against history, and inject into the list. - */ - private async performSemanticRecall( - list: AgentMessageList, - input: AgentMessage[], - persistence: AgentPersistenceOptions, - executionCounter?: AgentExecutionCounter, - ): Promise { - if (!this.config.semanticRecall || !this.config.memory) return; - - const userText = input - .filter((m) => isLlmMessage(m) && m.role === 'user') - .flatMap((m) => (isLlmMessage(m) ? m.content : [])) - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join(' '); - - if (!userText) return; - - let recalled: AgentDbMessage[] = []; - - if (this.config.memory.queryEmbeddings && this.config.semanticRecall.embedder) { - // Tier 3: runtime embeds the query, backend does vector search - const { embed } = getAiSdk(); - const embeddingModel = createEmbeddingModel( - this.config.semanticRecall.embedder, - this.config.semanticRecall.apiKey, - ); - - const { embedding, usage } = await embed({ model: embeddingModel, value: userText }); - incrementTokenCountFromUsage(executionCounter, usage); - - const hits = await this.config.memory.queryEmbeddings({ - scope: this.config.semanticRecall.scope ?? 'resource', - threadId: persistence.threadId, - resourceId: persistence.resourceId, - vector: embedding, - topK: this.config.semanticRecall.topK, - }); - - if (hits.length > 0) { - const hitIds = new Set(hits.map((h) => h.id)); - // TODO: add getMessagesByIds() to BuiltMemory to avoid loading all messages. - const allMsgs = await this.config.memory.getMessages(persistence.threadId); - - if (this.config.semanticRecall.messageRange) { - recalled = this.expandMessageRange( - allMsgs, - hitIds, - this.config.semanticRecall.messageRange, - ); - } else { - recalled = allMsgs.filter((m) => { - const id = m.id; - return id !== undefined && hitIds.has(id); - }); - } - } - } else if (this.config.memory.search) { - // Fallback: high-level search (backend handles everything) - recalled = await this.config.memory.search(userText, { - threadId: persistence.threadId, - resourceId: persistence.resourceId, - topK: this.config.semanticRecall.topK, - messageRange: this.config.semanticRecall.messageRange, - }); - } - - if (recalled.length === 0) return; - - // Deduplicate against already-loaded history by message ID - const { historyIds } = list.serialize(); - const historyIdSet = new Set(historyIds); - - const newRecalled = recalled.filter((m) => { - const id = m.id; - return !id || !historyIdSet.has(id); - }); - - if (newRecalled.length > 0) { - list.addHistory(newRecalled); - } - } - - /** Expand hit IDs by messageRange (before/after) within the ordered message list. */ - private expandMessageRange( - allMsgs: AgentDbMessage[], - hitIds: Set, - range: { before: number; after: number }, - ): AgentDbMessage[] { - const expandedIds = new Set(); - for (const msg of allMsgs) { - const id = 'id' in msg && typeof msg.id === 'string' ? msg.id : undefined; - if (!id || !hitIds.has(id)) continue; - const idx = allMsgs.indexOf(msg); - const start = Math.max(0, idx - (range.before ?? 0)); - const end = Math.min(allMsgs.length - 1, idx + (range.after ?? 0)); - for (let i = start; i <= end; i++) { - const el = allMsgs[i]; - const mid = 'id' in el && typeof el.id === 'string' ? el.id : undefined; - if (mid) expandedIds.add(mid); - } - } - return allMsgs.filter((m) => { - const mid = 'id' in m && typeof m.id === 'string' ? m.id : undefined; - return mid && expandedIds.has(mid); - }); - } - /** * Common setup for generate() and stream(): reset abort state, transition to running, * emit AgentStart, fetch model cost, normalize input, and build the message list. @@ -1623,16 +1505,6 @@ export class AgentRuntime { // Memory jobs receive the execution counter so their LLM and embedding // usage contributes to token_count. - // Generate and save embeddings if semantic recall is configured - if (this.config.semanticRecall?.embedder && this.config.memory.saveEmbeddings) { - await this.saveEmbeddingsForMessages( - options.persistence.threadId, - options.persistence.resourceId, - delta, - options.executionCounter, - ); - } - const observationTasks = this.scheduleObservationLogJobs( options.persistence, options.executionCounter, @@ -1825,51 +1697,6 @@ export class AgentRuntime { }; } - private async saveEmbeddingsForMessages( - threadId: string, - resourceId: string | undefined, - messages: AgentDbMessage[], - executionCounter?: AgentExecutionCounter, - ): Promise { - // Extract text from user and assistant messages - const embeddable: Array<{ id: string; text: string }> = []; - for (const msg of messages) { - if (!isLlmMessage(msg) || (msg.role !== 'user' && msg.role !== 'assistant')) continue; - const text = msg.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'); - if (!text) continue; - embeddable.push({ id: msg.id, text }); - } - - if (embeddable.length === 0) return; - - const embedder = this.config.semanticRecall?.embedder; - if (!embedder) return; - - const { embedMany } = getAiSdk(); - const embeddingModel = createEmbeddingModel(embedder, this.config.semanticRecall?.apiKey); - - const { embeddings, usage } = await embedMany({ - model: embeddingModel, - values: embeddable.map((e) => e.text), - }); - incrementTokenCountFromUsage(executionCounter, usage); - - await this.config.memory!.saveEmbeddings!({ - scope: this.config.semanticRecall?.scope ?? 'resource', - threadId, - resourceId, - entries: embeddable.map((e, i) => ({ - id: e.id, - vector: embeddings[i], - text: e.text, - model: embedder, - })), - }); - } - /** Build the providerOptions object for thinking/reasoning config. */ private buildThinkingProviderOptions(): Record> | undefined { if (!this.config.thinking) return undefined; diff --git a/packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts b/packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts index 83ba3007e0a..73cd679326f 100644 --- a/packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts +++ b/packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; +import { withSdkOwnedBuiltInMetadata } from './sdk-owned-tool'; import { - assertSubAgentPolicyAllowsChild, assertSubAgentPolicyAllowsChildCount, createChildSubAgentTaskPath, type SubAgentTaskPath, @@ -12,9 +12,14 @@ 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'; +import type { BuiltTool, ToolContext } from '../types/sdk/tool'; export const DELEGATE_SUB_AGENT_TOOL_NAME = 'delegate_subagent'; +export const INLINE_SUB_AGENT_ID = 'inline'; +/** i18n key — localized in the agent chat UI; see `agents.chat.delegate.childSuspendUnsupported`. */ +export const DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE = + 'agents.chat.delegate.childSuspendUnsupported'; +export const INLINE_DELEGATE_SUB_AGENT_TOOL_METADATA_KEY = 'inlineDelegateSubAgent'; // 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. @@ -22,8 +27,9 @@ const delegateSubAgentInputSchema = z.object({ subAgentId: z .string() .min(1) - .optional() - .describe('Configured sub-agent ID to run. Use when multiple sub-agents are available.'), + .describe( + 'Required. Use "inline" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.', + ), taskName: z .string() .min(1) @@ -42,7 +48,7 @@ const delegateSubAgentInputSchema = z.object({ // 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']), + status: z.enum(['completed', 'failed', 'suspended']), taskPath: z.string().optional(), runId: z.string().optional(), threadId: z.string().optional(), @@ -58,14 +64,26 @@ const delegateSubAgentOutputSchema = z.object({ .optional(), finishReason: z.string().optional(), error: z.string().optional(), + pendingSuspend: z + .array( + z.object({ + runId: z.string(), + toolCallId: z.string(), + toolName: z.string(), + input: z.unknown(), + suspendPayload: z.unknown(), + resumeSchema: z.unknown().optional(), + }), + ) + .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}). + * 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 @@ -80,7 +98,7 @@ export type DelegateSubAgentPolicy = SubAgentTaskPathPolicy; * 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`). */ + /** Direct child path assigned to this delegation (e.g. `/root/research_api_0`). */ taskPath: SubAgentTaskPath; /** Parent run id (`ctx.runId`), e.g. for memory scoping / correlation. */ parentRunId?: string; @@ -103,7 +121,7 @@ export interface DelegateSubAgentRequest extends DelegateSubAgentInput { /** The result a delegation returns to the parent model and to lifecycle events. */ export interface DelegateSubAgentToolOutput { - status: 'completed' | 'failed'; + status: 'completed' | 'failed' | 'suspended'; /** Echoed back so consumers can correlate the result with the delegation. */ taskPath?: SubAgentTaskPath; /** The child run's id, when the executor produced one. */ @@ -122,6 +140,8 @@ export interface DelegateSubAgentToolOutput { finishReason?: FinishReason; /** Present when status is 'failed'. */ error?: string; + /** Present when status is 'suspended' — child run paused awaiting tool resume. */ + pendingSuspend?: GenerateResult['pendingSuspend']; } /** @@ -133,6 +153,20 @@ export interface DelegateSubAgentToolOutput { * `subagent-started` / `-completed` lifecycle events) is owned by * the tool. */ +/** + * Helpers passed to a host `runSubAgent` callback so the host can route + * `subAgentId: "inline"` while reusing the SDK inline child runner implementation. + */ +export interface DelegateSubAgentRunnerHelpers { + /** Run a one-off inline child using the parent agent's inherited tool set. */ + runInlineSubAgent: (request: DelegateSubAgentRequest) => Promise; +} + +export type DelegateSubAgentRunner = ( + request: DelegateSubAgentRequest, + helpers: DelegateSubAgentRunnerHelpers, +) => Promise; + export interface CreateDelegateSubAgentToolOptions { /** * Sub-agents the model may choose between. Listed in the system prompt; the @@ -141,18 +175,26 @@ export interface CreateDelegateSubAgentToolOptions { 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; + /** Additional local/deferred tool names the host removes from inline children. */ + inlineSubAgentBlockedTools?: string[]; + /** + * Run the child for this delegation and return its result. When provided, the + * host receives every `subAgentId` (including `"inline"`) and may call + * `helpers.runInlineSubAgent` for inline work. + */ + runSubAgent?: DelegateSubAgentRunner; } +export type DelegateSubAgentToolMetadata = CreateDelegateSubAgentToolOptions; + /** * 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` + * delegate, task-path bookkeeping, fan-out policy enforcement, 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): @@ -162,30 +204,55 @@ export interface CreateDelegateSubAgentToolOptions { * policy: { maxChildren: 5 }, * })); */ -export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOptions) { +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) + const tool = 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.', + 'Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. ' + + 'Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. ' + + 'Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.', ) .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.', + 'delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: "inline" to run a one-off inline child that inherits your available tools after safety filtering. The child cannot see this conversation or your memory, so everything it needs must be in the call.', + 'Use a configured subagent ID only when one is listed and its name/description fits the subtask better than a generic inline child.', ...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.', + 'WHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.', + 'WHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.', + 'HOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your available tools after safety filtering; you cannot change their tool set per delegation.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.', ].join('\n'), ) .input(delegateSubAgentInputSchema) .output(delegateSubAgentOutputSchema) .handler(async (input, ctx) => await handleDelegateSubAgent(input, ctx, options, childCounts)) .build(); + + return withSdkOwnedBuiltInMetadata({ + ...tool, + metadata: { + ...tool.metadata, + [INLINE_DELEGATE_SUB_AGENT_TOOL_METADATA_KEY]: { + ...(options.availableSubAgents !== undefined + ? { availableSubAgents: options.availableSubAgents } + : {}), + ...(options.policy !== undefined ? { policy: options.policy } : {}), + ...(options.inlineSubAgentBlockedTools !== undefined + ? { inlineSubAgentBlockedTools: options.inlineSubAgentBlockedTools } + : {}), + ...(options.runSubAgent !== undefined ? { runSubAgent: options.runSubAgent } : {}), + } satisfies DelegateSubAgentToolMetadata, + }, + }); +} + +export function getInlineDelegateSubAgentToolOptions( + tool: BuiltTool, +): DelegateSubAgentToolMetadata | undefined { + const value = tool.metadata?.[INLINE_DELEGATE_SUB_AGENT_TOOL_METADATA_KEY]; + if (typeof value !== 'object' || value === null) return undefined; + return value as DelegateSubAgentToolMetadata; } function formatAvailableSubAgents( @@ -194,7 +261,7 @@ function formatAvailableSubAgents( if (!availableSubAgents?.length) return []; return [ - 'Configured subagents are available. Pick the most relevant one and pass its id as subAgentId:', + 'Configured subagents are available as specialist options. Use subAgentId: "inline" for the default inline child; pass one of these exact IDs only when that specialist is a better fit:', ...availableSubAgents.map((subAgent) => { const description = subAgent.description ? ` - ${subAgent.description}` : ''; return `- ${subAgent.id}: ${subAgent.name}${description}`; @@ -220,7 +287,6 @@ async function handleDelegateSubAgent( 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); @@ -246,7 +312,18 @@ async function handleDelegateSubAgent( startedAt = Date.now(); emitSubAgentStarted(ctx, request, startedAt); - const output = await options.runSubAgent(request); + if (!options.runSubAgent) { + throw new Error( + 'delegate_subagent was registered without a runSubAgent callback, and no host runner was provided. Register it on an Agent (for inline delegation) or pass runSubAgent.', + ); + } + const output = await options.runSubAgent(request, { + runInlineSubAgent: () => { + throw new Error( + 'delegate_subagent host runner does not support inline delegation without helpers.runInlineSubAgent from an Agent build.', + ); + }, + }); emitSubAgentCompleted(ctx, request, output, startedAt); return output; } catch (error) { @@ -351,33 +428,68 @@ export function renderDelegateSubAgentPrompt(request: { 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}`, + 'You are a focused subagent working on a specific delegated task.', + `YOUR TASK:\n${request.goal}`, ]; if (request.context) { - sections.push(`Context:\n${request.context}`); + sections.push(`CONTEXT:\n${request.context}`); } if (request.expectedOutput) { - sections.push(`Expected output:\n${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.', + [ + 'Complete this task using the tools available to you. When finished, provide a clear, concise summary of:', + '- What you did', + '- What you found or accomplished', + '- Important outputs, decisions, or evidence', + '- Any issues, assumptions, or limitations', + '', + 'If the information above is insufficient, do your best with explicitly stated assumptions and note what was missing, rather than stopping to ask.', + '', + 'Be thorough but concise -- your response is returned to the parent agent as a summary.', + ].join('\n'), ); return sections.join('\n\n'); } +function resolveDelegateSubAgentStatus( + result: GenerateResult, +): DelegateSubAgentToolOutput['status'] { + if (result.finishReason === 'error' || result.error !== undefined) { + return 'failed'; + } + if (result.pendingSuspend !== undefined && result.pendingSuspend.length > 0) { + return 'suspended'; + } + return 'completed'; +} + +/** Failed delegate output when a child run suspends for user input (not yet resumable). */ +export function failedDelegatedChildSuspendOutput( + taskPath: SubAgentTaskPath, +): DelegateSubAgentToolOutput { + return { + status: 'failed', + taskPath, + answer: '', + error: DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + }; +} + /** Map an agent {@link GenerateResult} into the delegate tool's output shape. */ export function generateResultToDelegateSubAgentOutput( taskPath: SubAgentTaskPath, result: GenerateResult, threadId?: string, ): DelegateSubAgentToolOutput { + const status = resolveDelegateSubAgentStatus(result); return { - status: result.finishReason === 'error' || result.error !== undefined ? 'failed' : 'completed', + status, taskPath, runId: result.runId, ...(threadId !== undefined ? { threadId } : {}), @@ -395,6 +507,9 @@ export function generateResultToDelegateSubAgentOutput( : {}), ...(result.finishReason !== undefined ? { finishReason: result.finishReason } : {}), ...(result.error !== undefined ? { error: stringifyUnknown(result.error) } : {}), + ...(status === 'suspended' && result.pendingSuspend !== undefined + ? { pendingSuspend: result.pendingSuspend } + : {}), }; } diff --git a/packages/@n8n/agents/src/runtime/sdk-owned-tool.ts b/packages/@n8n/agents/src/runtime/sdk-owned-tool.ts new file mode 100644 index 00000000000..b44f80dd3d3 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/sdk-owned-tool.ts @@ -0,0 +1,17 @@ +import type { BuiltTool } from '../types/sdk/tool'; + +export const SDK_OWNED_BUILTIN_TOOL_METADATA_KEY = 'sdkOwnedBuiltinTool'; + +export function isSdkOwnedBuiltInTool(tool: BuiltTool): boolean { + return tool.metadata?.[SDK_OWNED_BUILTIN_TOOL_METADATA_KEY] === true; +} + +export function withSdkOwnedBuiltInMetadata(tool: BuiltTool): BuiltTool { + return { + ...tool, + metadata: { + ...tool.metadata, + [SDK_OWNED_BUILTIN_TOOL_METADATA_KEY]: true, + }, + }; +} diff --git a/packages/@n8n/agents/src/runtime/sub-agent-task-path.ts b/packages/@n8n/agents/src/runtime/sub-agent-task-path.ts index 7e7aad6b60a..925f8764fbe 100644 --- a/packages/@n8n/agents/src/runtime/sub-agent-task-path.ts +++ b/packages/@n8n/agents/src/runtime/sub-agent-task-path.ts @@ -1,11 +1,11 @@ /** * 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.: + * A "task path" is a filesystem-like address that gives every agent run a + * stable, human-readable position in the delegation flow, e.g.: * - * /root ← the top-level (orchestrating) agent - * /root/research_api_0 ← a first-level delegated child + * /root ← the top-level (orchestrating) agent + * /root/research_api_0 ← a direct child delegation from the orchestrator * * Each child segment carries the parent's 0-based child index (`_0`, `_1`, …) so * that delegations with the same task name stay distinct. @@ -15,12 +15,8 @@ * 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. + * lets us cap per-parent fan-out so a misbehaving agent can't spawn hundreds + * of parallel children, which would blow up cost, latency, and resources. * * 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 @@ -28,11 +24,11 @@ */ /** - * 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}. + * A delegation task path: `/root` or a single direct-child segment under `/root`. + * 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}`}`; +export type SubAgentTaskPath = '/root' | `/root/${string}`; /** * Guardrails applied when a parent tries to spawn a child sub-agent. Every limit @@ -41,16 +37,14 @@ export type SubAgentTaskPath = `/root${'' | `/${string}`}`; 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. */ +/** Path of the initiating (orchestrating) agent. */ 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. */ +/** A valid path is `/root` or `/root` plus one lowercase alphanumeric/underscore segment. */ const SUB_AGENT_TASK_PATH_PATTERN = /^\/root(?:\/[a-z0-9_]+)?$/; /** @@ -82,7 +76,7 @@ export function sanitizeSubAgentTaskName(taskName: string): string { return sanitized; } -/** Type guard: does this string match the flat `/root[/segment]?` shape? */ +/** Type guard: does this string match `/root` or `/root/`? */ export function isSubAgentTaskPath(value: string): value is SubAgentTaskPath { return SUB_AGENT_TASK_PATH_PATTERN.test(value); } @@ -115,17 +109,6 @@ export function createChildSubAgentTaskPath( 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. * diff --git a/packages/@n8n/agents/src/runtime/write-todos-tool.ts b/packages/@n8n/agents/src/runtime/write-todos-tool.ts new file mode 100644 index 00000000000..7fad09b0c25 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/write-todos-tool.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; + +import { withSdkOwnedBuiltInMetadata } from './sdk-owned-tool'; +import { Tool } from '../sdk/tool'; +import type { BuiltTool } from '../types/sdk/tool'; + +export const WRITE_TODOS_TOOL_NAME = 'write_todos'; + +const todoStatusSchema = z.enum(['pending', 'in_progress', 'completed', 'blocked', 'cancelled']); + +const todoDelegateHintSchema = z + .object({ + subAgentId: z + .string() + .optional() + .describe( + 'Optional sub-agent id when this task is a delegate_subagent candidate. Use "inline" for one-off inline sub-agents.', + ), + expectedOutput: z + .string() + .optional() + .describe('Optional expected output shape when delegating this task.'), + }) + .optional(); + +const todoItemSchema = z.object({ + id: z.string().min(1).describe('Stable identifier for this task within the current plan.'), + content: z.string().min(1).describe('Concrete, self-contained task description.'), + status: todoStatusSchema, + delegateHint: todoDelegateHintSchema, +}); + +const writeTodosInputSchema = z + .object({ + todos: z + .array(todoItemSchema) + .describe('Full task list for the current run. Replaces any previous list.'), + }) + .superRefine((value, ctx) => { + const seen = new Set(); + for (const [index, todo] of value.todos.entries()) { + if (seen.has(todo.id)) { + ctx.addIssue({ + code: 'custom', + message: `Duplicate todo id "${todo.id}". Each task must have a unique id.`, + path: ['todos', index, 'id'], + }); + } + seen.add(todo.id); + } + }); + +const writeTodosOutputSchema = z.object({ + status: z.literal('ok'), + todoCount: z.number(), + todos: z.array(todoItemSchema), +}); + +const WRITE_TODOS_DESCRIPTION = + 'Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.'; + +const WRITE_TODOS_SYSTEM_INSTRUCTION = [ + 'write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.', + 'WHEN TO USE write_todos:', + '- The user request has 3+ meaningful steps or multiple deliverables.', + '- The request decomposes into 2+ independent workstreams.', + '- Some workstreams are good candidates for delegate_subagent.', + '- You need to track progress, revise the plan, or avoid losing context.', + 'WHEN NOT TO USE write_todos:', + '- The request is trivial, conversational, or informational.', + '- The task can be completed directly in one or two simple steps.', + "- You would only create a todo list to restate the user's request.", + 'HOW TO USE write_todos:', + '- Write concrete, self-contained tasks, not vague phases.', + '- Mark the first active task, or independent active tasks, as in_progress immediately.', + '- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task.', + '- Do not delegate the entire user request as one task.', + '- Update task status as soon as work completes; do not batch completions at the end.', + '- Revise the list when new information changes the plan.', + '- Do not call write_todos multiple times in parallel; send one full list update at a time.', + '- After all work is done, send the final answer as normal assistant text after the last write_todos call.', +].join('\n'); + +/** + * Build the planner-only `write_todos` tool — lets a parent agent maintain a + * structured task list for complex work without auto-dispatching sub-agents. + */ +export function createWriteTodosTool(): BuiltTool { + const tool = new Tool(WRITE_TODOS_TOOL_NAME) + .description(WRITE_TODOS_DESCRIPTION) + .systemInstruction(WRITE_TODOS_SYSTEM_INSTRUCTION) + .input(writeTodosInputSchema) + .output(writeTodosOutputSchema) + .handler(async (input) => { + const todos = [...input.todos]; + + return await Promise.resolve({ + status: 'ok' as const, + todoCount: todos.length, + todos, + }); + }) + .build(); + + return withSdkOwnedBuiltInMetadata(tool); +} diff --git a/packages/@n8n/agents/src/sdk/__tests__/delegate-sub-agent-routing.test.ts b/packages/@n8n/agents/src/sdk/__tests__/delegate-sub-agent-routing.test.ts new file mode 100644 index 00000000000..3f584a9341d --- /dev/null +++ b/packages/@n8n/agents/src/sdk/__tests__/delegate-sub-agent-routing.test.ts @@ -0,0 +1,221 @@ +import { z } from 'zod'; + +import type * as AgentRuntimeModule from '../../runtime/agent-runtime'; +import { + DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + DELEGATE_SUB_AGENT_TOOL_NAME, + INLINE_SUB_AGENT_ID, + createDelegateSubAgentTool, + getInlineDelegateSubAgentToolOptions, + type DelegateSubAgentRunner, + type DelegateSubAgentRunnerHelpers, +} from '../../runtime/delegate-sub-agent-tool'; +import type { BuiltTool } from '../../types'; +import { Agent } from '../agent'; + +const runtimeConfigs: Array> = []; +let inlineChildGenerateResult: + | Awaited['generate']>> + | undefined; + +vi.mock('../../runtime/agent-runtime', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AgentRuntime: class MockAgentRuntime { + constructor(config: Record) { + runtimeConfigs.push(config); + } + + async generate() { + if (inlineChildGenerateResult !== undefined) { + return await Promise.resolve(inlineChildGenerateResult); + } + return await Promise.resolve({ + runId: 'child-run', + finishReason: 'stop', + messages: [ + { + role: 'assistant', + type: 'llm', + content: [{ type: 'text', text: 'inline answer' }], + }, + ], + usage: {}, + }); + } + + async dispose() { + return await Promise.resolve(); + } + }, + }; +}); + +function makeTool(name: string): BuiltTool { + return { + name, + description: `${name} tool`, + inputSchema: z.object({}), + handler: async () => await Promise.resolve({ ok: true }), + }; +} + +const delegateInput = { + subAgentId: INLINE_SUB_AGENT_ID, + taskName: 'Research API', + goal: 'Find the API behavior.', +}; + +describe('delegate sub-agent routing', () => { + beforeEach(() => { + runtimeConfigs.length = 0; + inlineChildGenerateResult = undefined; + }); + + it('routes inline delegations through a host runner with runInlineSubAgent helpers', async () => { + const hostRunSubAgent = vi.fn(async (request, helpers) => { + expect(request.subAgentId).toBe(INLINE_SUB_AGENT_ID); + return await helpers.runInlineSubAgent(request); + }); + + const agent = new Agent('parent') + .model('openai', 'gpt-4o-mini') + .instructions('Delegate when needed.') + .tool( + createDelegateSubAgentTool({ + runSubAgent: hostRunSubAgent, + }), + ) + .tool(makeTool('lookup')); + + await (agent as unknown as { build(): Promise }).build(); + + expect(runtimeConfigs).toHaveLength(1); + const builtTools = runtimeConfigs[0]?.tools as BuiltTool[] | undefined; + const delegateTool = builtTools?.find((tool) => tool.name === DELEGATE_SUB_AGENT_TOOL_NAME); + expect(delegateTool).toBeDefined(); + + await expect( + delegateTool?.handler?.(delegateInput, { runId: 'parent-run-1' }), + ).resolves.toMatchObject({ + status: 'completed', + answer: 'inline answer', + }); + + expect(hostRunSubAgent).toHaveBeenCalledOnce(); + expect(hostRunSubAgent.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + runInlineSubAgent: expect.any(Function), + }), + ); + expect(runtimeConfigs).toHaveLength(2); + }); + + it('runs inline delegations without a host runner when the tool is built on an Agent', async () => { + const agent = new Agent('parent') + .model('openai', 'gpt-4o-mini') + .instructions('Delegate when needed.') + .tool(createDelegateSubAgentTool()) + .tool(makeTool('lookup')); + + await (agent as unknown as { build(): Promise }).build(); + + const builtTools = runtimeConfigs[0]?.tools as BuiltTool[] | undefined; + const delegateTool = builtTools?.find((tool) => tool.name === DELEGATE_SUB_AGENT_TOOL_NAME); + expect(delegateTool).toBeDefined(); + + await expect( + delegateTool?.handler?.(delegateInput, { runId: 'parent-run-1' }), + ).resolves.toMatchObject({ + status: 'completed', + answer: 'inline answer', + }); + + expect(runtimeConfigs).toHaveLength(2); + }); + + it('lets a host-style runner delegate inline through helpers from tool metadata', async () => { + const runInlineSubAgent = vi + .fn() + .mockResolvedValue({ + status: 'completed', + taskPath: '/root/research_api_0', + answer: 'inline via helper', + }); + const hostRunSubAgent = vi.fn(async (request, helpers) => { + if (request.subAgentId === INLINE_SUB_AGENT_ID) { + return await helpers.runInlineSubAgent(request); + } + return { + status: 'failed', + taskPath: request.taskPath, + answer: '', + error: 'unexpected', + }; + }); + + const tool = createDelegateSubAgentTool({ runSubAgent: hostRunSubAgent }); + const options = getInlineDelegateSubAgentToolOptions(tool); + expect(options?.runSubAgent).toBe(hostRunSubAgent); + + await expect( + options?.runSubAgent?.( + { + ...delegateInput, + taskPath: '/root/research_api_0', + childCount: 0, + }, + { runInlineSubAgent }, + ), + ).resolves.toMatchObject({ + status: 'completed', + answer: 'inline via helper', + }); + + expect(runInlineSubAgent).toHaveBeenCalledOnce(); + }); + + it('returns a failed delegate output when an inline child run suspends', async () => { + inlineChildGenerateResult = { + runId: 'child-run-suspended', + finishReason: 'tool-calls', + messages: [ + { + role: 'assistant', + type: 'llm', + content: [{ type: 'text', text: 'awaiting approval' }], + }, + ], + pendingSuspend: [ + { + runId: 'child-run-suspended', + toolCallId: 'tool-call-1', + toolName: 'delete_file', + input: { path: '/tmp/foo.txt' }, + suspendPayload: { message: 'Delete file?' }, + }, + ], + }; + + const agent = new Agent('parent') + .model('openai', 'gpt-4o-mini') + .instructions('Delegate when needed.') + .tool(createDelegateSubAgentTool()) + .tool(makeTool('lookup')); + + await (agent as unknown as { build(): Promise }).build(); + + const builtTools = runtimeConfigs[0]?.tools as BuiltTool[] | undefined; + const delegateTool = builtTools?.find((tool) => tool.name === DELEGATE_SUB_AGENT_TOOL_NAME); + expect(delegateTool).toBeDefined(); + + await expect( + delegateTool?.handler?.(delegateInput, { runId: 'parent-run-1' }), + ).resolves.toMatchObject({ + status: 'failed', + answer: '', + error: DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + }); + }); +}); diff --git a/packages/@n8n/agents/src/sdk/__tests__/inline-sub-agent-tools.test.ts b/packages/@n8n/agents/src/sdk/__tests__/inline-sub-agent-tools.test.ts new file mode 100644 index 00000000000..40acad44aa9 --- /dev/null +++ b/packages/@n8n/agents/src/sdk/__tests__/inline-sub-agent-tools.test.ts @@ -0,0 +1,152 @@ +import { z } from 'zod'; + +import type * as AgentRuntimeModule from '../../runtime/agent-runtime'; +import type { DelegateSubAgentRequest } from '../../runtime/delegate-sub-agent-tool'; +import { + DELEGATE_SUB_AGENT_TOOL_NAME, + INLINE_SUB_AGENT_ID, +} from '../../runtime/delegate-sub-agent-tool'; +import { RECALL_MEMORY_TOOL_NAME } from '../../runtime/episodic-memory'; +import { WRITE_TODOS_TOOL_NAME } from '../../runtime/write-todos-tool'; +import type { BuiltProviderTool, BuiltTool } from '../../types'; +import { Agent, filterInlineSubAgentTools } from '../agent'; + +const runtimeConfigs: Array> = []; + +vi.mock('../../runtime/agent-runtime', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AgentRuntime: class MockAgentRuntime { + constructor(config: Record) { + runtimeConfigs.push(config); + } + + async generate() { + return await Promise.resolve({ + runId: 'child-run', + finishReason: 'stop', + messages: [ + { + role: 'assistant', + type: 'llm', + content: [{ type: 'text', text: 'done' }], + }, + ], + usage: {}, + }); + } + + async dispose() { + return await Promise.resolve(); + } + }, + }; +}); + +function makeTool(name: string): BuiltTool { + return { + name, + description: `${name} tool`, + inputSchema: z.object({}), + handler: async () => await Promise.resolve({ ok: true }), + }; +} + +const openaiWebSearchProviderTool: BuiltProviderTool = { + name: 'openai.web_search_preview', + args: {}, +}; + +const anthropicWebSearchProviderTool: BuiltProviderTool = { + name: 'anthropic.web_search_20250305', + args: {}, +}; + +type AgentWithInlineRunner = { + createInlineSubAgentRunner: (options: { + deferredTools: BuiltTool[]; + modelConfig: string; + providerTools: BuiltProviderTool[]; + tools: BuiltTool[]; + inlineSubAgentBlockedTools?: string[]; + }) => (request: DelegateSubAgentRequest) => Promise; +}; + +function createInlineRunner(options: { + providerTools: BuiltProviderTool[]; + tools?: BuiltTool[]; + inlineSubAgentBlockedTools?: string[]; +}) { + const agent = new Agent('parent'); + return (agent as unknown as AgentWithInlineRunner).createInlineSubAgentRunner({ + deferredTools: [], + modelConfig: 'openai/gpt-4o-mini', + tools: options.tools ?? [makeTool('lookup')], + providerTools: options.providerTools, + inlineSubAgentBlockedTools: options.inlineSubAgentBlockedTools, + }); +} + +describe('inline sub-agent tool filtering', () => { + beforeEach(() => { + runtimeConfigs.length = 0; + }); + + it.each([ + { + name: 'blocks SDK-owned tools by default but not other tool names', + tools: [ + makeTool(DELEGATE_SUB_AGENT_TOOL_NAME), + makeTool(RECALL_MEMORY_TOOL_NAME), + makeTool(WRITE_TODOS_TOOL_NAME), + makeTool('host_tool'), + makeTool('lookup'), + ], + blockedTools: undefined, + expected: ['host_tool', 'lookup'], + }, + { + name: 'blocks host-supplied tool names when configured', + tools: [makeTool('host_tool'), makeTool('lookup')], + blockedTools: ['host_tool'], + expected: ['lookup'], + }, + ])('$name', ({ tools, blockedTools, expected }) => { + expect(filterInlineSubAgentTools(tools, blockedTools).map((tool) => tool.name)).toEqual( + expected, + ); + }); + + it('inherits all provider tools when not blocked', () => { + expect( + filterInlineSubAgentTools([openaiWebSearchProviderTool, anthropicWebSearchProviderTool]).map( + (tool) => tool.name, + ), + ).toEqual(['openai.web_search_preview', 'anthropic.web_search_20250305']); + }); + + it('passes all provider tools to inline child runtimes by default', async () => { + const runner = createInlineRunner({ + providerTools: [openaiWebSearchProviderTool, anthropicWebSearchProviderTool], + }); + + await runner({ + subAgentId: INLINE_SUB_AGENT_ID, + taskName: 'research', + goal: 'Find the answer', + taskPath: '/root/research', + childCount: 0, + }); + + expect(runtimeConfigs).toHaveLength(1); + expect( + (runtimeConfigs[0]?.providerTools as BuiltProviderTool[] | undefined)?.map( + (tool) => tool.name, + ), + ).toEqual(['openai.web_search_preview', 'anthropic.web_search_20250305']); + expect((runtimeConfigs[0]?.tools as BuiltTool[] | undefined)?.map((tool) => tool.name)).toEqual( + ['lookup'], + ); + }); +}); diff --git a/packages/@n8n/agents/src/sdk/__tests__/tool-name-collisions.test.ts b/packages/@n8n/agents/src/sdk/__tests__/tool-name-collisions.test.ts new file mode 100644 index 00000000000..64f395961c5 --- /dev/null +++ b/packages/@n8n/agents/src/sdk/__tests__/tool-name-collisions.test.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import { + createDelegateSubAgentTool, + DELEGATE_SUB_AGENT_TOOL_NAME, +} from '../../runtime/delegate-sub-agent-tool'; +import { isSdkOwnedBuiltInTool } from '../../runtime/sdk-owned-tool'; +import { createWriteTodosTool, WRITE_TODOS_TOOL_NAME } from '../../runtime/write-todos-tool'; +import { Agent } from '../agent'; +import { Tool } from '../tool'; + +function makeCustomTool(name: string) { + return new Tool(name) + .description('Custom tool') + .input(z.object({})) + .handler(async () => await Promise.resolve({ ok: true })) + .build(); +} + +function makeAgent() { + return new Agent('parent').model('openai', 'gpt-4o-mini').instructions('Test agent.'); +} + +describe('SDK reserved built-in tool names', () => { + it.each([DELEGATE_SUB_AGENT_TOOL_NAME, WRITE_TODOS_TOOL_NAME])( + 'rejects a custom static tool named %s', + (toolName) => { + expect(() => makeAgent().tool(makeCustomTool(toolName))).toThrow( + `Tool name "${toolName}" is reserved for SDK built-in tools`, + ); + }, + ); + + it.each([DELEGATE_SUB_AGENT_TOOL_NAME, WRITE_TODOS_TOOL_NAME])( + 'rejects a deferred tool named %s', + (toolName) => { + expect(() => makeAgent().deferredTool(makeCustomTool(toolName))).toThrow( + `Tool name "${toolName}" is reserved for SDK built-in tools`, + ); + }, + ); + + it('allows official SDK built-in tools to be registered', () => { + const agent = makeAgent().tool(createDelegateSubAgentTool()).tool(createWriteTodosTool()); + + expect(agent.declaredTools.map((tool) => tool.name)).toEqual([ + DELEGATE_SUB_AGENT_TOOL_NAME, + WRITE_TODOS_TOOL_NAME, + ]); + expect(agent.declaredTools.every((tool) => isSdkOwnedBuiltInTool(tool))).toBe(true); + }); +}); diff --git a/packages/@n8n/agents/src/sdk/__tests__/tool.test.ts b/packages/@n8n/agents/src/sdk/__tests__/tool.test.ts index 86c5e5a3818..496716d2fbf 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/tool.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/tool.test.ts @@ -318,4 +318,56 @@ describe('wrapToolForApproval — telemetry propagation', () => { expect(capturedCtx).toBeDefined(); expect(capturedCtx!.parentTelemetry).toBe(fakeTelemetry); }); + + it('forwards the full ToolContext to the original handler after approval', async () => { + let capturedCtx: ToolContext | undefined; + const baseTool = makeBuiltTool({ + handler: async (_input, ctx) => { + capturedCtx = ctx as ToolContext; + return await Promise.resolve({ result: 'ok' }); + }, + }); + const wrapped = wrapToolForApproval(baseTool, { requireApproval: true }); + const { ctx } = makeCtx({ approved: true }); + const abortController = new AbortController(); + const emitEvent = vi.fn(); + ctx.parentTelemetry = fakeTelemetry; + ctx.runId = 'parent-run-1'; + ctx.toolCallId = 'tool-call-1'; + ctx.persistence = { resourceId: 'resource-1', threadId: 'thread-1' }; + ctx.emitEvent = emitEvent; + ctx.abortSignal = abortController.signal; + + await wrapped.handler!({ id: 'test' }, ctx); + + expect(capturedCtx).toEqual({ + runId: 'parent-run-1', + toolCallId: 'tool-call-1', + persistence: { resourceId: 'resource-1', threadId: 'thread-1' }, + parentTelemetry: fakeTelemetry, + emitEvent, + abortSignal: abortController.signal, + suspend: ctx.suspend, + resumeData: { approved: true }, + }); + }); + + it('forwards the full ToolContext when approval is not needed', async () => { + let capturedCtx: ToolContext | undefined; + const baseTool = makeBuiltTool({ + handler: async (_input, ctx) => { + capturedCtx = ctx as ToolContext; + return await Promise.resolve({ result: 'ok' }); + }, + }); + const wrapped = wrapToolForApproval(baseTool, { requireApproval: false }); + const { ctx } = makeCtx(); + ctx.runId = 'parent-run-2'; + ctx.toolCallId = 'tool-call-2'; + + await wrapped.handler!({ id: 'test' }, ctx); + + expect(capturedCtx?.runId).toBe('parent-run-2'); + expect(capturedCtx?.toolCallId).toBe('tool-call-2'); + }); }); diff --git a/packages/@n8n/agents/src/sdk/agent.ts b/packages/@n8n/agents/src/sdk/agent.ts index 905eca6b297..6bae29c5696 100644 --- a/packages/@n8n/agents/src/sdk/agent.ts +++ b/packages/@n8n/agents/src/sdk/agent.ts @@ -8,7 +8,21 @@ import { Telemetry } from './telemetry'; 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 { + DELEGATE_SUB_AGENT_TOOL_NAME, + INLINE_SUB_AGENT_ID, + createDelegateSubAgentTool, + failedDelegatedChildSuspendOutput, + generateResultToDelegateSubAgentOutput, + getInlineDelegateSubAgentToolOptions, + renderDelegateSubAgentPrompt, + type DelegateSubAgentRequest, + type DelegateSubAgentToolOutput, +} from '../runtime/delegate-sub-agent-tool'; +import { RECALL_MEMORY_TOOL_NAME } from '../runtime/episodic-memory'; import { AgentEventBus } from '../runtime/event-bus'; +import { isSdkOwnedBuiltInTool } from '../runtime/sdk-owned-tool'; +import { WRITE_TODOS_TOOL_NAME } from '../runtime/write-todos-tool'; import { appendSkillCatalogToInstructions, createRuntimeSkillSource, @@ -46,6 +60,17 @@ import type { Workspace } from '../workspace/workspace'; type ToolParameter = BuiltTool | { build(): BuiltTool }; +const SDK_INLINE_SUB_AGENT_BLOCKED_TOOL_NAMES = new Set([ + DELEGATE_SUB_AGENT_TOOL_NAME, + RECALL_MEMORY_TOOL_NAME, + WRITE_TODOS_TOOL_NAME, +]); + +const SDK_RESERVED_BUILTIN_TOOL_NAMES = new Set([ + DELEGATE_SUB_AGENT_TOOL_NAME, + WRITE_TODOS_TOOL_NAME, +]); + interface DeferredToolOptions { search?: { topK?: number; @@ -197,7 +222,7 @@ export class Agent implements BuiltAgent, AgentBuilder { const tools = Array.isArray(t) ? t : [t]; const builtTools = tools.map((tool) => ('build' in tool ? tool.build() : tool)); for (const built of builtTools) { - this.assertToolNameAvailable(built.name); + this.assertToolRegistrationAllowed(built); } this.tools.push(...builtTools); return this; @@ -208,6 +233,7 @@ export class Agent implements BuiltAgent, AgentBuilder { const tools = Array.isArray(t) ? t : [t]; for (const tool of tools) { const built = 'build' in tool ? tool.build() : tool; + this.assertReservedSdkBuiltInToolName(built); this.deferredTools.push(built); } if (options?.search?.topK !== undefined) { @@ -763,7 +789,7 @@ export class Agent implements BuiltAgent, AgentBuilder { ); } - const allTools = [...finalStaticTools, ...mcpTools]; + let allTools = [...finalStaticTools, ...mcpTools]; // Validate checkpoint again after discovering actual MCP tools // (catches the case where MCP tools have suspendSchema after listing). @@ -793,6 +819,22 @@ export class Agent implements BuiltAgent, AgentBuilder { instructions = `${instructions}\n\n${wsInstructions}`; } } + const telemetry = this.telemetryConfig ?? (await this.telemetryBuilder?.build()); + const toolSearch = + finalDeferredTools.length > 0 && this.deferredToolSearchTopK !== undefined + ? { topK: this.deferredToolSearchTopK } + : undefined; + + allTools = this.completeInlineDelegateTools(allTools, { + deferredTools: finalDeferredTools, + modelConfig, + providerTools: this.providerTools, + ...(telemetry !== undefined ? { telemetry } : {}), + ...(this.concurrencyValue !== undefined + ? { toolCallConcurrency: this.concurrencyValue } + : {}), + ...(toolSearch !== undefined ? { toolSearch } : {}), + }); this.runtime = new AgentRuntime({ name: this.name, @@ -800,35 +842,145 @@ export class Agent implements BuiltAgent, AgentBuilder { instructions, tools: allTools.length > 0 ? allTools : undefined, deferredTools: finalDeferredTools.length > 0 ? finalDeferredTools : undefined, - toolSearch: - finalDeferredTools.length > 0 && this.deferredToolSearchTopK !== undefined - ? { topK: this.deferredToolSearchTopK } - : undefined, + toolSearch, instructionProviderOptions: this.instructionProviderOpts, providerTools: this.providerTools.length > 0 ? this.providerTools : undefined, memory: memoryConfig?.memory, observationLog: memoryConfig?.observationLog, observationalMemory: memoryConfig?.observationalMemory, episodicMemory: memoryConfig?.episodicMemory, - semanticRecall: memoryConfig?.semanticRecall, structuredOutput: this.outputSchema, checkpointStorage: this.checkpointStore, thinking: this.thinkingConfig, eventBus: this.eventBus, toolCallConcurrency: this.concurrencyValue, titleGeneration: memoryConfig?.titleGeneration, - telemetry: this.telemetryConfig ?? (await this.telemetryBuilder?.build()), + telemetry, }); return this.runtime; } + private completeInlineDelegateTools( + tools: BuiltTool[], + options: { + deferredTools: BuiltTool[]; + modelConfig: ModelConfig; + providerTools: BuiltProviderTool[]; + telemetry?: BuiltTelemetry; + toolCallConcurrency?: number; + toolSearch?: { topK?: number }; + }, + ): BuiltTool[] { + return tools.map((tool) => { + const delegateOptions = getInlineDelegateSubAgentToolOptions(tool); + if (!delegateOptions) return tool; + + const runInlineSubAgent = this.createInlineSubAgentRunner({ + ...options, + tools, + inlineSubAgentBlockedTools: delegateOptions.inlineSubAgentBlockedTools, + }); + const hostRunner = delegateOptions.runSubAgent; + const completedTool = createDelegateSubAgentTool({ + ...delegateOptions, + runSubAgent: async (request, _helpersFromHandler) => { + const helpers = { runInlineSubAgent }; + if (hostRunner) { + return await hostRunner(request, helpers); + } + if (request.subAgentId === INLINE_SUB_AGENT_ID) { + return await runInlineSubAgent(request); + } + return { + status: 'failed', + taskPath: request.taskPath, + answer: '', + error: `No configured subagent matched "${request.subAgentId}". Use "inline" for an inline sub-agent, or pass one of the configured subagent IDs.`, + }; + }, + }); + + if (tool.withDefaultApproval) { + return wrapToolForApproval(completedTool, { requireApproval: true }); + } + return completedTool; + }); + } + + private createInlineSubAgentRunner(options: { + deferredTools: BuiltTool[]; + modelConfig: ModelConfig; + providerTools: BuiltProviderTool[]; + telemetry?: BuiltTelemetry; + toolCallConcurrency?: number; + toolSearch?: { topK?: number }; + tools: BuiltTool[]; + inlineSubAgentBlockedTools?: string[]; + }): (request: DelegateSubAgentRequest) => Promise { + return async (request) => { + const tools = filterInlineSubAgentTools(options.tools, options.inlineSubAgentBlockedTools); + const deferredTools = filterInlineSubAgentTools( + options.deferredTools, + options.inlineSubAgentBlockedTools, + ); + const providerTools = filterInlineSubAgentTools( + options.providerTools, + options.inlineSubAgentBlockedTools, + ); + const childRuntime = new AgentRuntime({ + name: `${this.name}:${request.taskName}`, + model: options.modelConfig, + instructions: + 'You are a focused subagent working on a specific delegated task. Complete the delegated task independently and return a concise, self-contained summary to your parent agent.', + tools: tools.length > 0 ? tools : undefined, + deferredTools: deferredTools.length > 0 ? deferredTools : undefined, + toolSearch: deferredTools.length > 0 ? options.toolSearch : undefined, + providerTools: providerTools.length > 0 ? providerTools : undefined, + instructionProviderOptions: this.instructionProviderOpts, + checkpointStorage: this.checkpointStore, + thinking: this.thinkingConfig, + ...(options.telemetry !== undefined ? { telemetry: options.telemetry } : {}), + ...(options.toolCallConcurrency !== undefined + ? { toolCallConcurrency: options.toolCallConcurrency } + : {}), + }); + + try { + const result = await childRuntime.generate(renderDelegateSubAgentPrompt(request), { + ...(request.parentAbortSignal !== undefined + ? { abortSignal: request.parentAbortSignal } + : {}), + ...(options.telemetry !== undefined ? { telemetry: options.telemetry } : {}), + }); + if (result.pendingSuspend !== undefined && result.pendingSuspend.length > 0) { + return failedDelegatedChildSuspendOutput(request.taskPath); + } + return generateResultToDelegateSubAgentOutput(request.taskPath, result); + } finally { + await childRuntime.dispose(); + } + }; + } + + private assertToolRegistrationAllowed(tool: BuiltTool): void { + this.assertToolNameAvailable(tool.name); + this.assertReservedSdkBuiltInToolName(tool); + } + private assertToolNameAvailable(toolName: string): void { if (!this.hasRuntimeSkillTool || !RUNTIME_SKILL_TOOL_NAMES.has(toolName)) return; throw new Error(`Tool name "${toolName}" is reserved for runtime skills`); } + private assertReservedSdkBuiltInToolName(tool: BuiltTool): void { + if (!SDK_RESERVED_BUILTIN_TOOL_NAMES.has(tool.name)) return; + if (isSdkOwnedBuiltInTool(tool)) return; + + throw new Error(`Tool name "${tool.name}" is reserved for SDK built-in tools`); + } + private removeRuntimeSkillTools(): void { if (!this.hasRuntimeSkillTool) return; @@ -837,6 +989,18 @@ export class Agent implements BuiltAgent, AgentBuilder { } } +export function buildInlineSubAgentBlockedToolNames(hostBlockedTools?: string[]): Set { + return new Set([...SDK_INLINE_SUB_AGENT_BLOCKED_TOOL_NAMES, ...(hostBlockedTools ?? [])]); +} + +export function filterInlineSubAgentTools( + tools: T[], + hostBlockedTools?: string[], +): T[] { + const blocked = buildInlineSubAgentBlockedToolNames(hostBlockedTools); + return tools.filter((tool) => !blocked.has(tool.name)); +} + function findDuplicateToolNames(tools: BuiltTool[]): string[] { const seen = new Set(); const duplicates = new Set(); diff --git a/packages/@n8n/agents/src/sdk/memory.ts b/packages/@n8n/agents/src/sdk/memory.ts index 09394d3318f..9478d0ab275 100644 --- a/packages/@n8n/agents/src/sdk/memory.ts +++ b/packages/@n8n/agents/src/sdk/memory.ts @@ -23,7 +23,6 @@ import type { EpisodicMemoryConfig, MemoryConfig, ObservationalMemoryConfig, - SemanticRecallConfig, TitleGenerationConfig, } from '../types'; import type { ModelConfig } from '../types/sdk/agent'; @@ -165,8 +164,6 @@ export function normalizeMemoryConfig(config: MemoryConfig): MemoryConfig { * ``` */ export class Memory { - private semanticRecallConfig?: SemanticRecallConfig; - private episodicMemoryConfig?: EpisodicMemoryConfig; private memoryBackend?: BuiltMemory; @@ -190,12 +187,6 @@ export class Memory { return this; } - /** Enable semantic recall (RAG-based retrieval of relevant past messages). */ - semanticRecall(config: SemanticRecallConfig): this { - this.semanticRecallConfig = config; - return this; - } - /** Enable source-backed cross-session episodic memory. */ episodicMemory(config: EpisodicMemoryConfig = {}): this { if (config.enabled === false) { @@ -233,26 +224,10 @@ export class Memory { /** * Validate configuration and produce a `MemoryConfig`. - * - * @throws if `.semanticRecall()` is used with a backend that doesn't support search() */ build(): MemoryConfig { const memory: BuiltMemory = this.memoryBackend ?? new InMemoryMemory(); - if (this.semanticRecallConfig) { - if (!memory.queryEmbeddings && !memory.search) { - throw new Error( - 'Semantic recall requires a storage backend with queryEmbeddings() or search() support.', - ); - } - if (!memory.search && !this.semanticRecallConfig.embedder) { - throw new Error( - 'Semantic recall requires an embedder when using queryEmbeddings(). Add embedder to your semanticRecall config: ' + - ".semanticRecall({ topK: 5, embedder: 'openai/text-embedding-3-small' })", - ); - } - } - if (isEpisodicMemoryEnabled(this.episodicMemoryConfig)) { if (!hasEpisodicMemoryStore(memory)) { throw new Error( @@ -263,7 +238,6 @@ export class Memory { const baseConfig = { memory, - semanticRecall: this.semanticRecallConfig, episodicMemory: this.episodicMemoryConfig, titleGeneration: this.titleGenerationConfig, }; diff --git a/packages/@n8n/agents/src/sdk/tool.ts b/packages/@n8n/agents/src/sdk/tool.ts index b9b67dd12ac..93c7b56c27b 100644 --- a/packages/@n8n/agents/src/sdk/tool.ts +++ b/packages/@n8n/agents/src/sdk/tool.ts @@ -56,18 +56,14 @@ export function wrapToolForApproval(tool: BuiltTool, config: ApprovalConfig): Bu if (needs) { return await interruptCtx.suspend({ type: 'approval', toolName: tool.name, args: input }); } - return await originalHandler(input, { - parentTelemetry: interruptCtx.parentTelemetry, - } as ToolContext); + return await originalHandler(input, interruptCtx as ToolContext); } const { approved } = interruptCtx.resumeData as z.infer; if (!approved) { return { declined: true, message: `Tool "${tool.name}" was not approved` }; } - return await originalHandler(input, { - parentTelemetry: interruptCtx.parentTelemetry, - } as ToolContext); + return await originalHandler(input, interruptCtx as ToolContext); }, }; } diff --git a/packages/@n8n/agents/src/storage/base-memory.ts b/packages/@n8n/agents/src/storage/base-memory.ts index e4e58b6ab1e..8234bb30de4 100644 --- a/packages/@n8n/agents/src/storage/base-memory.ts +++ b/packages/@n8n/agents/src/storage/base-memory.ts @@ -36,35 +36,6 @@ export abstract class BaseMemory { throw new Error('Method not implemented.'); } - search?( - _query: string, - _opts?: { - scope?: 'thread' | 'resource'; - threadId?: string; - resourceId?: string; - topK?: number; - messageRange?: { before: number; after: number }; - }, - ): Promise { - throw new Error('Method not implemented.'); - } - saveEmbeddings?(_opts: { - scope?: 'thread' | 'resource'; - threadId?: string; - resourceId?: string; - entries: Array<{ id: string; vector: number[]; text: string; model: string }>; - }): Promise { - throw new Error('Method not implemented.'); - } - queryEmbeddings?(_opts: { - scope?: 'thread' | 'resource'; - threadId?: string; - resourceId?: string; - vector: number[]; - topK: number; - }): Promise> { - throw new Error('Method not implemented.'); - } close?(): Promise { throw new Error('Method not implemented.'); diff --git a/packages/@n8n/agents/src/types/index.ts b/packages/@n8n/agents/src/types/index.ts index a669cf4bd3d..0cb8953d35f 100644 --- a/packages/@n8n/agents/src/types/index.ts +++ b/packages/@n8n/agents/src/types/index.ts @@ -92,7 +92,6 @@ export type { RetrievedEpisodicMemoryEntry, ObservationCapableMemory, MemoryDescriptor, - SemanticRecallConfig, MemoryConfig, ObservationLogMemoryConfig, ObservationalMemoryConfig, diff --git a/packages/@n8n/agents/src/types/runtime/event.ts b/packages/@n8n/agents/src/types/runtime/event.ts index ac82779cbc3..9620c7c7fe3 100644 --- a/packages/@n8n/agents/src/types/runtime/event.ts +++ b/packages/@n8n/agents/src/types/runtime/event.ts @@ -19,7 +19,7 @@ export interface SubAgentStartedPayload extends SubAgentLifecycleBase { } export interface SubAgentCompletedPayload extends SubAgentLifecycleBase { - status: 'completed' | 'failed'; + status: 'completed' | 'failed' | 'suspended'; startedAt: number; finishedAt: number; durationMs: number; diff --git a/packages/@n8n/agents/src/types/sdk/memory.ts b/packages/@n8n/agents/src/types/sdk/memory.ts index c078feda446..190530b0069 100644 --- a/packages/@n8n/agents/src/types/sdk/memory.ts +++ b/packages/@n8n/agents/src/types/sdk/memory.ts @@ -61,38 +61,6 @@ export interface BuiltMemory { messages: AgentDbMessage[]; }): Promise; deleteMessages(messageIds: string[]): Promise; - // --- Semantic recall (optional) --- - search?( - query: string, - opts?: { - /** @default 'resource' */ - scope?: 'thread' | 'resource'; - threadId?: string; - resourceId?: string; - topK?: number; - messageRange?: { before: number; after: number }; - }, - ): Promise; - // --- Tier 3: Vector operations (optional — runtime handles embeddings) --- - saveEmbeddings?(opts: { - scope?: 'thread' | 'resource'; - threadId?: string; - resourceId?: string; - entries: Array<{ - id: string; - vector: number[]; - text: string; - model: string; - }>; - }): Promise; - queryEmbeddings?(opts: { - /** @default 'resource' */ - scope?: 'thread' | 'resource'; - threadId?: string; - resourceId?: string; - vector: number[]; - topK: number; - }): Promise>; // --- Episodic memory (optional — runtime handles extraction and embeddings) --- episodic?: EpisodicMemoryMethods; // --- Lifecycle (optional) --- @@ -102,18 +70,6 @@ export interface BuiltMemory { describe(): MemoryDescriptor; } -// --- Semantic Recall Config --- - -export interface SemanticRecallConfig { - /** @default 'resource' */ - scope?: 'thread' | 'resource'; - topK: number; - messageRange?: { before: number; after: number }; - embedder?: string; // e.g. 'openai/text-embedding-3-small' — required for queryEmbeddings(), optional for search()-based backends - /** API key for the embedder provider. Falls back to environment variables if not set. */ - apiKey?: string; -} - export type EpisodicMemoryStatus = 'active' | 'superseded' | 'dropped'; export interface EpisodicMemoryScope { @@ -346,7 +302,6 @@ export interface ObservationalMemoryConfig { interface MemoryConfigBase { observationLog?: ObservationLogMemoryConfig; - semanticRecall?: SemanticRecallConfig; episodicMemory?: EpisodicMemoryConfig; titleGeneration?: TitleGenerationConfig; } 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 918d8ad4433..7c9332ef8f0 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 @@ -2,18 +2,6 @@ import { z, type ZodError } from 'zod'; import { AgentIntegrationConfigSchema } from './agent-integration.schema'; -const SemanticRecallSchema = z.object({ - topK: z.number().int().min(1).max(100), - scope: z.enum(['thread', 'resource']).optional(), - messageRange: z - .object({ - before: z.number().int().min(0), - after: z.number().int().min(0), - }) - .optional(), - embedder: z.string().optional(), -}); - export const AgentModelSchema = z .string() .min(1) @@ -60,7 +48,6 @@ const EpisodicMemoryConfigSchema = z.discriminatedUnion('enabled', [ const MemoryConfigSchema = z.object({ enabled: z.boolean(), storage: z.enum(['n8n']), - semanticRecall: SemanticRecallSchema.optional(), observationalMemory: ObservationalMemoryConfigSchema.optional(), episodicMemory: EpisodicMemoryConfigSchema.optional(), }); @@ -338,7 +325,3 @@ 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/cli/src/modules/agents/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts index a3d426a0e75..a8cdfa80ac9 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 @@ -149,6 +149,56 @@ describe('AgentExecutionService', () => { 'version-1', ); }); + + it('syncs a generated title from memory on later messages when the thread has no title yet', async () => { + agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ + thread: makeThread({ title: null }), + created: false, + }); + agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); + agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution); + memoryBackend.getThread.mockResolvedValue({ + id: 'thread-1', + resourceId: 'user-1', + title: 'Workflow builder chat', + createdAt: new Date(), + updatedAt: new Date(), + }); + + await service.recordMessage({ + threadId: 'thread-1', + agentId: 'agent-1', + agentName: 'Agent', + projectId: 'project-1', + userMessage: 'Follow up', + record: makeMessageRecord(), + }); + + expect(agentExecutionThreadRepository.update).toHaveBeenCalledWith('thread-1', { + title: 'Workflow builder chat', + }); + }); + + it('does not sync title from memory when the thread already has a title', async () => { + agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ + thread: makeThread({ title: 'Existing title' }), + created: false, + }); + agentExecutionRepository.create.mockImplementation((data) => data 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: 'Follow up', + record: makeMessageRecord(), + }); + + expect(memoryBackend.getThread).not.toHaveBeenCalled(); + expect(agentExecutionThreadRepository.update).not.toHaveBeenCalled(); + }); }); describe('getThreadDetail', () => { 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 b538b6d602a..409f735da74 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,9 +1,4 @@ -import { - AgentJsonConfigSchema, - isNodeToolsEnabled, - isSubAgentsEnabled, - type AgentJsonConfig, -} from '@n8n/api-types'; +import { AgentJsonConfigSchema, isNodeToolsEnabled, type AgentJsonConfig } from '@n8n/api-types'; const baseConfig: AgentJsonConfig = { name: 'Test Agent', @@ -104,21 +99,10 @@ describe('AgentJsonConfigSchema — subAgents', () => { expect(parsed.success).toBe(true); }); - it('rejects the removed subAgents.enabled flag', () => { + it('accepts an empty saved-agent reference list', () => { 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); + AgentJsonConfigSchema.safeParse({ ...baseConfig, subAgents: { agents: [] } }).success, + ).toBe(true); }); }); 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 5d14a189722..a983a960fc8 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 @@ -66,6 +66,21 @@ describe('agent-sse-stream — stringifyError (via pumpChunks error chunk)', () }); }); +describe('agent-sse-stream — stream completion', () => { + it('completes after the runtime stream closes even when a finish chunk is present', async () => { + const events = await collectEvents([ + { type: 'text-delta', id: 't-1', delta: 'hello' }, + { type: 'text-end', id: 't-1' }, + { type: 'finish', finishReason: 'stop' }, + ]); + + expect(events).toEqual([ + { type: 'text-delta', id: 't-1', delta: 'hello' }, + { type: 'text-end', id: 't-1' }, + ]); + }); +}); + describe('agent-sse-stream — tool execution lifecycle chunks', () => { it('forwards tool-execution-start with its server startTime', async () => { const events = await collectEvents([ diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts index 5aa5baf6c02..ad71fb5930b 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts @@ -1,37 +1,34 @@ import type * as agents from '@n8n/agents'; +import { DELEGATE_SUB_AGENT_TOOL_NAME, WRITE_TODOS_TOOL_NAME } from '@n8n/agents'; import type { CredentialProvider, BuiltTool } from '@n8n/agents'; import type { AgentsConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; -import type { ToolRegistry } from '../tool-registry'; import type { Logger } from '@n8n/backend-common'; -import type { - ExecutionRepository, - ProjectRelationRepository, - UserRepository, - WorkflowRepository, -} from '@n8n/db'; +import type { ExecutionRepository, UserRepository, WorkflowRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import type { ActiveExecutions } from '@/active-executions'; import type { EphemeralNodeExecutor } from '@/node-execution'; +import type { OauthService } from '@/oauth/oauth.service'; import type { UrlService } from '@/services/url.service'; -import type { Telemetry } from '@/telemetry'; import type { WorkflowRunner } from '@/workflow-runner'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; -import type { AgentExecutionService } from '../agent-execution.service'; -import type { AgentSkillsService } from '../agent-skills.service'; +import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service'; import type { AgentsToolsService } from '../agents-tools.service'; -import { AgentsService } from '../agents.service'; import type { Agent } from '../entities/agent.entity'; import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage'; import type { N8nMemory } from '../integrations/n8n-memory'; import type { AgentJsonConfig } from '@n8n/api-types'; -import type { AgentHistoryRepository } from '../repositories/agent-history.repository'; import type { AgentRepository } from '../repositories/agent.repository'; +import type { ToolExecutor } from '../json-config/from-json-config'; import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; +import type { AgentKnowledgeCommandService } from '../agent-knowledge-command.service'; +import type { AgentKnowledgeService } from '../agent-knowledge.service'; +import { SubAgentForegroundRunner } from '../sub-agents/sub-agent-foreground-runner'; -// Mock buildFromJson so reconstructFromConfig doesn't try to actually build an agent. +// Mock buildFromJson so reconstruction doesn't try to actually build an agent. const builtAgent = mock(); builtAgent.hasCheckpointStorage.mockReturnValue(true); // skip checkpoint injection branch @@ -49,17 +46,25 @@ jest.mock('../json-config/mcp-client-factory', () => ({ // Avoid loading the rich-interaction tool (its import path resolves to runtime code). jest.mock('../integrations/rich-interaction-tool', () => ({ - createRichInteractionTool: () => ({}) as never, + createRichInteractionTool: () => ({ name: 'rich_interaction' }) as never, })); -function makeService( +beforeEach(() => { + Container.set(SubAgentForegroundRunner, mock()); +}); + +function makeReconstructionService( agentsToolsService: AgentsToolsService, modules: string[] = [], -): AgentsService { - return new AgentsService( - mock(), + overrides: { + logger?: Logger; + } = {}, +): AgentRuntimeReconstructionService { + const secureRuntime = mock(); + secureRuntime.createToolExecutor.mockReturnValue(mock()); + return new AgentRuntimeReconstructionService( + overrides.logger ?? mock(), mock(), - mock(), mock(), mock(), mock(), @@ -68,23 +73,14 @@ function makeService( mock(), mock(), mock(), - mock(), + secureRuntime, mock(), agentsToolsService, mock(), - mock(), - mock(), - mock(), - mock(), // AgentTaskRepository - mock(), // AgentTaskSnapshotRepository - mock(), + mock(), { modules } as unknown as AgentsConfig, - mock(), - mock(), - mock(), - mock(), - mock(), - mock(), + mock(), + mock(), ); } @@ -107,19 +103,9 @@ function makeAgentEntity( } as unknown as Agent; } -// reconstructFromConfig is private; cast to invoke directly. -type Reconstructable = { - reconstructFromConfig( - agentEntity: Agent, - credentialProvider: CredentialProvider, - userId?: string, - ): Promise<{ agent: agents.Agent; toolRegistry: ToolRegistry }>; -}; - -describe('AgentsService.reconstructFromConfig — node tools gating', () => { +describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — node tools gating', () => { beforeEach(() => { jest.clearAllMocks(); - // rebuild the builtAgent mock state (jest.clearAllMocks clears calls, not behavior) builtAgent.hasCheckpointStorage.mockReturnValue(true); }); @@ -127,7 +113,7 @@ describe('AgentsService.reconstructFromConfig — node tools gating', () => { const agentsToolsService = mock(); agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]); const credentialProvider = mock(); - const service = makeService( + const service = makeReconstructionService( agentsToolsService, options.nodeToolsModuleEnabled ? ['node-tools-searcher'] : [], ); @@ -177,7 +163,7 @@ describe('AgentsService.reconstructFromConfig — node tools gating', () => { }); const entity = makeAgentEntity(schemaConfig); - await (service as unknown as Reconstructable).reconstructFromConfig(entity, credentialProvider); + await service.reconstructFromAgentEntity(entity, credentialProvider, 'user-1'); if (attaches) { expect(agentsToolsService.getRuntimeTools).toHaveBeenCalledWith( @@ -190,14 +176,11 @@ describe('AgentsService.reconstructFromConfig — node tools gating', () => { }); }); -describe('AgentsService.reconstructFromConfig — MCP wiring', () => { +describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — MCP wiring', () => { beforeEach(() => { jest.clearAllMocks(); builtAgent.hasCheckpointStorage.mockReturnValue(true); buildFromJsonMock.mockImplementation(async (_config, _descriptors, options) => { - // Drive the buildMcpClient callback exactly once per configured server, - // matching what the real buildFromJson does — this is what lets the - // gating test assert how many MCP clients were created. const cfg = _config as AgentJsonConfig; if (options?.buildMcpClient && cfg.mcpServers) { for (const server of cfg.mcpServers) { @@ -212,7 +195,7 @@ describe('AgentsService.reconstructFromConfig — MCP wiring', () => { const agentsToolsService = mock(); agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]); const credentialProvider = mock(); - const service = makeService(agentsToolsService); + const service = makeReconstructionService(agentsToolsService); return { service, credentialProvider }; } @@ -220,7 +203,7 @@ describe('AgentsService.reconstructFromConfig — MCP wiring', () => { const { service, credentialProvider } = setup(); const entity = makeAgentEntity(); - await (service as unknown as Reconstructable).reconstructFromConfig(entity, credentialProvider); + await service.reconstructFromAgentEntity(entity, credentialProvider, 'user-1'); expect(buildMcpClientForServerMock).not.toHaveBeenCalled(); }); @@ -244,10 +227,113 @@ describe('AgentsService.reconstructFromConfig — MCP wiring', () => { ], }); - await (service as unknown as Reconstructable).reconstructFromConfig(entity, credentialProvider); + await service.reconstructFromAgentEntity(entity, credentialProvider, 'user-1'); expect(buildMcpClientForServerMock).toHaveBeenCalledTimes(2); expect(buildMcpClientForServerMock.mock.calls[0][0]).toMatchObject({ name: 'github' }); expect(buildMcpClientForServerMock.mock.calls[1][0]).toMatchObject({ name: 'fs' }); }); }); + +describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-agent delegation gating', () => { + beforeEach(() => { + jest.clearAllMocks(); + builtAgent.hasCheckpointStorage.mockReturnValue(true); + builtAgent.tool.mockClear(); + }); + + function setup() { + const agentsToolsService = mock(); + agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]); + const credentialProvider = mock(); + const service = makeReconstructionService(agentsToolsService); + return { service, credentialProvider }; + } + + function getInjectedToolNames(): string[] { + const names: string[] = []; + for (const call of builtAgent.tool.mock.calls) { + for (const item of Array.isArray(call[0]) ? call[0] : [call[0]]) { + const tool = item as { name?: string }; + if (tool.name) names.push(tool.name); + } + } + return names; + } + + it.each([ + { + name: 'no subAgents block', + subAgents: undefined, + }, + { + name: 'empty saved-agent reference list', + subAgents: { agents: [] }, + }, + { + name: 'saved-agent references', + subAgents: { agents: [{ agentId: 'agent-2' }] }, + }, + ])('always injects delegation tools for $name', async ({ subAgents }) => { + const { service, credentialProvider } = setup(); + const entity = makeAgentEntity(undefined, subAgents !== undefined ? { subAgents } : {}); + + await service.reconstructFromAgentEntity(entity, credentialProvider, 'user-1'); + + const toolNames = getInjectedToolNames(); + expect(toolNames).toContain(DELEGATE_SUB_AGENT_TOOL_NAME); + expect(toolNames).toContain(WRITE_TODOS_TOOL_NAME); + }); +}); + +describe('AgentRuntimeReconstructionService.reconstructFromResolvedSource — sub-agent runtime profile', () => { + beforeEach(() => { + jest.clearAllMocks(); + builtAgent.hasCheckpointStorage.mockReturnValue(true); + builtAgent.tool.mockClear(); + }); + + function getInjectedToolNames(): string[] { + const names: string[] = []; + for (const call of builtAgent.tool.mock.calls) { + for (const item of Array.isArray(call[0]) ? call[0] : [call[0]]) { + const tool = item as { name?: string }; + if (tool.name) names.push(tool.name); + } + } + return names; + } + + it('does not inject rich_interaction or integration context/action tools', async () => { + const agentsToolsService = mock(); + agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]); + const credentialProvider = mock(); + const service = makeReconstructionService(agentsToolsService); + + const config: AgentJsonConfig = { + name: 'Child', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Help', + }; + + await service.reconstructFromResolvedSource({ + config, + memoryOwnerAgentId: 'child-agent-1', + projectId: 'project-1', + credentialProvider, + toolDescriptors: {}, + toolCodeByName: {}, + skills: {}, + userId: 'user-1', + runtimeProfile: 'sub-agent', + parentAgentIdForDelegation: 'parent-agent-1', + }); + + const toolNames = getInjectedToolNames(); + expect(toolNames).not.toContain('rich_interaction'); + expect(toolNames.filter((name) => name.endsWith('_context'))).toHaveLength(0); + expect(toolNames.filter((name) => name.endsWith('_action'))).toHaveLength(0); + expect(toolNames).not.toContain(DELEGATE_SUB_AGENT_TOOL_NAME); + expect(toolNames).not.toContain(WRITE_TODOS_TOOL_NAME); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts index 8fde99cdc4a..00281b0822c 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts @@ -1,20 +1,10 @@ /* eslint-disable @typescript-eslint/require-await -- mock implementations kept async for future-proofing */ import type { Logger } from '@n8n/backend-common'; import type { AgentsConfig } from '@n8n/config'; -import type { - ExecutionRepository, - ProjectRelationRepository, - UserRepository, - WorkflowRepository, -} from '@n8n/db'; +import type { ProjectRelationRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; -import type { ActiveExecutions } from '@/active-executions'; -import type { EphemeralNodeExecutor } from '@/node-execution'; -import type { UrlService } from '@/services/url.service'; import type { Telemetry } from '@/telemetry'; -import type { WorkflowRunner } from '@/workflow-runner'; -import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import type { AgentExecutionService } from '../agent-execution.service'; import type { AgentSkillsService } from '../agent-skills.service'; @@ -24,7 +14,6 @@ import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storag import type { N8nMemory } from '../integrations/n8n-memory'; import type { AgentJsonConfig } from '@n8n/api-types'; import type { AgentRepository } from '../repositories/agent.repository'; -import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; import type { ChatIntegrationService } from '../integrations/chat-integration.service'; function makeAgent(overrides: Partial = {}): Agent { @@ -62,17 +51,7 @@ describe('AgentsService — updateName / updateDescription schema sync', () => { mock(), agentRepository, mock(), - mock(), - mock(), - mock(), - mock(), - mock(), - mock(), - mock(), mock(), - mock(), - mock(), - mock(), // AgentsToolsService mock(), mock(), mock(), @@ -86,7 +65,6 @@ describe('AgentsService — updateName / updateDescription schema sync', () => { mock(), mock(), mock(), - mock(), ); }); 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 92d320a5086..6f81112213c 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -14,7 +14,19 @@ import { CredentialsService } from '@/credentials/credentials.service'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service'; import { AgentSkillsService } from '../agent-skills.service'; +import type { AgentsToolsService } from '../agents-tools.service'; +import type { Logger } from '@n8n/backend-common'; +import type { ExecutionRepository, UserRepository, WorkflowRepository } from '@n8n/db'; +import type { ActiveExecutions } from '@/active-executions'; +import type { EphemeralNodeExecutor } from '@/node-execution'; +import type { OauthService } from '@/oauth/oauth.service'; +import type { UrlService } from '@/services/url.service'; +import type { WorkflowRunner } from '@/workflow-runner'; +import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import type { AgentKnowledgeCommandService } from '../agent-knowledge-command.service'; +import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; import { AgentTaskService } from '../agent-task.service'; import { AgentsService, chatThreadId } from '../agents.service'; import type { AgentHistory } from '../entities/agent-history.entity'; @@ -36,6 +48,7 @@ import type { AgentHistoryRepository } from '../repositories/agent-history.repos import type { AgentTaskSnapshotRepository } from '../repositories/agent-task-snapshot.repository'; import type { AgentTaskRepository } from '../repositories/agent-task.repository'; import type { AgentRepository } from '../repositories/agent.repository'; +import { SubAgentForegroundRunner } from '../sub-agents/sub-agent-foreground-runner'; import type { AgentTaskSnapshot } from '../entities/agent-task-snapshot.entity'; const agentId = 'agent-1'; @@ -74,6 +87,31 @@ function makeAgentHistory(overrides: Partial = {}): AgentHistory { } as unknown as AgentHistory; } +function makeRuntimeReconstructionService( + modules: string[] = [], +): AgentRuntimeReconstructionService { + return new AgentRuntimeReconstructionService( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + { modules } as unknown as AgentsConfig, + mock(), + mock(), + ); +} + function makeTaskSnapshot(overrides: Partial = {}): AgentTaskSnapshot { return { versionId: 'published-version-id', @@ -157,17 +195,7 @@ describe('AgentsService', () => { logger, agentRepository, mock(), - mock(), - mock(), - mock(), - mock(), - mock(), - mock(), - mock(), n8nCheckpointStorage, - mock(), - mock(), - mock(), n8nMemory, agentExecutionService, agentHistoryRepository, @@ -181,7 +209,6 @@ describe('AgentsService', () => { chatIntegrationService, agentKnowledgeService, mock(), - mock(), ); }); @@ -794,7 +821,32 @@ describe('AgentsService', () => { 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' }] }); + expect(savedEntity.schema?.subAgents).toEqual({ + agents: [{ agentId: 'agent-2' }], + }); + }); + + it('normalizes an explicit empty subAgents list without an enabled flag', async () => { + const agent = makeAgent(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const configWithEmptySubAgents = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + subAgents: { agents: [] }, + } as AgentJsonConfig; + jest.spyOn(service, 'validateConfig').mockResolvedValue({ + valid: true, + config: configWithEmptySubAgents, + }); + + await service.updateConfig(agentId, projectId, configWithEmptySubAgents); + + const savedEntity = agentRepository.save.mock.calls[0][0] as Agent; + expect(savedEntity.schema?.subAgents).toEqual({ + agents: [], + }); }); it('rejects unpublished subagent references', async () => { @@ -1339,6 +1391,10 @@ describe('AgentsService', () => { }); describe('integration runtime tools', () => { + beforeEach(() => { + Container.set(SubAgentForegroundRunner, mock()); + }); + it('injects each credential integration context/action tool only once', async () => { const integrationRegistry = new ChatIntegrationRegistry(); Container.set(ChatIntegrationRegistry, integrationRegistry); @@ -1356,18 +1412,27 @@ describe('AgentsService', () => { if (item.name) toolNames.push(item.name); } }), + on: jest.fn(), hasCheckpointStorage: jest.fn().mockReturnValue(true), checkpoint: jest.fn(), }; + const reconstructionService = makeRuntimeReconstructionService(); await ( - service as unknown as { + reconstructionService as unknown as { injectRuntimeDependencies(params: { agent: typeof runtimeAgent; agentId: string; projectId: string; credentialProvider: unknown; + userId: string; + runtimeProfile: 'top-level'; nodeToolsEnabled: boolean; + subAgentDelegation: { + sourcesById: Record; + availableSubAgents: []; + }; + parentAgentIdForDelegation: string; credentialIntegrations: Array<{ type: string; credentialId: string }>; }): Promise; } @@ -1376,7 +1441,14 @@ describe('AgentsService', () => { agentId, projectId, credentialProvider: mock(), + userId: 'user-1', + runtimeProfile: 'top-level', nodeToolsEnabled: false, + parentAgentIdForDelegation: agentId, + subAgentDelegation: { + sourcesById: {}, + availableSubAgents: [], + }, credentialIntegrations: [{ type: 'slack', credentialId: 'cred-slack' }], }); diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts index 0edc809b5af..a5a587fd370 100644 --- a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -104,6 +104,9 @@ describe('buildFromJson()', () => { extract?: unknown; reflect?: unknown; }; + titleGeneration?: { + sync?: boolean; + }; }; } ).memoryConfig; @@ -357,6 +360,25 @@ describe('buildFromJson()', () => { ).rejects.toThrow('Tool name "load_skill" is reserved for runtime skills'); }); + it('rejects custom tools that reuse SDK built-in tool names', async () => { + const descriptor = makeToolDescriptor({ name: 'write_todos' }); + const config = makeConfig({ + tools: [{ type: 'custom', id: 'planner_tool' }], + }); + + await expect( + buildFromJson( + config, + { planner_tool: descriptor }, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ), + ).rejects.toThrow('Tool name "write_todos" is reserved for SDK built-in tools'); + }); + it('throws when custom tool id is not found in descriptors', async () => { const config = makeConfig({ tools: [{ type: 'custom', id: 'missing_tool' }] }); @@ -783,6 +805,24 @@ describe('buildFromJson()', () => { expect(getMemoryConfig(agent)?.observationalMemory?.reflect).toBeUndefined(); }); + it('uses synchronous title generation so the first message can sync the title', async () => { + const config = makeConfig({ + memory: { enabled: true, storage: 'n8n' }, + }); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: jest.fn().mockReturnValue(makeMockMemoryBackend()), + }, + ); + + expect(getMemoryConfig(agent)?.titleGeneration?.sync).toBe(true); + }); + it('configures observational memory worker models with their own credentials', async () => { const observeSpy = jest.spyOn(AgentsRuntime, 'createObservationLogObserveFn'); const reflectSpy = jest.spyOn(AgentsRuntime, 'createObservationLogReflectFn'); diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts new file mode 100644 index 00000000000..8764c03b631 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -0,0 +1,459 @@ +import { + createWriteTodosTool, + type Agent as RuntimeAgent, + BuiltTool, + CredentialProvider, + ToolDescriptor, +} from '@n8n/agents'; +import type { + AgentIntegrationConfig, + AgentJsonConfig, + AgentJsonMcpServerConfig, + AgentJsonMemoryConfig, + AgentJsonToolConfig, + AgentSkill, + SubAgentRunPolicy, + SubAgentSource, +} from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { AgentsConfig } from '@n8n/config'; +import { isNodeToolsEnabled } from '@n8n/api-types'; +import { ExecutionRepository, UserRepository, WorkflowRepository } from '@n8n/db'; +import { Container, Service } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; + +import { ActiveExecutions } from '@/active-executions'; +import { EphemeralNodeExecutor } from '@/node-execution'; +import { OauthService } from '@/oauth/oauth.service'; +import { UrlService } from '@/services/url.service'; +import { WorkflowRunner } from '@/workflow-runner'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import { Agent } from './entities/agent.entity'; +import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; +import { ChatIntegrationActionExecutor } from './integrations/integration-action-executor'; +import { ChatIntegrationContextQueryExecutor } from './integrations/integration-context-query-executor'; +import { IntegrationMessageContextService } from './integrations/integration-message-context.service'; +import { + createIntegrationActionTool, + createIntegrationContextTool, + getIntegrationToolConnectionDescriptors, +} from './integrations/integration-tools'; +import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; +import { N8nMemory } from './integrations/n8n-memory'; +import { createGetEnvironmentTool } from './tools/environment-tool'; +import { createRichInteractionTool } from './integrations/rich-interaction-tool'; +import { + buildFromJson, + type MemoryFactory, + type ToolResolver, +} from './json-config/from-json-config'; +import { buildMcpClientForServer } from './json-config/mcp-client-factory'; +import { AgentRepository } from './repositories/agent.repository'; +import { AgentSecureRuntime } from './runtime/agent-secure-runtime'; +import { buildToolRegistry, type ToolRegistry } from './tool-registry'; +import { AgentKnowledgeCommandService } from './agent-knowledge-command.service'; +import { AgentKnowledgeService } from './agent-knowledge.service'; +import { AgentsToolsService } from './agents-tools.service'; +import { createN8nDelegateSubAgentTool } from './sub-agents/delegate-sub-agent-tool'; +import { SubAgentForegroundRunner } from './sub-agents/sub-agent-foreground-runner'; +export type AgentRuntimeProfile = 'top-level' | 'sub-agent'; + +export interface SubAgentDelegationConfig { + sourcesById: Record; + availableSubAgents: Array<{ id: string; name: string; description?: string }>; +} + +export interface ReconstructAgentRuntimeParams { + config: AgentJsonConfig; + memoryOwnerAgentId: string; + projectId: string; + credentialProvider: CredentialProvider; + toolDescriptors: Record; + toolCodeByName: Record; + skills: Record; + /** Required for workflow tool resolution. */ + userId: string; + runtimeProfile: AgentRuntimeProfile; + /** Delegating parent agent id for sub-agent runs; defaults to memoryOwnerAgentId for top-level. */ + parentAgentIdForDelegation?: string; + /** Top-level chat/integration runtimes only. */ + integrationType?: string; + /** Top-level chat/integration runtimes only. */ + credentialIntegrations?: AgentIntegrationConfig[]; +} + +@Service() +export class AgentRuntimeReconstructionService { + constructor( + private readonly logger: Logger, + private readonly agentRepository: AgentRepository, + private readonly workflowRunner: WorkflowRunner, + private readonly activeExecutions: ActiveExecutions, + private readonly executionRepository: ExecutionRepository, + private readonly workflowRepository: WorkflowRepository, + private readonly userRepository: UserRepository, + private readonly workflowFinderService: WorkflowFinderService, + private readonly urlService: UrlService, + private readonly n8nCheckpointStorage: N8NCheckpointStorage, + private readonly secureRuntime: AgentSecureRuntime, + private readonly ephemeralNodeExecutor: EphemeralNodeExecutor, + private readonly agentsToolsService: AgentsToolsService, + private readonly n8nMemory: N8nMemory, + private readonly oauthService: OauthService, + private readonly agentsConfig: AgentsConfig, + private readonly agentKnowledgeService: AgentKnowledgeService, + private readonly agentKnowledgeCommandService: AgentKnowledgeCommandService, + ) {} + + async reconstructFromAgentEntity( + agentEntity: Agent, + credentialProvider: CredentialProvider, + userId: string, + integrationType?: string, + ): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> { + const config = agentEntity.schema; + if (!config) { + throw new UserError('Agent has no JSON config.'); + } + + const toolsByName: Record = {}; + const toolDescriptors: Record = {}; + for (const [_toolId, toolEntry] of Object.entries(agentEntity.tools ?? {})) { + toolsByName[toolEntry.descriptor.name] = toolEntry.code; + toolDescriptors[_toolId] = toolEntry.descriptor; + } + + const subAgentDelegation = await this.createSubAgentDelegationConfig( + config, + agentEntity.projectId, + ); + + return await this.reconstructRuntime({ + config, + memoryOwnerAgentId: agentEntity.id, + projectId: agentEntity.projectId, + credentialProvider, + toolDescriptors, + toolCodeByName: toolsByName, + skills: agentEntity.skills ?? {}, + userId, + runtimeProfile: 'top-level', + parentAgentIdForDelegation: agentEntity.id, + integrationType, + credentialIntegrations: agentEntity.integrations ?? [], + subAgentDelegation, + }); + } + + async reconstructFromResolvedSource( + params: ReconstructAgentRuntimeParams, + ): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> { + const subAgentDelegation = await this.createSubAgentDelegationConfig( + params.config, + params.projectId, + ); + + return await this.reconstructRuntime({ + ...params, + credentialIntegrations: [], + subAgentDelegation, + }); + } + + private async reconstructRuntime(options: { + config: AgentJsonConfig; + memoryOwnerAgentId: string; + projectId: string; + credentialProvider: CredentialProvider; + toolDescriptors: Record; + toolCodeByName: Record; + skills: Record; + userId: string; + runtimeProfile: AgentRuntimeProfile; + parentAgentIdForDelegation?: string; + integrationType?: string; + credentialIntegrations: AgentIntegrationConfig[]; + subAgentDelegation: SubAgentDelegationConfig; + }): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> { + const { + config, + memoryOwnerAgentId, + projectId, + credentialProvider, + toolDescriptors, + toolCodeByName, + skills, + userId, + runtimeProfile, + parentAgentIdForDelegation, + integrationType, + credentialIntegrations, + subAgentDelegation, + } = options; + + const toolExecutor = this.secureRuntime.createToolExecutor(toolCodeByName); + const toolResolver = this.makeToolResolver(projectId, userId); + const resolvedTools: BuiltTool[] = []; + + const buildMcpClient = async (server: AgentJsonMcpServerConfig) => + await buildMcpClientForServer(server, { + credentialProvider, + oauthService: this.oauthService, + projectId, + }); + + const reconstructed = await buildFromJson(config, toolDescriptors, { + toolExecutor, + credentialProvider, + resolveTool: async (ref) => { + const resolved = await toolResolver(ref); + if (resolved) resolvedTools.push(resolved); + return resolved; + }, + skills, + memoryFactory: this.getMemoryFactory(memoryOwnerAgentId), + buildMcpClient, + }); + + await this.injectRuntimeDependencies({ + agent: reconstructed, + agentId: memoryOwnerAgentId, + projectId, + credentialProvider, + userId, + runtimeProfile, + nodeToolsEnabled: this.shouldAttachNodeTools(config.config), + subAgentDelegation, + parentAgentIdForDelegation: parentAgentIdForDelegation ?? memoryOwnerAgentId, + integrationType, + credentialIntegrations, + }); + + return { agent: reconstructed, toolRegistry: buildToolRegistry(resolvedTools) }; + } + + async createSubAgentDelegationConfig( + config: AgentJsonConfig, + projectId: string, + ): Promise { + const configuredAgents = config.subAgents?.agents ?? []; + 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 { sourcesById, availableSubAgents }; + } + + 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 getMemoryFactory(agentId: string): MemoryFactory { + return (_params: AgentJsonMemoryConfig) => this.n8nMemory.getImplementation(agentId); + } + + private shouldAttachNodeTools(config: AgentJsonConfig['config']): boolean { + return this.isNodeToolsModuleEnabled() && isNodeToolsEnabled(config); + } + + private isNodeToolsModuleEnabled(): boolean { + return this.agentsConfig.modules.includes('node-tools-searcher'); + } + + private isKnowledgeBaseModuleEnabled(): boolean { + return this.agentsConfig.modules.includes('knowledge-base'); + } + + private makeToolResolver(projectId: string, userId: string): ToolResolver { + return async (ref: AgentJsonToolConfig) => { + if (ref.type === 'workflow') { + if (!userId) { + throw new UserError('userId is required when agent uses workflow tools'); + } + const { resolveWorkflowTool } = await import('./tools/workflow-tool-factory'); + return await resolveWorkflowTool(ref, { + workflowRepository: this.workflowRepository, + workflowRunner: this.workflowRunner, + activeExecutions: this.activeExecutions, + executionRepository: this.executionRepository, + workflowFinderService: this.workflowFinderService, + userRepository: this.userRepository, + userId, + projectId, + webhookBaseUrl: this.urlService.getWebhookBaseUrl(), + }); + } + + if (ref.type === 'node') { + const { resolveNodeTool } = await import('./tools/node-tool-factory'); + return await resolveNodeTool(ref, { + executor: this.ephemeralNodeExecutor, + projectId, + }); + } + + return null; + }; + } + + private async injectRuntimeDependencies(params: { + agent: RuntimeAgent; + agentId: string; + projectId: string; + credentialProvider: CredentialProvider; + userId: string; + runtimeProfile: AgentRuntimeProfile; + nodeToolsEnabled: boolean; + subAgentDelegation: SubAgentDelegationConfig; + parentAgentIdForDelegation: string; + integrationType?: string; + credentialIntegrations: AgentIntegrationConfig[]; + }): Promise { + const { + agent, + agentId, + projectId, + credentialProvider, + userId, + runtimeProfile, + nodeToolsEnabled, + subAgentDelegation, + parentAgentIdForDelegation, + integrationType, + credentialIntegrations, + } = params; + + agent.tool(createGetEnvironmentTool()); + + if (this.isKnowledgeBaseModuleEnabled()) { + try { + const { createSearchKnowledgeTool } = await import('./tools/knowledge/tool'); + agent.tool( + createSearchKnowledgeTool({ + agentId, + projectId, + knowledgeService: this.agentKnowledgeService, + commandService: this.agentKnowledgeCommandService, + }), + ); + } catch (toolError) { + this.logger.warn('Failed to inject search_knowledge tool', { + agentId, + error: toolError instanceof Error ? toolError.message : String(toolError), + }); + } + } + + if (runtimeProfile === 'top-level') { + const integrationRegistry = Container.get(ChatIntegrationRegistry); + const integration = integrationType ? integrationRegistry.get(integrationType) : undefined; + if (integration?.supportedComponents !== undefined) { + agent.tool(createRichInteractionTool(integrationType)); + } + + if (credentialIntegrations.length > 0) { + const messageContextStore = Container.get(IntegrationMessageContextService); + const actionExecutor = Container.get(ChatIntegrationActionExecutor); + const queryExecutor = Container.get(ChatIntegrationContextQueryExecutor); + + for (const descriptor of getIntegrationToolConnectionDescriptors( + credentialIntegrations, + agentId, + (integrationConfig) => { + const integrationDef = integrationRegistry.get(integrationConfig.type); + return { + contextQueries: integrationDef?.contextQueries, + actions: integrationDef?.actions, + }; + }, + )) { + agent.tool( + createIntegrationContextTool({ descriptor, messageContextStore, queryExecutor }), + ); + agent.tool( + createIntegrationActionTool({ descriptor, messageContextStore, actionExecutor }), + ); + } + } + } + + if (nodeToolsEnabled) { + agent.tool(this.agentsToolsService.getRuntimeTools(credentialProvider, projectId)); + } + + if (runtimeProfile === 'top-level') { + this.attachSubAgentDelegationTool({ + agent, + parentAgentId: parentAgentIdForDelegation, + projectId, + credentialProvider, + userId, + delegation: subAgentDelegation, + }); + this.attachWriteTodosTool(agent, agentId); + } + + if (!agent.hasCheckpointStorage()) { + agent.checkpoint(this.n8nCheckpointStorage); + } + } + + private attachSubAgentDelegationTool(params: { + agent: RuntimeAgent; + parentAgentId: string; + projectId: string; + credentialProvider: CredentialProvider; + userId: string; + delegation: SubAgentDelegationConfig; + }): void { + const { agent, parentAgentId, projectId, credentialProvider, userId, delegation } = params; + agent.tool( + createN8nDelegateSubAgentTool({ + runner: Container.get(SubAgentForegroundRunner), + ...delegation, + projectId, + parentAgentId, + userId, + credentialProvider, + policy: this.buildSubAgentPolicy(), + }), + ); + this.logger.debug('Injected delegate_subagent tool', { agentId: parentAgentId }); + } + + private attachWriteTodosTool(agent: RuntimeAgent, agentId: string): void { + agent.tool(createWriteTodosTool()); + this.logger.debug('Injected write_todos tool', { agentId }); + } + + private buildSubAgentPolicy(): SubAgentRunPolicy { + return { + maxChildren: this.agentsConfig.subAgentMaxChildren, + timeoutMs: this.agentsConfig.subAgentTimeoutMs, + }; + } +} diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 61ee675f43d..19b7c6e933a 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -1,8 +1,7 @@ -import type { - Agent as RuntimeAgent, +import { + type Agent as RuntimeAgent, AgentExecutionCounter, BuiltAgent, - BuiltTool, CredentialProvider, StreamChunk, ToolDescriptor, @@ -13,33 +12,21 @@ import { AgentJsonConfigSchema, isNodeToolsEnabled, sanitizeAgentJsonConfig, - isSubAgentsEnabled, AgentModelSchema, type AgentIntegrationConfig, type AgentJsonConfig, - type AgentJsonMcpServerConfig, - type AgentJsonMemoryConfig, type AgentJsonToolConfig, type AgentSkill, type AgentSkillMutationResponse, 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'; import { AgentsConfig, GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; -import { - ExecutionRepository, - In, - ProjectRelationRepository, - User, - UserRepository, - WorkflowRepository, -} from '@n8n/db'; +import { In, ProjectRelationRepository, User } from '@n8n/db'; import { OnPubSubEvent } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; import type { EntityManager } from '@n8n/typeorm'; @@ -52,20 +39,14 @@ import { } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; -import { ActiveExecutions } from '@/active-executions'; import { CredentialsService } from '@/credentials/credentials.service'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { resolveBuiltinNodeDefinitionDirs } from '@/modules/instance-ai/node-definition-resolver'; -import { EphemeralNodeExecutor } from '@/node-execution'; -import { OauthService } from '@/oauth/oauth.service'; import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map'; import { Publisher } from '@/scaling/pubsub/publisher.service'; -import { UrlService } from '@/services/url.service'; import { Telemetry } from '@/telemetry'; import { TtlMap } from '@/utils/ttl-map'; -import { WorkflowRunner } from '@/workflow-runner'; -import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { AgentsCredentialProvider } from './adapters/agents-credential-provider'; import { markAgentDraftDirty } from './utils/agent-draft.utils'; @@ -74,65 +55,28 @@ import { executionsToMessagesDto } from './utils/execution-to-message-mapper'; import { generateAgentResourceId } from './utils/agent-resource-id'; import { AgentExecutionService } from './agent-execution.service'; import { AgentSkillsService } from './agent-skills.service'; -import { AgentsToolsService } from './agents-tools.service'; import { AGENT_THREAD_PREFIX } from './builder/builder-tool-names'; import { LLM_PROVIDER_DEFAULTS } from './builder/interactive/llm-provider-defaults'; import { Agent } from './entities/agent.entity'; import { AgentTask } from './entities/agent-task.entity'; import { ExecutionRecorder } from './execution-recorder'; import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; -import { ChatIntegrationActionExecutor } from './integrations/integration-action-executor'; -import { ChatIntegrationContextQueryExecutor } from './integrations/integration-context-query-executor'; -import { IntegrationMessageContextService } from './integrations/integration-message-context.service'; -import { - createIntegrationActionTool, - createIntegrationContextTool, - getIntegrationToolConnectionDescriptors, -} from './integrations/integration-tools'; import { syncAgentIntegrations } from './integrations/integrations-sync'; import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; import { N8nMemory } from './integrations/n8n-memory'; -import { createGetEnvironmentTool } from './tools/environment-tool'; -import { createRichInteractionTool } from './integrations/rich-interaction-tool'; import { composeJsonConfig, decomposeJsonConfig } from './json-config/agent-config-composition'; import { sanitizeUnknownAgentCredentials } from './json-config/sanitize-unknown-agent-credentials'; -import { - buildFromJson, - type MemoryFactory, - type ToolResolver, -} from './json-config/from-json-config'; -import { buildMcpClientForServer } from './json-config/mcp-client-factory'; +import { AgentRuntimeReconstructionService } from './agent-runtime-reconstruction.service'; import { AgentHistoryRepository } from './repositories/agent-history.repository'; import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository'; import { AgentTaskRepository } from './repositories/agent-task.repository'; import { AgentRepository } from './repositories/agent.repository'; -import { AgentSecureRuntime } from './runtime/agent-secure-runtime'; -import { buildToolRegistry, type ToolRegistry } from './tool-registry'; +import { 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']; -interface InjectRuntimeDependenciesParams { - agent: RuntimeAgent; - agentId: string; - 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}`; @@ -323,17 +267,7 @@ export class AgentsService { private readonly logger: Logger, private readonly agentRepository: AgentRepository, private readonly projectRelationRepository: ProjectRelationRepository, - private readonly workflowRunner: WorkflowRunner, - private readonly activeExecutions: ActiveExecutions, - private readonly executionRepository: ExecutionRepository, - private readonly workflowRepository: WorkflowRepository, - private readonly userRepository: UserRepository, - private readonly workflowFinderService: WorkflowFinderService, - private readonly urlService: UrlService, private readonly n8nCheckpointStorage: N8NCheckpointStorage, - private readonly secureRuntime: AgentSecureRuntime, - private readonly ephemeralNodeExecutor: EphemeralNodeExecutor, - private readonly agentsToolsService: AgentsToolsService, private readonly n8nMemory: N8nMemory, private readonly agentExecutionService: AgentExecutionService, private readonly agentHistoryRepository: AgentHistoryRepository, @@ -346,8 +280,7 @@ export class AgentsService { private readonly telemetry: Telemetry, private readonly chatIntegrationService: ChatIntegrationService, private readonly agentKnowledgeService: AgentKnowledgeService, - private readonly agentKnowledgeCommandService: AgentKnowledgeCommandService, - private readonly oauthService: OauthService, + private readonly agentRuntimeReconstructionService: AgentRuntimeReconstructionService, ) {} private isNodeToolsModuleEnabled(): boolean { @@ -407,14 +340,6 @@ export class AgentsService { }; } - private shouldAttachNodeTools(config: AgentJsonConfig['config']): boolean { - 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`. @@ -879,10 +804,6 @@ export class AgentsService { return executionsToMessagesDto(detail.executions); } - private getMemoryFactory(agentId: string): MemoryFactory { - return (_params: AgentJsonMemoryConfig) => this.n8nMemory.getImplementation(agentId); - } - /** Create a credential provider scoped to a project. */ private createCredentialProvider(projectId: string): AgentsCredentialProvider { return new AgentsCredentialProvider(Container.get(CredentialsService), projectId); @@ -935,204 +856,6 @@ export class AgentsService { return runtime; } - /** - * Returns a `resolveTool` callback for `Agent.fromSchema()` that converts - * non-editable tool schema entries into functional `BuiltTool` implementations. - * - * Detects the tool type via `metadata.workflowTool` / `metadata.nodeTool` and - * delegates to the appropriate factory. Returns `null` for unknown types so that - * `fromSchema` falls back to a passthrough marker. - */ - private makeToolResolver(projectId: string, userId?: string): ToolResolver { - return async (ref: AgentJsonToolConfig) => { - if (ref.type === 'workflow') { - if (!userId) { - throw new UserError('userId is required when agent uses workflow tools'); - } - const { resolveWorkflowTool } = await import('./tools/workflow-tool-factory'); - return await resolveWorkflowTool(ref, { - workflowRepository: this.workflowRepository, - workflowRunner: this.workflowRunner, - activeExecutions: this.activeExecutions, - executionRepository: this.executionRepository, - workflowFinderService: this.workflowFinderService, - userRepository: this.userRepository, - userId, - projectId, - webhookBaseUrl: this.urlService.getWebhookBaseUrl(), - }); - } - - if (ref.type === 'node') { - const { resolveNodeTool } = await import('./tools/node-tool-factory'); - return await resolveNodeTool(ref, { - executor: this.ephemeralNodeExecutor, - projectId, - }); - } - - return null; - }; - } - - /** - * Inject platform-level tools and storage into an agent instance. - * Workflow and node tools are resolved earlier via `makeToolResolver()` inside - * `fromSchema()`, so this method only handles host-side singletons. - * - * `nodeToolsEnabled` comes from the agent's `config.nodeTools.enabled` flag - * (opt-in, defaults to false) — see {@link shouldAttachNodeTools}. - */ - private async injectRuntimeDependencies(params: InjectRuntimeDependenciesParams): Promise { - const { - agent, - agentId, - projectId, - credentialProvider, - nodeToolsEnabled, - subAgentDelegation, - credentialIntegrations, - integrationType, - } = params; - - // Inject get_environment unconditionally. It surfaces info the model - // can't know on its own (current date, instance timezone, day of week) - // via a tool call rather than the system prompt — so values that change - // per request don't bust system-prompt prompt caching. - agent.tool(createGetEnvironmentTool()); - - // search_knowledge is gated behind the `knowledge-base` agents module. - // It's also an optional capability: if wiring it up fails (e.g. dynamic - // import or service construction error), degrade gracefully and keep the - // rest of the runtime usable rather than failing the whole agent. The - // failure is logged so it stays observable. - if (this.isKnowledgeBaseModuleEnabled()) { - try { - const { createSearchKnowledgeTool } = await import('./tools/knowledge/tool'); - agent.tool( - createSearchKnowledgeTool({ - agentId, - projectId, - knowledgeService: this.agentKnowledgeService, - commandService: this.agentKnowledgeCommandService, - }), - ); - } catch (toolError) { - this.logger.warn('Failed to inject search_knowledge tool', { - agentId, - error: toolError instanceof Error ? toolError.message : String(toolError), - }); - } - } - - // Inject the rich_interaction tool only for platforms that can actually - // render its suspend/resume HITL cards. Two gates: - // - A registered integration in ChatIntegrationRegistry. The in-app - // test chat uses `integrationType = 'chat'`, which isn't registered, - // and the compile/validate path passes no integrationType at all — - // neither has a bridge to render the card or resume the suspended - // turn, so letting the model call the tool there would hang the - // agent. - // - The integration must declare `supportedComponents`. Platforms - // that omit it (e.g. Linear) have explicitly opted out of - // rich_interaction. - const integrationRegistry = Container.get(ChatIntegrationRegistry); - const integration = integrationType ? integrationRegistry.get(integrationType) : undefined; - if (integration?.supportedComponents !== undefined) { - agent.tool(createRichInteractionTool(integrationType)); - } - - if (credentialIntegrations.length > 0) { - const messageContextStore = Container.get(IntegrationMessageContextService); - const actionExecutor = Container.get(ChatIntegrationActionExecutor); - const queryExecutor = Container.get(ChatIntegrationContextQueryExecutor); - - for (const descriptor of getIntegrationToolConnectionDescriptors( - credentialIntegrations, - agentId, - (integrationConfig) => { - const integrationDef = integrationRegistry.get(integrationConfig.type); - return { - contextQueries: integrationDef?.contextQueries, - actions: integrationDef?.actions, - }; - }, - )) { - agent.tool( - createIntegrationContextTool({ descriptor, messageContextStore, queryExecutor }), - ); - agent.tool( - createIntegrationActionTool({ descriptor, messageContextStore, actionExecutor }), - ); - } - } - - if (nodeToolsEnabled) { - 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); - } - } - - /** - * Attaches the built-in node tool chain (search_nodes, get_node_types, - * list_credentials, run_node_tool) so the agent can discover and execute - * n8n nodes on demand. Sourced from {@link AgentsToolsService}, which in - * turn delegates to `NodeCatalogService`. - */ - private attachNodeToolChain( - agent: RuntimeAgent, - credentialProvider: CredentialProvider, - projectId: string, - ): void { - 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 @@ -2388,90 +2111,12 @@ export class AgentsService { userId: string, integrationType?: string, ): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> { - const config = agentEntity.schema; - if (!config) { - throw new UserError('Agent has no JSON config.'); - } - - // Build toolsByName map: { toolName -> code } - const toolsByName: Record = {}; - for (const [_toolId, toolEntry] of Object.entries(agentEntity.tools ?? {})) { - toolsByName[toolEntry.descriptor.name] = toolEntry.code; - } - - // Build toolDescriptors map: { toolId -> descriptor } - const toolDescriptors: Record = {}; - for (const [toolId, toolEntry] of Object.entries(agentEntity.tools ?? {})) { - toolDescriptors[toolId] = toolEntry.descriptor; - } - - const toolExecutor = this.secureRuntime.createToolExecutor(toolsByName); - - const toolResolver = this.makeToolResolver(agentEntity.projectId, userId); - - const resolvedTools: BuiltTool[] = []; - - const buildMcpClient = async (server: AgentJsonMcpServerConfig) => - await buildMcpClientForServer(server, { - credentialProvider, - oauthService: this.oauthService, - projectId: agentEntity.projectId, - }); - - const reconstructed = await buildFromJson(config, toolDescriptors, { - toolExecutor, + return await this.agentRuntimeReconstructionService.reconstructFromAgentEntity( + agentEntity, credentialProvider, - resolveTool: async (ref) => { - const resolved = await toolResolver(ref); - if (resolved) resolvedTools.push(resolved); - return resolved; - }, - skills: agentEntity.skills ?? {}, - memoryFactory: this.getMemoryFactory(agentEntity.id), - 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 ?? [], + userId, integrationType, - }); - - 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; + ); } } @@ -2484,5 +2129,6 @@ function normalizeSubAgentsConfig( subAgents: AgentJsonConfig['subAgents'], ): AgentJsonConfig['subAgents'] { if (!subAgents) return undefined; - return { agents: subAgents.agents ?? [] }; + const agents = subAgents.agents ?? []; + return { 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 cc87b6370c4..07c3afb559d 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 @@ -143,14 +143,9 @@ describe('builder model recommendations', () => { 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', () => { 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 291d2cd6f33..9e28a7216a1 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -140,31 +140,35 @@ 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": "" }] }\`. +The target agent can always delegate bounded subtasks through \`delegate_subagent\`. -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. +The target agent can call \`delegate_subagent\` with +\`subAgentId: "inline"\` without any saved-agent refs. Inline subagents are +ad-hoc child agents for one-off focused tasks. -- Configure subagents only when the user asks for subagents, delegation, helper - agents, independent review, or research-style task decomposition. +\`subAgents.agents\` is only for optional saved/published n8n Agent specialists +that the target agent may select by id when they are a better fit than an inline +subagent. + +- Do not write a flag to enable or disable delegation; delegation is always + available. +- Add saved subagent refs only when the user asks to use specific published + agents, reusable specialists, named helper agents, or saved-agent delegation. - 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 +- If no published agents are available, do not configure saved subagents. Inline + delegation still works without saved-agent refs. +- Patch selected saved 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.`; +- Preserve existing \`subAgents.agents\` refs unless the user explicitly asks to + change saved subagents.`; export const READ_CONFIG_FRESHNESS_SECTION = `\ ## Config Freshness 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 aeafc349fa5..0b760cb0f04 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 @@ -78,7 +78,6 @@ Use \`patch_config\` with: 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. 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 3ad2e22aa8c..224ea46e46d 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 @@ -62,11 +62,13 @@ 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\`. +- Subagent delegation lives at top level under \`subAgents\`. + Delegation is always available; do not write an enabled/disabled flag. + \`subAgents.agents\` is only for optional saved/published n8n Agent specialists; + inline delegation uses \`subAgentId: "inline"\` at tool-call time and does not + require saved-agent refs. The runtime also exposes \`write_todos\` for planning + complex multi-step work before separate \`delegate_subagent\` calls. Only use + \`agentId\` values returned by \`list_sub_agents\`. - 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 diff --git a/packages/cli/src/modules/agents/json-config/from-json-config.ts b/packages/cli/src/modules/agents/json-config/from-json-config.ts index a471a87cc35..43d2741b7c9 100644 --- a/packages/cli/src/modules/agents/json-config/from-json-config.ts +++ b/packages/cli/src/modules/agents/json-config/from-json-config.ts @@ -313,10 +313,6 @@ async function applyMemoryFromConfig( const builtMemory = memoryFactory(memoryConfig); memory.storage(await Promise.resolve(builtMemory)); - if (memoryConfig.semanticRecall) { - memory.semanticRecall(memoryConfig.semanticRecall); - } - if (memoryConfig.episodicMemory?.enabled === true) { memory.episodicMemory( await resolveEpisodicMemoryJsonConfig(memoryConfig.episodicMemory, credentialProvider), diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts index e4db8c913fa..854a120e316 100644 --- a/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts @@ -1,8 +1,12 @@ -import type { CredentialProvider, GenerateResult } from '@n8n/agents'; +import { + getInlineDelegateSubAgentToolOptions, + INLINE_SUB_AGENT_ID, + type CredentialProvider, + type GenerateResult, +} from '@n8n/agents'; import type { SubAgentSource } from '@n8n/api-types'; import { mock } from 'jest-mock-extended'; -import type { ToolExecutor } from '../../json-config/from-json-config'; import { createN8nDelegateSubAgentTool, formatSubAgentToolOutput, @@ -13,6 +17,7 @@ import type { } from '../sub-agent-foreground-runner'; const projectId = 'project-1'; +const userId = 'user-1'; const source: SubAgentSource = { agentId: 'agent-2', @@ -49,18 +54,12 @@ const foregroundResult: SubAgentForegroundResult = { describe('createN8nDelegateSubAgentTool', () => { 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 () => { @@ -68,15 +67,15 @@ describe('createN8nDelegateSubAgentTool', () => { runner, sourcesById: { 'agent-2': source }, projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, policy: { maxChildren: 2, timeoutMs: 1000 }, }); await expect( tool.handler?.( { + subAgentId: 'agent-2', taskName: 'Research API', goal: 'Find the API behavior.', context: 'Focus on auth endpoints.', @@ -107,9 +106,8 @@ describe('createN8nDelegateSubAgentTool', () => { }, expect.objectContaining({ projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, }), ); }); @@ -119,13 +117,12 @@ describe('createN8nDelegateSubAgentTool', () => { runner, sourcesById: { 'agent-2': source }, projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, }); await tool.handler?.( - { taskName: 'Research API', goal: 'Find behavior.' }, + { subAgentId: 'agent-2', taskName: 'Research API', goal: 'Find behavior.' }, { runId: 'parent-run-1', persistence: { threadId: 'parent-thread-1', resourceId: 'resource-1' }, @@ -150,9 +147,8 @@ describe('createN8nDelegateSubAgentTool', () => { }, availableSubAgents: [{ id: 'agent-2', name: 'Research Agent' }], projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, }); await tool.handler?.( @@ -174,14 +170,13 @@ describe('createN8nDelegateSubAgentTool', () => { runner, sourcesById: { 'agent-2': source }, projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, }); await expect( tool.handler?.( - { taskName: 'Research API', goal: 'Find behavior.' }, + { subAgentId: 'agent-2', taskName: 'Research API', goal: 'Find behavior.' }, { runId: 'parent-run-1' }, ), ).resolves.toMatchObject({ @@ -191,6 +186,73 @@ describe('createN8nDelegateSubAgentTool', () => { error: 'child failed', }); }); + + it('routes inline subAgentId through runInlineSubAgent helpers instead of the foreground runner', async () => { + const runInlineSubAgent = jest.fn().mockResolvedValue({ + status: 'completed', + taskPath: '/root/research_api_0', + runId: 'inline-run-1', + answer: 'Inline answer', + }); + const tool = createN8nDelegateSubAgentTool({ + runner, + sourcesById: { 'agent-2': source }, + projectId, + userId, + credentialProvider, + }); + const runSubAgent = getInlineDelegateSubAgentToolOptions(tool)?.runSubAgent; + expect(runSubAgent).toBeDefined(); + + await expect( + runSubAgent?.( + { + subAgentId: INLINE_SUB_AGENT_ID, + taskName: 'Research API', + goal: 'Find behavior.', + taskPath: '/root/research_api_0', + childCount: 0, + }, + { runInlineSubAgent }, + ), + ).resolves.toMatchObject({ + status: 'completed', + taskPath: '/root/research_api_0', + answer: 'Inline answer', + }); + + expect(runInlineSubAgent).toHaveBeenCalledWith( + expect.objectContaining({ + subAgentId: INLINE_SUB_AGENT_ID, + goal: 'Find behavior.', + }), + ); + expect(runner.runForeground).not.toHaveBeenCalled(); + }); + + it('requires Agent inline helpers when inline is invoked through the tool handler directly', async () => { + const tool = createN8nDelegateSubAgentTool({ + runner, + sourcesById: { 'agent-2': source }, + projectId, + userId, + credentialProvider, + }); + + await expect( + tool.handler?.( + { subAgentId: INLINE_SUB_AGENT_ID, taskName: 'Research API', goal: 'Find behavior.' }, + { runId: 'parent-run-1' }, + ), + ).resolves.toMatchObject({ + status: 'failed', + taskPath: '/root/research_api_0', + answer: '', + error: + 'delegate_subagent host runner does not support inline delegation without helpers.runInlineSubAgent from an Agent build.', + }); + expect(runner.runForeground).not.toHaveBeenCalled(); + }); }); describe('formatSubAgentToolOutput', () => { 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 index 720a929d21f..dd43330c5c8 100644 --- 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 @@ -1,9 +1,9 @@ -import type { - BuiltAgent, - CredentialProvider, - StreamChunk, - StreamResult, - ToolDescriptor, +import { + DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + type BuiltAgent, + type CredentialProvider, + type StreamChunk, + type StreamResult, } from '@n8n/agents'; import type { Logger } from '@n8n/backend-common'; import type { @@ -11,21 +11,19 @@ import type { RunnableAgentJsonConfig, SubAgentSpawnRequest, } from '@n8n/api-types'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; +import { AgentRuntimeReconstructionService } from '../../agent-runtime-reconstruction.service'; 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 userId = 'user-1'; const parentThreadId = 'parent-thread-1'; const parentAgentId = 'parent-agent-1'; @@ -41,26 +39,24 @@ const source: ResolvedSubAgentSource = { 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, + tool_1: { + 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, + }, }, toolCodeByName: { lookup_customer: 'return input;', @@ -98,19 +94,20 @@ const defaultStreamChunks: StreamChunk[] = [ describe('SubAgentForegroundRunner', () => { let sourceResolver: jest.Mocked; + let reconstructionService: 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(); + Container.reset(); sourceResolver = mock(); sourceResolver.resolveForRuntime.mockResolvedValue(runtimeSource); + reconstructionService = mock(); + Container.set(AgentRuntimeReconstructionService, reconstructionService); agentExecutionService = mock(); logger = mock(); runner = new SubAgentForegroundRunner(sourceResolver, agentExecutionService, logger); @@ -118,20 +115,29 @@ describe('SubAgentForegroundRunner', () => { childAgent = mock(); childAgent.stream.mockResolvedValue(makeStreamResult(defaultStreamChunks)); childAgent.close.mockResolvedValue(undefined); - jest.mocked(buildFromJson).mockResolvedValue(childAgent as never); + reconstructionService.reconstructFromResolvedSource.mockResolvedValue({ + agent: childAgent as never, + toolRegistry: new Map(), + }); 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 () => { + it('resolves reconstruction from the container at run time', async () => { + await runner.runForeground(spawnRequest, { + projectId, + userId, + credentialProvider, + }); + + expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenCalledTimes(1); + }); + + it('rebuilds the child through the shared reconstruction service and runs it with a fresh prompt', async () => { const result = await runner.runForeground(spawnRequest, { projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, }); expect(result).toMatchObject({ @@ -144,23 +150,21 @@ describe('SubAgentForegroundRunner', () => { usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cost: 0.01 }, }), }); - expect(createToolExecutor).toHaveBeenCalledWith(runtimeSource.toolCodeByName); + expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenCalledWith({ + config: runnableConfig, + memoryOwnerAgentId: 'agent-1', + projectId, + credentialProvider, + toolDescriptors: runtimeSource.toolDescriptors, + toolCodeByName: runtimeSource.toolCodeByName, + skills: runtimeSource.skills, + userId, + runtimeProfile: 'sub-agent', + parentAgentIdForDelegation: undefined, + }); 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.stringContaining('YOUR TASK:\nFind the relevant API behavior.'), expect.objectContaining({ persistence: { resourceId: result.threadId, @@ -169,10 +173,8 @@ describe('SubAgentForegroundRunner', () => { }), ); 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(childPrompt).toContain('CONTEXT:\nFocus on auth endpoints.'); + expect(childPrompt).toContain('EXPECTED OUTPUT:\nA concise summary.'); expect(agentExecutionService.recordMessage).toHaveBeenCalledWith( expect.objectContaining({ threadId: result.threadId, @@ -182,42 +184,13 @@ describe('SubAgentForegroundRunner', () => { ); }); - 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, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, }, ); @@ -233,7 +206,7 @@ describe('SubAgentForegroundRunner', () => { 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 () => { + it('uses the saved n8n agent id as memory owner and records parent linkage', async () => { sourceResolver.resolveForRuntime.mockResolvedValue({ ...runtimeSource, source: { @@ -245,10 +218,6 @@ describe('SubAgentForegroundRunner', () => { }, }, }); - jest.mocked(buildFromJson).mockImplementation(async (_config, _toolDescriptors, options) => { - await options.memoryFactory({ enabled: true, storage: 'n8n' }); - return childAgent as never; - }); const result = await runner.runForeground( { @@ -257,15 +226,20 @@ describe('SubAgentForegroundRunner', () => { }, { projectId, + userId, 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(reconstructionService.reconstructFromResolvedSource).toHaveBeenCalledWith( + expect.objectContaining({ + memoryOwnerAgentId: 'agent-2', + userId, + runtimeProfile: 'sub-agent', + parentAgentIdForDelegation: parentAgentId, + }), + ); expect(childAgent.stream).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -277,13 +251,11 @@ describe('SubAgentForegroundRunner', () => { ); 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, @@ -292,6 +264,37 @@ describe('SubAgentForegroundRunner', () => { ); }); + it('marks the run as failed when the child stream emits a suspension', async () => { + childAgent.stream.mockResolvedValue( + makeStreamResult([ + { type: 'text-delta', id: 'text-1', delta: 'Choose an option' }, + { + type: 'tool-call-suspended', + runId: 'child-run-1', + toolCallId: 'tool-call-1', + toolName: 'rich_interaction', + }, + { type: 'finish', finishReason: 'tool-calls' }, + ]), + ); + + await expect( + runner.runForeground(spawnRequest, { + projectId, + userId, + credentialProvider, + }), + ).resolves.toMatchObject({ + status: 'failed', + result: { + runId: 'child-run-1', + finishReason: 'error', + error: DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + }, + }); + expect(childAgent.close).toHaveBeenCalledTimes(1); + }); + it('marks the run as failed when the child result contains an error', async () => { childAgent.stream.mockResolvedValue( makeStreamResult([ @@ -303,9 +306,8 @@ describe('SubAgentForegroundRunner', () => { await expect( runner.runForeground(spawnRequest, { projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, }), ).resolves.toMatchObject({ status: 'failed', @@ -321,9 +323,8 @@ describe('SubAgentForegroundRunner', () => { }, { projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, }, ); @@ -354,9 +355,8 @@ describe('SubAgentForegroundRunner', () => { const run = runner.runForeground(spawnRequest, { projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, abortSignal: parentAbort.signal, }); @@ -393,9 +393,8 @@ describe('SubAgentForegroundRunner', () => { }, { projectId, + userId, credentialProvider, - createToolExecutor, - createMemoryFactory, }, ); 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 index fcb9b14400a..14a2623c700 100644 --- 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 @@ -1,6 +1,7 @@ import { createDelegateSubAgentTool, generateResultToDelegateSubAgentOutput, + INLINE_SUB_AGENT_ID, type DelegateSubAgentToolOutput, } from '@n8n/agents'; import type { SubAgentRunPolicy, SubAgentSource } from '@n8n/api-types'; @@ -24,7 +25,11 @@ export function createN8nDelegateSubAgentTool(options: CreateN8nDelegateSubAgent return createDelegateSubAgentTool({ ...(availableSubAgents !== undefined ? { availableSubAgents } : {}), ...(policy !== undefined ? { policy } : {}), - runSubAgent: async (request) => { + runSubAgent: async (request, helpers) => { + if (request.subAgentId === INLINE_SUB_AGENT_ID) { + return await helpers.runInlineSubAgent(request); + } + const selectedSource = selectSubAgentSource({ sourcesById, subAgentId: request.subAgentId, @@ -32,8 +37,9 @@ export function createN8nDelegateSubAgentTool(options: CreateN8nDelegateSubAgent if (!selectedSource) { return { status: 'failed', - answer: - 'No subagent matched this request. Provide subAgentId when multiple configured subagents are available.', + taskPath: request.taskPath, + answer: '', + error: `No configured subagent matched "${request.subAgentId}". Use "inline" for an inline sub-agent, or pass one of the configured subagent IDs.`, }; } @@ -70,13 +76,11 @@ export function createN8nDelegateSubAgentTool(options: CreateN8nDelegateSubAgent function selectSubAgentSource(options: { sourcesById: Record; - subAgentId?: string; + 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; + if (subAgentId === INLINE_SUB_AGENT_ID) return undefined; + return sourcesById?.[subAgentId]; } export function formatSubAgentToolOutput( 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 index a6803ed89fb..111b47d4f70 100644 --- 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 @@ -1,5 +1,6 @@ import { assertSubAgentTaskPath, + DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, renderDelegateSubAgentPrompt, type AgentExecutionCounter, type AgentMessage, @@ -9,29 +10,22 @@ import { } from '@n8n/agents'; import { Logger } from '@n8n/backend-common'; import type { ResolvedSubAgentSource, SubAgentSpawnRequest } from '@n8n/api-types'; -import { Service } from '@n8n/di'; +import { Container, 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; + /** n8n user ID — required for workflow/node tool resolution during reconstruction. */ + userId: 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; @@ -65,8 +59,8 @@ export class SubAgentForegroundRunner { } // 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. + // enforced the depth/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); @@ -74,7 +68,6 @@ export class SubAgentForegroundRunner { 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 @@ -86,13 +79,19 @@ export class SubAgentForegroundRunner { // 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, + + const reconstructionService = await getReconstructionService(); + const { agent } = await reconstructionService.reconstructFromResolvedSource({ + config: runtimeSource.source.config, + memoryOwnerAgentId: runtimeSource.source.sourceId, + projectId: context.projectId, credentialProvider: context.credentialProvider, - resolveTool: context.resolveTool, + toolDescriptors: runtimeSource.toolDescriptors, + toolCodeByName: runtimeSource.toolCodeByName, skills: runtimeSource.skills, - memoryFactory: createSubAgentMemoryFactory(runtimeSource.source, context), + userId: context.userId, + runtimeProfile: 'sub-agent', + parentAgentIdForDelegation: context.parentAgentId, }); const timeoutController = request.policy?.timeoutMs ? new AbortController() : undefined; @@ -114,6 +113,7 @@ export class SubAgentForegroundRunner { }); const recorder = new ExecutionRecorder(); let structuredOutput: unknown; + let childSuspended = false; const reader = resultStream.stream.getReader(); try { @@ -121,6 +121,9 @@ export class SubAgentForegroundRunner { const { done, value } = await reader.read(); if (done) break; recorder.record(value); + if (value.type === 'tool-call-suspended') { + childSuspended = true; + } if (value.type === 'finish' && value.structuredOutput !== undefined) { structuredOutput = value.structuredOutput; } @@ -140,6 +143,20 @@ export class SubAgentForegroundRunner { prompt, record: messageRecord, }); + if (childSuspended) { + return { + taskPath, + threadId, + status: 'failed', + result: { + runId: resultStream.runId, + messages: [], + finishReason: 'error', + error: DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + }, + }; + } + const result = buildGenerateResultFromRecord( resultStream.runId, messageRecord, @@ -213,6 +230,19 @@ export class SubAgentForegroundRunner { } } +/** + * Lazy resolution avoids a circular DI dependency: AgentRuntimeReconstructionService + * injects SubAgentForegroundRunner into the delegate tool, while this runner needs + * reconstruction only when a configured sub-agent run starts. + */ +async function getReconstructionService() { + // eslint-disable-next-line import-x/no-cycle + const { AgentRuntimeReconstructionService } = await import( + '../agent-runtime-reconstruction.service' + ); + return Container.get(AgentRuntimeReconstructionService); +} + function buildGenerateResultFromRecord( runId: string, record: MessageRecord, @@ -267,15 +297,6 @@ function toKnownFinishReason( 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, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 2e0cb2aab87..5fc9c0d6e2b 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -6125,6 +6125,17 @@ "agents.chat.toolNames.searchKnowledge": "Search knowledge", "agents.chat.delegate.label": "Sub-agent · {name}", "agents.chat.delegate.labelFallback": "Sub-agent", + "agents.chat.delegate.childSuspendUnsupported": "Sub-agent requested user input, which is not supported for delegated runs yet.", + "agents.chat.writeTodos.label": "Task list", + "agents.chat.writeTodos.summary.one": "{count} task", + "agents.chat.writeTodos.summary.other": "{count} tasks", + "agents.chat.writeTodos.status.inProgress": "In progress", + "agents.chat.writeTodos.status.pending": "Pending", + "agents.chat.writeTodos.status.completed": "Completed", + "agents.chat.writeTodos.status.blocked": "Blocked", + "agents.chat.writeTodos.status.cancelled": "Cancelled", + "agents.chat.writeTodos.hint.subAgent": "Sub-agent", + "agents.chat.writeTodos.hint.expectedOutput": "Expected output", "agents.chat.toolStep.waitingForInput": "Waiting for your input", "agents.chat.askQuestion.otherLabel": "Other", "agents.chat.askQuestion.otherPlaceholder": "Type another answer", @@ -6301,7 +6312,7 @@ "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.description": "This agent can delegate focused subtasks. Add published agents from this project when you want reusable specialists.", "agents.builder.subAgents.add": "Add agent", "agents.builder.subAgents.loadError": "Could not load project agents", "agents.builder.subAgents.remove": "Remove {name}", @@ -6316,9 +6327,6 @@ "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", "agents.builder.episodicMemoryCredentialModal.description": "An OpenAI credential is used to create embeddings for Episodic Memory.", - "agents.builder.memory.semanticRecall.topK": "Top K", - "agents.builder.memory.semanticRecall.rangeBefore": "Range before", - "agents.builder.memory.semanticRecall.rangeAfter": "Range after", "agents.builder.editor.copy": "Copy to clipboard", "agents.builder.editor.copied": "Copied", "agents.builder.progress.building.title": "Building your agent...", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts new file mode 100644 index 00000000000..387b29ebe1f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts @@ -0,0 +1,115 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import AgentChatToolSteps from '../components/AgentChatToolSteps.vue'; +import type { ToolCall } from '../composables/agentChatMessages'; +import { TOOL_CALL_STATE } from '../constants'; +import { DELEGATE_SUB_AGENT_TOOL_NAME } from '../utils/delegate-tool'; +import { WRITE_TODOS_TOOL_NAME } from '../utils/write-todos-tool'; + +vi.mock('@n8n/design-system', () => ({ + N8nIcon: { + template: '', + props: ['icon', 'size', 'spin'], + }, + N8nMarkdownEditor: { + template: '
{{ modelValue }}
', + props: ['modelValue', 'readonly', 'variant', 'showToolbar', 'maxHeight'], + }, + N8nTooltip: { template: '
', props: ['content', 'placement'] }, +})); + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ + baseText: (key: string, opts?: { interpolate?: { name?: string; count?: string } }) => { + if (key === 'agents.chat.delegate.label' && opts?.interpolate?.name) { + return `Sub-agent · ${opts.interpolate.name}`; + } + if (key === 'agents.chat.writeTodos.label') return 'Task list'; + if (key === 'agents.chat.writeTodos.summary.one' && opts?.interpolate?.count) { + return `${opts.interpolate.count} task`; + } + if (key === 'agents.chat.writeTodos.summary.other' && opts?.interpolate?.count) { + return `${opts.interpolate.count} tasks`; + } + const statusLabels: Record = { + 'agents.chat.writeTodos.status.inProgress': 'In progress', + 'agents.chat.writeTodos.status.pending': 'Pending', + 'agents.chat.writeTodos.status.completed': 'Completed', + 'agents.chat.writeTodos.status.blocked': 'Blocked', + 'agents.chat.writeTodos.status.cancelled': 'Cancelled', + 'agents.chat.writeTodos.hint.subAgent': 'Sub-agent', + 'agents.chat.writeTodos.hint.expectedOutput': 'Expected output', + }; + return statusLabels[key] ?? key; + }, + }), +})); + +vi.mock('../composables/useSubAgentNames', () => ({ + useSubAgentNames: () => ({ subAgentNameById: { value: new Map() } }), +})); + +function mountSteps(toolCalls: ToolCall[]) { + return mount(AgentChatToolSteps, { + props: { toolCalls, projectId: 'project-1' }, + }); +} + +describe('AgentChatToolSteps', () => { + it('does not make generic tool steps expandable', () => { + const wrapper = mountSteps([ + { + tool: 'search_nodes', + toolCallId: 'tc-1', + state: TOOL_CALL_STATE.DONE, + output: { nodes: ['Slack'] }, + }, + ]); + + expect(wrapper.text()).toContain('Search nodes'); + expect(wrapper.find('[data-testid="tool-step-summary"]').exists()).toBe(false); + expect(wrapper.find('button').exists()).toBe(false); + expect(wrapper.find('[data-test-id="tool-step-details"]').exists()).toBe(false); + }); + + it('expands write_todos output with label and plural summary', async () => { + const wrapper = mountSteps([ + { + tool: WRITE_TODOS_TOOL_NAME, + toolCallId: 'tc-todos', + state: TOOL_CALL_STATE.DONE, + output: { + status: 'ok', + todoCount: 2, + todos: [ + { id: 'a', content: 'Research APIs', status: 'in_progress' }, + { id: 'b', content: 'Write summary', status: 'pending' }, + ], + }, + }, + ]); + + expect(wrapper.text()).toContain('Task list'); + expect(wrapper.find('[data-testid="tool-step-summary"]').text()).toContain('2 tasks'); + + await wrapper.find('button').trigger('click'); + expect(wrapper.find('[data-test-id="tool-step-details"]').text()).toContain('Research APIs'); + }); + + it('keeps delegate_subagent expandable behavior', async () => { + const wrapper = mountSteps([ + { + tool: DELEGATE_SUB_AGENT_TOOL_NAME, + toolCallId: 'tc-delegate', + state: TOOL_CALL_STATE.DONE, + input: { subAgentId: 'inline', taskName: 'research_api' }, + output: { status: 'completed', answer: 'Child answer' }, + }, + ]); + + expect(wrapper.text()).toContain('Sub-agent · Research api'); + + await wrapper.find('button').trigger('click'); + expect(wrapper.find('[data-test-id="tool-step-details"]').text()).toBe('Child answer'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts index 7aca54006e3..2548d86be74 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts @@ -233,7 +233,7 @@ describe('convertDbMessages — interactive turn synthesis', () => { type: 'tool-call', toolName: 'delegate_subagent', toolCallId: 'tc-d', - input: { taskName: 'research' }, + input: { subAgentId: 'inline', taskName: 'research' }, state: 'resolved', output: { status: 'failed', answer: '', error: 'child failed' }, }, @@ -257,7 +257,7 @@ describe('convertDbMessages — interactive turn synthesis', () => { type: 'tool-call', toolName: 'delegate_subagent', toolCallId: 'tc-d2', - input: {}, + input: { subAgentId: 'inline' }, state: 'resolved', output: { status: 'completed', answer: 'all good' }, }, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/delegate-tool.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/delegate-tool.spec.ts index 4c374b6b284..b910a213324 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/delegate-tool.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/delegate-tool.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { DELEGATE_SUB_AGENT_TOOL_NAME, + INLINE_SUB_AGENT_ID, delegateLabel, humanizeTaskName, isDelegateSubAgentTool, @@ -37,6 +38,10 @@ describe('delegate-tool', () => { expect(parseDelegateInput(undefined)).toBeUndefined(); expect(parseDelegateInput(null)).toBeUndefined(); }); + + it('requires a subAgentId', () => { + expect(parseDelegateInput({ taskName: 'compare-pricing' })).toBeUndefined(); + }); }); describe('parseDelegateOutput', () => { @@ -111,10 +116,13 @@ describe('delegate-tool', () => { ).toBe('Research api'); }); - it('falls back to the humanized task name when no id is given', () => { - expect(resolveSubAgentName({ taskName: 'compare-pricing' }, new Map())).toBe( - 'Compare pricing', - ); + it('falls back to the humanized task name for inline subagents', () => { + expect( + resolveSubAgentName( + { subAgentId: INLINE_SUB_AGENT_ID, taskName: 'compare-pricing' }, + new Map(), + ), + ).toBe('Compare pricing'); }); it('ignores a blank resolved name and uses the task name', () => { @@ -125,7 +133,7 @@ describe('delegate-tool', () => { }); it('returns empty string when neither id nor task name resolve', () => { - expect(resolveSubAgentName({}, new Map())).toBe(''); + expect(resolveSubAgentName({ subAgentId: INLINE_SUB_AGENT_ID }, new Map())).toBe(''); expect(resolveSubAgentName('not-an-object', new Map())).toBe(''); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/interactive-summary.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/interactive-summary.test.ts index 24d34b26efe..627392507be 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/interactive-summary.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/interactive-summary.test.ts @@ -4,7 +4,8 @@ import { ASK_LLM_TOOL_NAME, ASK_QUESTION_TOOL_NAME, } from '@n8n/api-types'; -import { summariseInteractiveOutput } from '../utils/interactive-summary'; +import { summariseInteractiveOutput, summariseToolCall } from '../utils/interactive-summary'; +import { WRITE_TODOS_TOOL_NAME } from '../utils/write-todos-tool'; describe('summariseInteractiveOutput', () => { it('returns undefined for non-interactive tool names', () => { @@ -66,3 +67,15 @@ describe('summariseInteractiveOutput', () => { ).toBe('anthropic/claude-sonnet-4-6 · My Anthropic'); }); }); + +describe('summariseToolCall', () => { + it('does not summarise write_todos; AgentChatToolSteps owns the i18n summary', () => { + expect( + summariseToolCall(WRITE_TODOS_TOOL_NAME, { + status: 'ok', + todoCount: 2, + todos: [], + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/tool-call-details.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/tool-call-details.spec.ts new file mode 100644 index 00000000000..f7477e14554 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/tool-call-details.spec.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; +import { ASK_CREDENTIAL_TOOL_NAME, ASK_QUESTION_TOOL_NAME } from '@n8n/api-types'; +import { TOOL_CALL_STATE } from '../constants'; +import { + DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + DELEGATE_SUB_AGENT_TOOL_NAME, +} from '../utils/delegate-tool'; +import { getToolCallDetails, isToolCallExpandable } from '../utils/tool-call-details'; +import { WRITE_TODOS_TOOL_NAME, type WriteTodosI18n } from '../utils/write-todos-tool'; + +const writeTodosI18n: WriteTodosI18n = { + baseText: (key: string) => { + const labels: Record = { + 'agents.chat.writeTodos.status.inProgress': 'In progress', + 'agents.chat.writeTodos.status.pending': 'Pending', + 'agents.chat.writeTodos.status.completed': 'Completed', + 'agents.chat.writeTodos.status.blocked': 'Blocked', + 'agents.chat.writeTodos.status.cancelled': 'Cancelled', + 'agents.chat.writeTodos.hint.subAgent': 'Sub-agent', + 'agents.chat.writeTodos.hint.expectedOutput': 'Expected output', + }; + return labels[key] ?? key; + }, +} as WriteTodosI18n; + +describe('tool-call-details', () => { + describe('getToolCallDetails', () => { + it('returns undefined for running tool calls', () => { + expect( + getToolCallDetails({ + tool: 'search_nodes', + output: { nodes: ['Slack'] }, + state: TOOL_CALL_STATE.RUNNING, + }), + ).toBeUndefined(); + }); + + it('does not expose generic string output', () => { + expect( + getToolCallDetails({ + tool: 'search_nodes', + output: 'Found 3 nodes', + state: TOOL_CALL_STATE.DONE, + }), + ).toBeUndefined(); + }); + + it('does not expose generic object output as JSON', () => { + expect( + getToolCallDetails({ + tool: 'search_nodes', + output: { nodes: ['Slack'] }, + state: TOOL_CALL_STATE.DONE, + }), + ).toBeUndefined(); + }); + + it('does not expose generic error strings', () => { + expect( + getToolCallDetails({ + tool: 'search_nodes', + output: 'Credential missing', + state: TOOL_CALL_STATE.ERROR, + }), + ).toBeUndefined(); + }); + + it('does not expose resolved interactive tool resume payloads', () => { + expect( + getToolCallDetails({ + tool: ASK_QUESTION_TOOL_NAME, + output: { values: ['slack'] }, + state: TOOL_CALL_STATE.DONE, + }), + ).toBeUndefined(); + expect( + getToolCallDetails({ + tool: ASK_CREDENTIAL_TOOL_NAME, + output: { credentialId: 'c1', credentialName: 'My Slack' }, + state: TOOL_CALL_STATE.DONE, + }), + ).toBeUndefined(); + }); + + it('shows delegate answers for completed delegations', () => { + expect( + getToolCallDetails({ + tool: DELEGATE_SUB_AGENT_TOOL_NAME, + output: { status: 'completed', answer: 'Child result' }, + state: TOOL_CALL_STATE.DONE, + }), + ).toBe('Child result'); + }); + + it('shows delegate errors for failed delegations', () => { + expect( + getToolCallDetails({ + tool: DELEGATE_SUB_AGENT_TOOL_NAME, + output: { status: 'failed', answer: '', error: 'child failed' }, + state: TOOL_CALL_STATE.ERROR, + }), + ).toBe('child failed'); + }); + + it('localizes known delegate error i18n keys when i18n is provided', () => { + const i18n: WriteTodosI18n = { + baseText: (key: string) => { + if (key === DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE) { + return 'Sub-agent requested user input, which is not supported for delegated runs yet.'; + } + return key; + }, + }; + expect( + getToolCallDetails( + { + tool: DELEGATE_SUB_AGENT_TOOL_NAME, + output: { + status: 'failed', + answer: '', + error: DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + }, + state: TOOL_CALL_STATE.ERROR, + }, + i18n, + ), + ).toBe('Sub-agent requested user input, which is not supported for delegated runs yet.'); + }); + + it('passes sub-agent name map through for write_todos delegate hints', () => { + const nameById = new Map([['agent-2', 'Helper agent']]); + const details = getToolCallDetails( + { + tool: WRITE_TODOS_TOOL_NAME, + output: { + status: 'ok', + todoCount: 1, + todos: [ + { + id: 'a', + content: 'Delegated work', + status: 'pending', + delegateHint: { subAgentId: 'agent-2' }, + }, + ], + }, + state: TOOL_CALL_STATE.DONE, + }, + writeTodosI18n, + nameById, + ); + expect(details).toContain('_(Sub-agent: Helper agent)_'); + }); + + it('returns undefined for write_todos without i18n', () => { + expect( + getToolCallDetails({ + tool: WRITE_TODOS_TOOL_NAME, + output: { + status: 'ok', + todoCount: 1, + todos: [{ id: 'a', content: 'Task', status: 'pending' }], + }, + state: TOOL_CALL_STATE.DONE, + }), + ).toBeUndefined(); + }); + + it('shows write_todos failed output errors without i18n', () => { + expect( + getToolCallDetails({ + tool: WRITE_TODOS_TOOL_NAME, + output: { status: 'failed', error: 'Duplicate todo id "a"' }, + state: TOOL_CALL_STATE.ERROR, + }), + ).toBe('Duplicate todo id "a"'); + }); + + it('shows rejected write_todos tool error strings', () => { + expect( + getToolCallDetails({ + tool: WRITE_TODOS_TOOL_NAME, + output: 'Each task must have a unique id', + state: TOOL_CALL_STATE.ERROR, + }), + ).toBe('Each task must have a unique id'); + }); + }); + + describe('isToolCallExpandable', () => { + it('is false for generic tools even when output is present', () => { + expect( + isToolCallExpandable({ + tool: 'search_nodes', + output: { nodes: ['Slack'] }, + state: TOOL_CALL_STATE.DONE, + }), + ).toBe(false); + }); + + it('is true for delegate_subagent with answer content', () => { + expect( + isToolCallExpandable({ + tool: DELEGATE_SUB_AGENT_TOOL_NAME, + output: { status: 'completed', answer: 'Child result' }, + state: TOOL_CALL_STATE.DONE, + }), + ).toBe(true); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts index c875cfd79e1..c6d87367907 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts @@ -437,7 +437,12 @@ describe('useAgentChatStream — SDK-aligned event handling', () => { it('flips a ToolCall to done on tool-execution-end before the batched tool-result arrives', async () => { const events: AgentSseEvent[] = [ { type: 'start-step' }, - { type: 'tool-call', toolCallId: 'tc-11', toolName: 'delegate_subagent', input: {} }, + { + type: 'tool-call', + toolCallId: 'tc-11', + toolName: 'delegate_subagent', + input: { subAgentId: 'inline' }, + }, { type: 'finish-step' }, { type: 'tool-execution-start', @@ -471,7 +476,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => { type: 'tool-call', toolCallId: 'tc-d1', toolName: 'delegate_subagent', - input: { taskName: 'research' }, + input: { subAgentId: 'inline', taskName: 'research' }, }, { type: 'finish-step' }, { @@ -495,7 +500,12 @@ describe('useAgentChatStream — SDK-aligned event handling', () => { it('renders a completed delegate_subagent result as a done step', async () => { const events: AgentSseEvent[] = [ { type: 'start-step' }, - { type: 'tool-call', toolCallId: 'tc-d2', toolName: 'delegate_subagent', input: {} }, + { + type: 'tool-call', + toolCallId: 'tc-d2', + toolName: 'delegate_subagent', + input: { subAgentId: 'inline' }, + }, { type: 'finish-step' }, { type: 'tool-result', @@ -521,7 +531,12 @@ describe('useAgentChatStream — SDK-aligned event handling', () => { // persisted/reloaded one exactly. const events: AgentSseEvent[] = [ { type: 'start-step' }, - { type: 'tool-call', toolCallId: 'tc-12', toolName: 'delegate_subagent', input: {} }, + { + type: 'tool-call', + toolCallId: 'tc-12', + toolName: 'delegate_subagent', + input: { subAgentId: 'inline' }, + }, { type: 'finish-step' }, { type: 'tool-execution-start', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/write-todos-tool.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/write-todos-tool.spec.ts new file mode 100644 index 00000000000..5afc51e6711 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/write-todos-tool.spec.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import { + WRITE_TODOS_TOOL_NAME, + formatWriteTodosMarkdown, + isWriteTodosTool, + parseWriteTodosFailedOutput, + parseWriteTodosOutput, + writeTodosLabel, + writeTodosSummaryLabel, + type WriteTodosI18n, +} from '../utils/write-todos-tool'; + +const STATUS_LABELS: Record = { + 'agents.chat.writeTodos.status.inProgress': 'In progress', + 'agents.chat.writeTodos.status.pending': 'Pending', + 'agents.chat.writeTodos.status.completed': 'Completed', + 'agents.chat.writeTodos.status.blocked': 'Blocked', + 'agents.chat.writeTodos.status.cancelled': 'Cancelled', + 'agents.chat.writeTodos.hint.subAgent': 'Sub-agent', + 'agents.chat.writeTodos.hint.expectedOutput': 'Expected output', +}; + +function createWriteTodosI18n(): WriteTodosI18n { + return { + baseText: (key: string, opts?: { interpolate?: { count?: string } }) => { + if (key === 'agents.chat.writeTodos.summary.one' && opts?.interpolate?.count) { + return `${opts.interpolate.count} task`; + } + if (key === 'agents.chat.writeTodos.summary.other' && opts?.interpolate?.count) { + return `${opts.interpolate.count} tasks`; + } + return STATUS_LABELS[key] ?? key; + }, + } as WriteTodosI18n; +} + +describe('write-todos-tool', () => { + describe('isWriteTodosTool', () => { + it('matches the write_todos tool name only', () => { + expect(isWriteTodosTool(WRITE_TODOS_TOOL_NAME)).toBe(true); + expect(isWriteTodosTool('delegate_subagent')).toBe(false); + }); + }); + + describe('parseWriteTodosOutput', () => { + it('parses valid output and strips unknown fields', () => { + expect( + parseWriteTodosOutput({ + status: 'ok', + todoCount: 1, + todos: [{ id: 'a', content: 'Do thing', status: 'pending', extra: true }], + }), + ).toEqual({ + status: 'ok', + todoCount: 1, + todos: [{ id: 'a', content: 'Do thing', status: 'pending' }], + }); + }); + + it('returns undefined for malformed output', () => { + expect(parseWriteTodosOutput({ status: 'failed' })).toBeUndefined(); + expect(parseWriteTodosOutput('nope')).toBeUndefined(); + }); + }); + + describe('parseWriteTodosFailedOutput', () => { + it('parses failed output with an error message', () => { + expect(parseWriteTodosFailedOutput({ status: 'failed', error: 'Duplicate todo id' })).toEqual( + { + status: 'failed', + error: 'Duplicate todo id', + }, + ); + }); + + it('returns undefined for ok output or malformed failed payloads', () => { + expect( + parseWriteTodosFailedOutput({ + status: 'ok', + todoCount: 1, + todos: [{ id: 'a', content: 'Task', status: 'pending' }], + }), + ).toBeUndefined(); + expect(parseWriteTodosFailedOutput({ status: 'failed' })).toBeUndefined(); + }); + }); + + describe('formatWriteTodosMarkdown', () => { + const i18n = createWriteTodosI18n(); + + it('groups todos by status and humanizes inline delegate hints', () => { + const markdown = formatWriteTodosMarkdown( + { + status: 'ok', + todoCount: 2, + todos: [ + { + id: 'research', + content: 'Research auth options', + status: 'in_progress', + delegateHint: { + subAgentId: 'inline', + expectedOutput: 'Short comparison', + }, + }, + { + id: 'synthesize', + content: 'Synthesize findings', + status: 'pending', + }, + ], + }, + i18n, + ); + + expect(markdown).toContain('**In progress**'); + expect(markdown).toContain( + '- Research auth options _(Sub-agent: Inline; Expected output: Short comparison)_', + ); + expect(markdown).toContain('**Pending**'); + }); + + it('resolves configured sub-agent ids to friendly names in delegate hints', () => { + const nameById = new Map([['agent-2', 'Research specialist']]); + const markdown = formatWriteTodosMarkdown( + { + status: 'ok', + todoCount: 1, + todos: [ + { + id: 'research', + content: 'Research auth options', + status: 'pending', + delegateHint: { subAgentId: 'agent-2' }, + }, + ], + }, + i18n, + nameById, + ); + + expect(markdown).toContain('_(Sub-agent: Research specialist)_'); + }); + + it('falls back to the raw sub-agent id when no friendly name is known', () => { + const markdown = formatWriteTodosMarkdown( + { + status: 'ok', + todoCount: 1, + todos: [ + { + id: 'research', + content: 'Research auth options', + status: 'pending', + delegateHint: { subAgentId: 'unknown-agent-id' }, + }, + ], + }, + i18n, + new Map(), + ); + + expect(markdown).toContain('_(Sub-agent: Unknown agent id)_'); + }); + + it('returns undefined for empty todo lists', () => { + expect( + formatWriteTodosMarkdown( + { + status: 'ok', + todoCount: 0, + todos: [], + }, + i18n, + ), + ).toBeUndefined(); + }); + + it('returns trimmed error text for failed write_todos output', () => { + expect( + formatWriteTodosMarkdown({ status: 'failed', error: ' Duplicate todo id "a" ' }), + ).toBe('Duplicate todo id "a"'); + }); + + it('returns trimmed string output for rejected tool calls', () => { + expect(formatWriteTodosMarkdown(' Validation failed ')).toBe('Validation failed'); + }); + + it('returns undefined for empty failed or malformed error payloads', () => { + expect(formatWriteTodosMarkdown({ status: 'failed', error: ' ' })).toBeUndefined(); + expect(formatWriteTodosMarkdown({ status: 'failed' })).toBeUndefined(); + expect(formatWriteTodosMarkdown({})).toBeUndefined(); + }); + }); + + describe('i18n helpers', () => { + const i18n = { + baseText: (key: string, opts?: { interpolate?: { count?: string } }) => { + if (opts?.interpolate?.count) return `${key}:${opts.interpolate.count}`; + return key; + }, + } as WriteTodosI18n; + + it('uses the task list label key', () => { + expect(writeTodosLabel(i18n)).toBe('agents.chat.writeTodos.label'); + }); + + it('uses the singular summary key for one task', () => { + expect(writeTodosSummaryLabel(i18n, 1)).toBe('agents.chat.writeTodos.summary.one:1'); + }); + + it('uses the plural summary key for multiple tasks', () => { + expect(writeTodosSummaryLabel(i18n, 4)).toBe('agents.chat.writeTodos.summary.other:4'); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue index 3d559b4e455..d9e2e34f2e9 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue @@ -4,14 +4,15 @@ import { useI18n } from '@n8n/i18n'; import { reactive, toRef } from 'vue'; import type { ToolCall } from '../composables/agentChatMessages'; import { useSubAgentNames } from '../composables/useSubAgentNames'; -import { formatDuration } from '../session-timeline.utils'; import { formatToolNameForDisplay, getToolNameTranslationKey } from '../utils/toolDisplayName'; +import { delegateLabel, isDelegateSubAgentTool, resolveSubAgentName } from '../utils/delegate-tool'; +import { getToolCallDetails } from '../utils/tool-call-details'; import { - delegateLabel, - isDelegateSubAgentTool, - parseDelegateOutput, - resolveSubAgentName, -} from '../utils/delegate-tool'; + isWriteTodosTool, + parseWriteTodosOutput, + writeTodosLabel, + writeTodosSummaryLabel, +} from '../utils/write-todos-tool'; const props = defineProps<{ toolCalls: ToolCall[]; @@ -20,134 +21,149 @@ const props = defineProps<{ const i18n = useI18n(); -// Resolve sub-agent ids → friendly names for the delegate step's label, loaded -// lazily and only when the chat actually contains delegations. +function toolCallsNeedSubAgentNames(toolCalls: ToolCall[]): boolean { + return toolCalls.some((tc) => { + if (isDelegateSubAgentTool(tc.tool)) return true; + if (!isWriteTodosTool(tc.tool)) return false; + const parsed = parseWriteTodosOutput(tc.output); + return parsed?.todos.some((todo) => Boolean(todo.delegateHint?.subAgentId)) ?? false; + }); +} + +// Resolve sub-agent ids → friendly names for delegate labels and write_todos hints. const projectIdRef = toRef(() => props.projectId ?? ''); const { subAgentNameById } = useSubAgentNames(projectIdRef, () => - props.toolCalls.some((tc) => isDelegateSubAgentTool(tc.tool)), + toolCallsNeedSubAgentNames(props.toolCalls), ); -// Track which delegate steps are expanded (by tool-call id). +// Track which tool steps are expanded (by tool-call id). const expandedIds = reactive(new Set()); +interface ToolStepDisplay { + label: string; + summary: string | undefined; + details: string; + expandable: boolean; + expanded: boolean; +} + function getToolDisplayName(toolName: string): string { const translationKey = getToolNameTranslationKey(toolName); return translationKey ? i18n.baseText(translationKey) : formatToolNameForDisplay(toolName); } -// Delegate steps render as "Sub-agent · " (resolved id, else humanized -// task name) to flag that a sub-agent ran. -function stepLabel(tc: ToolCall): string { - if (!isDelegateSubAgentTool(tc.tool)) return getToolDisplayName(tc.tool); - return delegateLabel(i18n, resolveSubAgentName(tc.input, subAgentNameById.value)); +function toolStepLabel(tc: ToolCall): string { + if (isDelegateSubAgentTool(tc.tool)) { + return delegateLabel(i18n, resolveSubAgentName(tc.input, subAgentNameById.value)); + } + if (isWriteTodosTool(tc.tool)) return writeTodosLabel(i18n); + return getToolDisplayName(tc.tool); } -function delegateAnswer(tc: ToolCall): string { - if (!isDelegateSubAgentTool(tc.tool)) return ''; - return parseDelegateOutput(tc.output)?.answer?.trim() ?? ''; +function toolStepSummary(tc: ToolCall): string | undefined { + if (isWriteTodosTool(tc.tool)) { + const parsed = parseWriteTodosOutput(tc.output); + if (parsed) return writeTodosSummaryLabel(i18n, parsed.todos.length); + } + if (tc.displaySummary) return tc.displaySummary; + return undefined; } -// A delegate step is expandable once it has an answer to reveal. -function isExpandable(tc: ToolCall): boolean { - return delegateAnswer(tc).length > 0; +function toolStepView(tc: ToolCall): ToolStepDisplay { + const details = getToolCallDetails(tc, i18n, subAgentNameById.value) ?? ''; + return { + label: toolStepLabel(tc), + summary: toolStepSummary(tc), + details, + expandable: details.length > 0, + expanded: expandedIds.has(tc.toolCallId), + }; } -function isExpanded(tc: ToolCall): boolean { - return expandedIds.has(tc.toolCallId); -} - -function toggle(tc: ToolCall): void { - if (!isExpandable(tc)) return; +function toggle(tc: ToolCall, view: ToolStepDisplay): void { + if (!view.expandable) return; if (expandedIds.has(tc.toolCallId)) expandedIds.delete(tc.toolCallId); else expandedIds.add(tc.toolCallId); } - -// Show the elapsed duration only once the tool has settled (start + end both -// recorded). No live ticking — the spinner already conveys the running state. -function toolDuration(tc: ToolCall): string { - if (tc.startTime === undefined || tc.endTime === undefined) return ''; - return formatDuration(tc.endTime - tc.startTime); -} @@ -269,13 +285,6 @@ function toolDuration(tc: ToolCall): string { min-width: 0; } -.duration { - color: var(--text-color--subtler); - font-size: var(--font-size--xs); - line-height: var(--line-height--sm); - font-variant-numeric: tabular-nums; -} - .chevron { color: var(--text-color--subtler); flex-shrink: 0; diff --git a/packages/frontend/editor-ui/src/features/agents/types.ts b/packages/frontend/editor-ui/src/features/agents/types.ts index b4507758f9b..d0c21e9bf9f 100644 --- a/packages/frontend/editor-ui/src/features/agents/types.ts +++ b/packages/frontend/editor-ui/src/features/agents/types.ts @@ -72,11 +72,6 @@ export interface ProviderToolSchema { export interface MemorySchema { source: string | null; storage: 'memory' | 'custom'; - semanticRecall: { - topK: number; - messageRange: { before: number; after: number } | null; - embedder: string | null; - } | null; workingMemory: { type: 'structured' | 'freeform'; schema?: Record; diff --git a/packages/frontend/editor-ui/src/features/agents/utils/__tests__/delegate-tool.test.ts b/packages/frontend/editor-ui/src/features/agents/utils/__tests__/delegate-tool.test.ts new file mode 100644 index 00000000000..4226c46bf19 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/utils/__tests__/delegate-tool.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { isFailedDelegateOutput, parseDelegateOutput } from '../delegate-tool'; + +describe('delegate-tool parsing', () => { + it('parses suspended delegate output without treating it as failed', () => { + const output = { + status: 'suspended' as const, + answer: 'waiting', + pendingSuspend: [ + { + runId: 'child-run-1', + toolCallId: 'tool-call-1', + toolName: 'delete_file', + input: {}, + suspendPayload: {}, + }, + ], + }; + + expect(parseDelegateOutput(output)).toEqual({ + status: 'suspended', + answer: 'waiting', + }); + expect(isFailedDelegateOutput('delegate_subagent', output)).toBe(false); + }); + + it('still treats failed delegate output as failed', () => { + const output = { status: 'failed' as const, answer: '', error: 'boom' }; + + expect(isFailedDelegateOutput('delegate_subagent', output)).toBe(true); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/utils/delegate-tool.ts b/packages/frontend/editor-ui/src/features/agents/utils/delegate-tool.ts index f79774e6a7e..66560bb2ef2 100644 --- a/packages/frontend/editor-ui/src/features/agents/utils/delegate-tool.ts +++ b/packages/frontend/editor-ui/src/features/agents/utils/delegate-tool.ts @@ -8,13 +8,17 @@ import { z } from 'zod'; * tool step. */ export const DELEGATE_SUB_AGENT_TOOL_NAME = 'delegate_subagent'; +export const INLINE_SUB_AGENT_ID = 'inline'; +/** Mirrors `DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE` in `@n8n/agents`. */ +export const DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE = + 'agents.chat.delegate.childSuspendUnsupported'; // FE-local parsers for the fields the chat reads off a delegate_subagent call. // The full input/output shapes live in `@n8n/agents` (not exported as // api-types); we only parse what the tool step renders — the sub-agent it ran // (input) and its answer (output). Extra keys are stripped. const delegateInputSchema = z.object({ - subAgentId: z.string().optional(), + subAgentId: z.string().min(1), taskName: z.string().optional(), }); @@ -22,7 +26,7 @@ const delegateOutputSchema = z.object({ // A failed delegation still RESOLVES the tool call (the SDK never throws for // it), so the chat relies on `status`/`error` rather than the tool-call's // own error flag to render it as a failure. - status: z.enum(['completed', 'failed']).optional(), + status: z.enum(['completed', 'failed', 'suspended']).optional(), answer: z.string().optional(), error: z.string().optional(), }); @@ -49,6 +53,17 @@ export function parseDelegateOutput(output: unknown): DelegateOutput | undefined return result.success ? result.data : undefined; } +/** Localize a delegate tool error when it is a known i18n key. */ +export function formatDelegateError( + error: string, + i18n?: Pick, 'baseText'>, +): string { + if (i18n && error === DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE) { + return i18n.baseText(DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE); + } + return error; +} + /** * True when a `delegate_subagent` call resolved with a failed result. Such a * call settles successfully at the tool layer, so its step must be flipped to an @@ -72,11 +87,27 @@ export function humanizeTaskName(taskName: string | undefined): string { * `''`. Shared by the chat tool step and the session timeline so both label a * delegation identically. */ +/** Friendly label for a raw sub-agent id (delegate hints, todo delegateHint, etc.). */ +export function resolveSubAgentIdForDisplay( + subAgentId: string, + nameById: Map, +): string { + if (subAgentId === INLINE_SUB_AGENT_ID) { + return humanizeTaskName('inline'); + } + const resolved = nameById.get(subAgentId)?.trim(); + if (resolved) return resolved; + return humanizeTaskName(subAgentId) || subAgentId; +} + export function resolveSubAgentName(input: unknown, nameById: Map): string { const parsed = parseDelegateInput(input); // A blank/empty resolved name must fall through to the task name, so this is a // truthiness check (not nullish) on purpose. - const resolved = parsed?.subAgentId ? nameById.get(parsed.subAgentId)?.trim() : undefined; + const resolved = + parsed?.subAgentId && parsed.subAgentId !== INLINE_SUB_AGENT_ID + ? nameById.get(parsed.subAgentId)?.trim() + : undefined; if (resolved) return resolved; return humanizeTaskName(parsed?.taskName); } diff --git a/packages/frontend/editor-ui/src/features/agents/utils/tool-call-details.ts b/packages/frontend/editor-ui/src/features/agents/utils/tool-call-details.ts new file mode 100644 index 00000000000..646bfd3f29d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/utils/tool-call-details.ts @@ -0,0 +1,65 @@ +import type { ToolCallState } from '../constants'; +import { TOOL_CALL_STATE } from '../constants'; +import type { ToolCall } from '../composables/agentChatMessages'; +import { formatDelegateError, isDelegateSubAgentTool, parseDelegateOutput } from './delegate-tool'; +import { + formatWriteTodosMarkdown, + isWriteTodosTool, + type WriteTodosI18n, +} from './write-todos-tool'; + +function isSettledState(state: ToolCallState): boolean { + return state === TOOL_CALL_STATE.DONE || state === TOOL_CALL_STATE.ERROR; +} + +function formatDelegateDetails(output: unknown, i18n?: WriteTodosI18n): string | undefined { + const parsed = parseDelegateOutput(output); + if (!parsed) return undefined; + + const answer = parsed.answer?.trim(); + if (answer) return answer; + + const error = parsed.error?.trim(); + if (error) return formatDelegateError(error, i18n); + + return undefined; +} + +function formatExpandableDetails( + toolName: string, + output: unknown, + i18n?: WriteTodosI18n, + subAgentNameById?: Map, +): string | undefined { + if (isDelegateSubAgentTool(toolName)) { + return formatDelegateDetails(output, i18n); + } + + if (isWriteTodosTool(toolName)) { + return formatWriteTodosMarkdown(output, i18n, subAgentNameById); + } + + return undefined; +} + +/** + * Returns Markdown/text for the expandable tool-call details panel. + * Only `delegate_subagent` and `write_todos` have purpose-built detail views; + * other tools are not expandable until their UX is designed. + */ +export function getToolCallDetails( + tc: Pick, + i18n?: WriteTodosI18n, + subAgentNameById?: Map, +): string | undefined { + if (!isSettledState(tc.state)) return undefined; + return formatExpandableDetails(tc.tool, tc.output, i18n, subAgentNameById); +} + +export function isToolCallExpandable( + tc: Pick, + i18n?: WriteTodosI18n, + subAgentNameById?: Map, +): boolean { + return getToolCallDetails(tc, i18n, subAgentNameById) !== undefined; +} diff --git a/packages/frontend/editor-ui/src/features/agents/utils/write-todos-tool.ts b/packages/frontend/editor-ui/src/features/agents/utils/write-todos-tool.ts new file mode 100644 index 00000000000..2bf5cbefc44 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/utils/write-todos-tool.ts @@ -0,0 +1,143 @@ +import type { BaseTextKey, useI18n } from '@n8n/i18n'; +import { z } from 'zod'; + +import { resolveSubAgentIdForDisplay } from './delegate-tool'; + +/** + * Name of the SDK tool the parent agent calls to maintain a structured task list. + * Mirrors `WRITE_TODOS_TOOL_NAME` in `@n8n/agents` (not FE-importable). + */ +export const WRITE_TODOS_TOOL_NAME = 'write_todos'; + +const todoStatusSchema = z.enum(['pending', 'in_progress', 'completed', 'blocked', 'cancelled']); + +const todoItemSchema = z.object({ + id: z.string().min(1), + content: z.string().min(1), + status: todoStatusSchema, + delegateHint: z + .object({ + subAgentId: z.string().optional(), + expectedOutput: z.string().optional(), + }) + .optional(), +}); + +const writeTodosOutputSchema = z.object({ + status: z.literal('ok'), + todoCount: z.number(), + todos: z.array(todoItemSchema), +}); + +const writeTodosFailedOutputSchema = z.object({ + status: z.literal('failed'), + error: z.string(), +}); + +export type WriteTodosOutput = z.infer; +export type WriteTodosFailedOutput = z.infer; +export type TodoItem = z.infer; +export type TodoStatus = z.infer; + +export type WriteTodosI18n = Pick, 'baseText'>; + +const STATUS_I18N_KEY: Record = { + in_progress: 'agents.chat.writeTodos.status.inProgress', + pending: 'agents.chat.writeTodos.status.pending', + completed: 'agents.chat.writeTodos.status.completed', + blocked: 'agents.chat.writeTodos.status.blocked', + cancelled: 'agents.chat.writeTodos.status.cancelled', +}; + +const STATUS_ORDER: TodoStatus[] = ['in_progress', 'pending', 'completed', 'blocked', 'cancelled']; + +export function isWriteTodosTool(toolName: string | undefined): boolean { + return toolName === WRITE_TODOS_TOOL_NAME; +} + +export function parseWriteTodosOutput(output: unknown): WriteTodosOutput | undefined { + const result = writeTodosOutputSchema.safeParse(output); + return result.success ? result.data : undefined; +} + +export function parseWriteTodosFailedOutput(output: unknown): WriteTodosFailedOutput | undefined { + const result = writeTodosFailedOutputSchema.safeParse(output); + return result.success ? result.data : undefined; +} + +function formatWriteTodosErrorText(output: unknown): string | undefined { + const failed = parseWriteTodosFailedOutput(output); + if (failed) { + const error = failed.error.trim(); + return error.length > 0 ? error : undefined; + } + + if (typeof output === 'string') { + const trimmed = output.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + return undefined; +} + +export function writeTodosLabel(i18n: WriteTodosI18n): string { + return i18n.baseText('agents.chat.writeTodos.label'); +} + +export function writeTodosSummaryLabel(i18n: WriteTodosI18n, todoCount: number): string { + const key = + todoCount === 1 ? 'agents.chat.writeTodos.summary.one' : 'agents.chat.writeTodos.summary.other'; + return i18n.baseText(key, { + interpolate: { count: String(todoCount) }, + }); +} + +function writeTodosStatusLabel(i18n: WriteTodosI18n, status: TodoStatus): string { + return i18n.baseText(STATUS_I18N_KEY[status]); +} + +function formatTodoItem( + todo: TodoItem, + i18n: WriteTodosI18n, + subAgentNameById?: Map, +): string { + const hints: string[] = []; + if (todo.delegateHint?.subAgentId) { + const displayName = resolveSubAgentIdForDisplay( + todo.delegateHint.subAgentId, + subAgentNameById ?? new Map(), + ); + hints.push(`${i18n.baseText('agents.chat.writeTodos.hint.subAgent')}: ${displayName}`); + } + if (todo.delegateHint?.expectedOutput) { + hints.push( + `${i18n.baseText('agents.chat.writeTodos.hint.expectedOutput')}: ${todo.delegateHint.expectedOutput}`, + ); + } + + const suffix = hints.length > 0 ? ` _(${hints.join('; ')})_` : ''; + return `- ${todo.content}${suffix}`; +} + +/** Format parsed write_todos output as Markdown for the expandable details panel. */ +export function formatWriteTodosMarkdown( + output: unknown, + i18n?: WriteTodosI18n, + subAgentNameById?: Map, +): string | undefined { + const errorText = formatWriteTodosErrorText(output); + if (errorText) return errorText; + + const parsed = parseWriteTodosOutput(output); + if (!parsed || !i18n || parsed.todos.length === 0) return undefined; + + const sections: string[] = []; + for (const status of STATUS_ORDER) { + const items = parsed.todos.filter((todo) => todo.status === status); + if (items.length === 0) continue; + sections.push(`**${writeTodosStatusLabel(i18n, status)}**`); + sections.push(items.map((todo) => formatTodoItem(todo, i18n, subAgentNameById)).join('\n')); + } + + return sections.join('\n\n'); +}