feat(core): Add inline sub-agent delegation (#31553)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.22.3) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.15.0) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Sync API Docs / sync-public-api (push) Waiting to run

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bjorger 2026-06-03 18:00:54 +02:00 committed by GitHub
parent a089408968
commit 73d8bbe121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 3302 additions and 1379 deletions

View File

@ -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`

View File

@ -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

View File

@ -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),

View File

@ -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;

View File

@ -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');
});
});

View File

@ -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 {

View File

@ -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<Pick<BuiltMemory, 'saveEmbeddings' | 'queryEmbeddings'>>;
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<Pick<BuiltMemory, 'saveEmbeddings' | 'queryEmbeddings'>>;
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<Pick<BuiltMemory, 'saveEmbeddings' | 'queryEmbeddings'>>;
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<Pick<BuiltMemory, 'saveEmbeddings' | 'queryEmbeddings'>>;
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)
// ---------------------------------------------------------------------------

View File

@ -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<DelegateSubAgentToolOutput>>()
.mockResolvedValue({
status: 'completed',
taskPath: '/root/research_api',
runId: 'child-run-1',
answer: 'done',
});
const runSubAgent = vi.fn<DelegateSubAgentRunner>().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<DelegateSubAgentRunner>(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<DelegateSubAgentToolOutput>>()
.fn<DelegateSubAgentRunner>()
.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<DelegateSubAgentToolOutput>>()
.fn<DelegateSubAgentRunner>()
.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<DelegateSubAgentToolOutput>>()
.fn<DelegateSubAgentRunner>()
.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<DelegateSubAgentToolOutput>>()
.mockResolvedValue({
status: 'completed',
taskPath: '/root/research_api',
runId: 'child-run-1',
answer: 'done',
});
const runSubAgent = vi.fn<DelegateSubAgentRunner>().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();
});
});

View File

@ -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(

View File

@ -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);
});
});

View File

@ -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<void> {
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<string>,
range: { before: number; after: number },
): AgentDbMessage[] {
const expandedIds = new Set<string>();
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<void> {
// 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<string, Record<string, unknown>> | undefined {
if (!this.config.thinking) return undefined;

View File

@ -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<typeof delegateSubAgentInputSchema>;
/**
* 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<DelegateSubAgentToolOutput>;
}
export type DelegateSubAgentRunner = (
request: DelegateSubAgentRequest,
helpers: DelegateSubAgentRunnerHelpers,
) => Promise<DelegateSubAgentToolOutput>;
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<DelegateSubAgentToolOutput>;
/** 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<string, number>();
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 }
: {}),
};
}

View File

@ -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,
},
};
}

View File

@ -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/<segment>` 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/<one segment>` — 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/<segment>`? */
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.
*

View File

@ -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<string>();
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);
}

View File

@ -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<Record<string, unknown>> = [];
let inlineChildGenerateResult:
| Awaited<ReturnType<InstanceType<typeof AgentRuntimeModule.AgentRuntime>['generate']>>
| undefined;
vi.mock('../../runtime/agent-runtime', async (importOriginal) => {
const actual = await importOriginal<typeof AgentRuntimeModule>();
return {
...actual,
AgentRuntime: class MockAgentRuntime {
constructor(config: Record<string, unknown>) {
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<DelegateSubAgentRunner>(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<unknown> }).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<unknown> }).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<DelegateSubAgentRunnerHelpers['runInlineSubAgent']>()
.mockResolvedValue({
status: 'completed',
taskPath: '/root/research_api_0',
answer: 'inline via helper',
});
const hostRunSubAgent = vi.fn<DelegateSubAgentRunner>(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<unknown> }).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,
});
});
});

View File

@ -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<Record<string, unknown>> = [];
vi.mock('../../runtime/agent-runtime', async (importOriginal) => {
const actual = await importOriginal<typeof AgentRuntimeModule>();
return {
...actual,
AgentRuntime: class MockAgentRuntime {
constructor(config: Record<string, unknown>) {
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<unknown>;
};
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'],
);
});
});

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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<DelegateSubAgentToolOutput> {
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<string> {
return new Set([...SDK_INLINE_SUB_AGENT_BLOCKED_TOOL_NAMES, ...(hostBlockedTools ?? [])]);
}
export function filterInlineSubAgentTools<T extends { readonly name: string }>(
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<string>();
const duplicates = new Set<string>();

View File

@ -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,
};

View File

@ -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<typeof APPROVAL_RESUME_SCHEMA>;
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);
},
};
}

View File

@ -36,35 +36,6 @@ export abstract class BaseMemory<TConstructorOptions extends JSONObject = JSONOb
deleteMessages(_messageIds: string[]): Promise<void> {
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<AgentDbMessage[]> {
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<void> {
throw new Error('Method not implemented.');
}
queryEmbeddings?(_opts: {
scope?: 'thread' | 'resource';
threadId?: string;
resourceId?: string;
vector: number[];
topK: number;
}): Promise<Array<{ id: string; score: number }>> {
throw new Error('Method not implemented.');
}
close?(): Promise<void> {
throw new Error('Method not implemented.');

View File

@ -92,7 +92,6 @@ export type {
RetrievedEpisodicMemoryEntry,
ObservationCapableMemory,
MemoryDescriptor,
SemanticRecallConfig,
MemoryConfig,
ObservationLogMemoryConfig,
ObservationalMemoryConfig,

View File

@ -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;

View File

@ -61,38 +61,6 @@ export interface BuiltMemory {
messages: AgentDbMessage[];
}): Promise<void>;
deleteMessages(messageIds: string[]): Promise<void>;
// --- Semantic recall (optional) ---
search?(
query: string,
opts?: {
/** @default 'resource' */
scope?: 'thread' | 'resource';
threadId?: string;
resourceId?: string;
topK?: number;
messageRange?: { before: number; after: number };
},
): Promise<AgentDbMessage[]>;
// --- 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<void>;
queryEmbeddings?(opts: {
/** @default 'resource' */
scope?: 'thread' | 'resource';
threadId?: string;
resourceId?: string;
vector: number[];
topK: number;
}): Promise<Array<{ id: string; score: number }>>;
// --- 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;
}

View File

@ -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;
}

View File

@ -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', () => {

View File

@ -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);
});
});

View File

@ -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([

View File

@ -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<agents.Agent>();
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<SubAgentForegroundRunner>());
});
function makeReconstructionService(
agentsToolsService: AgentsToolsService,
modules: string[] = [],
): AgentsService {
return new AgentsService(
mock<Logger>(),
overrides: {
logger?: Logger;
} = {},
): AgentRuntimeReconstructionService {
const secureRuntime = mock<AgentSecureRuntime>();
secureRuntime.createToolExecutor.mockReturnValue(mock<ToolExecutor>());
return new AgentRuntimeReconstructionService(
overrides.logger ?? mock<Logger>(),
mock<AgentRepository>(),
mock<ProjectRelationRepository>(),
mock<WorkflowRunner>(),
mock<ActiveExecutions>(),
mock<ExecutionRepository>(),
@ -68,23 +73,14 @@ function makeService(
mock<WorkflowFinderService>(),
mock<UrlService>(),
mock<N8NCheckpointStorage>(),
mock<AgentSecureRuntime>(),
secureRuntime,
mock<EphemeralNodeExecutor>(),
agentsToolsService,
mock<N8nMemory>(),
mock<AgentExecutionService>(),
mock<AgentHistoryRepository>(),
mock<AgentSkillsService>(),
mock(), // AgentTaskRepository
mock(), // AgentTaskSnapshotRepository
mock(),
mock<OauthService>(),
{ modules } as unknown as AgentsConfig,
mock(),
mock<Telemetry>(),
mock(),
mock(),
mock(),
mock(),
mock<AgentKnowledgeService>(),
mock<AgentKnowledgeCommandService>(),
);
}
@ -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>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
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>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
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>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
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>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
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);
});
});

View File

@ -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> = {}): Agent {
@ -62,17 +51,7 @@ describe('AgentsService — updateName / updateDescription schema sync', () => {
mock<Logger>(),
agentRepository,
mock<ProjectRelationRepository>(),
mock<WorkflowRunner>(),
mock<ActiveExecutions>(),
mock<ExecutionRepository>(),
mock<WorkflowRepository>(),
mock<UserRepository>(),
mock<WorkflowFinderService>(),
mock<UrlService>(),
mock<N8NCheckpointStorage>(),
mock<AgentSecureRuntime>(),
mock<EphemeralNodeExecutor>(),
mock(), // AgentsToolsService
mock<N8nMemory>(),
mock<AgentExecutionService>(),
mock(),
@ -86,7 +65,6 @@ describe('AgentsService — updateName / updateDescription schema sync', () => {
mock<ChatIntegrationService>(),
mock(),
mock(),
mock(),
);
});

View File

@ -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> = {}): AgentHistory {
} as unknown as AgentHistory;
}
function makeRuntimeReconstructionService(
modules: string[] = [],
): AgentRuntimeReconstructionService {
return new AgentRuntimeReconstructionService(
mock<Logger>(),
mock<AgentRepository>(),
mock<WorkflowRunner>(),
mock<ActiveExecutions>(),
mock<ExecutionRepository>(),
mock<WorkflowRepository>(),
mock<UserRepository>(),
mock<WorkflowFinderService>(),
mock<UrlService>(),
mock<N8NCheckpointStorage>(),
mock<AgentSecureRuntime>(),
mock<EphemeralNodeExecutor>(),
mock<AgentsToolsService>(),
mock<N8nMemory>(),
mock<OauthService>(),
{ modules } as unknown as AgentsConfig,
mock<AgentKnowledgeService>(),
mock<AgentKnowledgeCommandService>(),
);
}
function makeTaskSnapshot(overrides: Partial<AgentTaskSnapshot> = {}): 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<SubAgentForegroundRunner>());
});
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<string, never>;
availableSubAgents: [];
};
parentAgentIdForDelegation: string;
credentialIntegrations: Array<{ type: string; credentialId: string }>;
}): Promise<void>;
}
@ -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' }],
});

View File

@ -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');

View File

@ -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<string, SubAgentSource>;
availableSubAgents: Array<{ id: string; name: string; description?: string }>;
}
export interface ReconstructAgentRuntimeParams {
config: AgentJsonConfig;
memoryOwnerAgentId: string;
projectId: string;
credentialProvider: CredentialProvider;
toolDescriptors: Record<string, ToolDescriptor>;
toolCodeByName: Record<string, string>;
skills: Record<string, AgentSkill>;
/** 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<string, string> = {};
const toolDescriptors: Record<string, ToolDescriptor> = {};
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<string, ToolDescriptor>;
toolCodeByName: Record<string, string>;
skills: Record<string, AgentSkill>;
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<SubAgentDelegationConfig> {
const configuredAgents = config.subAgents?.agents ?? [];
const sourcesById: Record<string, SubAgentSource> = {};
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<Array<{ agentId: string; agent: Agent | null }>> {
const seen = new Set<string>();
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<void> {
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,
};
}
}

View File

@ -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<string, SubAgentSource>;
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<void> {
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<string, string> = {};
for (const [_toolId, toolEntry] of Object.entries(agentEntity.tools ?? {})) {
toolsByName[toolEntry.descriptor.name] = toolEntry.code;
}
// Build toolDescriptors map: { toolId -> descriptor }
const toolDescriptors: Record<string, ToolDescriptor> = {};
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<SubAgentDelegationConfig | undefined> {
const configuredAgents = config.subAgents?.agents ?? [];
if (configuredAgents.length === 0) return undefined;
const sourcesById: Record<string, SubAgentSource> = {};
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 };
}

View File

@ -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": "<published-agent-id>" }] }`');
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', () => {

View File

@ -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": "<published-agent-id>" }] }\`.
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": "<returned-agent-id>" }\`. 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

View File

@ -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": "<selected-agent-id>" }\`.
- 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.

View File

@ -62,11 +62,13 @@ export function getConfigRulesSection(): string {
\`credentialType: "openAiApi"\`.
- Memory worker model fields use \`{ "model": "provider/model-name", "credential": "<credentialId>" }\`;
use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`.
- Subagent delegation lives at top level as
\`subAgents: { "agents": [{ "agentId": "<published-agent-id>" }] }\`. 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

View File

@ -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),

View File

@ -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<SubAgentForegroundRunner>;
let credentialProvider: jest.Mocked<CredentialProvider>;
let toolExecutor: jest.Mocked<ToolExecutor>;
let createToolExecutor: jest.Mock;
let createMemoryFactory: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
runner = mock<SubAgentForegroundRunner>();
runner.runForeground.mockResolvedValue(foregroundResult);
credentialProvider = mock<CredentialProvider>();
toolExecutor = mock<ToolExecutor>();
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', () => {

View File

@ -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<SubAgentSourceResolver>;
let reconstructionService: jest.Mocked<AgentRuntimeReconstructionService>;
let runner: SubAgentForegroundRunner;
let childAgent: jest.Mocked<BuiltAgent>;
let agentExecutionService: jest.Mocked<AgentExecutionService>;
let logger: jest.Mocked<Logger>;
let credentialProvider: jest.Mocked<CredentialProvider>;
let toolExecutor: jest.Mocked<ToolExecutor>;
let createToolExecutor: jest.Mock;
let createMemoryFactory: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
Container.reset();
sourceResolver = mock<SubAgentSourceResolver>();
sourceResolver.resolveForRuntime.mockResolvedValue(runtimeSource);
reconstructionService = mock<AgentRuntimeReconstructionService>();
Container.set(AgentRuntimeReconstructionService, reconstructionService);
agentExecutionService = mock<AgentExecutionService>();
logger = mock<Logger>();
runner = new SubAgentForegroundRunner(sourceResolver, agentExecutionService, logger);
@ -118,20 +115,29 @@ describe('SubAgentForegroundRunner', () => {
childAgent = mock<BuiltAgent>();
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<CredentialProvider>();
toolExecutor = mock<ToolExecutor>();
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,
},
);

View File

@ -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<string, SubAgentSource>;
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(

View File

@ -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<string, string>): 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,

View File

@ -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...",

View File

@ -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: '<i :data-icon="icon" />',
props: ['icon', 'size', 'spin'],
},
N8nMarkdownEditor: {
template: '<div data-test-id="tool-step-details">{{ modelValue }}</div>',
props: ['modelValue', 'readonly', 'variant', 'showToolbar', 'maxHeight'],
},
N8nTooltip: { template: '<div><slot /></div>', 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<string, string> = {
'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');
});
});

View File

@ -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' },
},

View File

@ -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('');
});
});

View File

@ -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();
});
});

View File

@ -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<string, string> = {
'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);
});
});
});

View File

@ -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',

View File

@ -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<string, string> = {
'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');
});
});
});

View File

@ -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<string>());
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 · <name>" (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);
}
</script>
<template>
<ol :class="$style.toolSteps">
<li v-for="(tc, i) in toolCalls" :key="i" :class="$style.toolStep">
<!-- Rail: the status icon plus a line that grows to fill the step's
height, so consecutive steps stay visually connected even when one
expands its answer. -->
<div :class="$style.rail">
<div :class="$style.indicator">
<N8nIcon
v-if="tc.state === 'done'"
icon="circle-check"
size="large"
:class="$style.indicatorDone"
/>
<N8nIcon
v-else-if="tc.state === 'error'"
icon="circle-x"
size="large"
:class="$style.indicatorError"
/>
<N8nIcon
v-else-if="tc.state === 'cancelled'"
icon="circle-x"
size="large"
:class="$style.indicatorCancelled"
/>
<N8nTooltip
v-else-if="tc.state === 'suspended'"
placement="top"
:content="i18n.baseText('agents.chat.toolStep.waitingForInput')"
>
<N8nIcon icon="clock" size="large" :class="$style.indicatorSuspended" />
</N8nTooltip>
<N8nIcon
v-else
icon="spinner"
size="large"
:spin="true"
:class="$style.indicatorLoading"
/>
<template v-for="view in [toolStepView(tc)]" :key="view.label">
<!-- Rail: the status icon plus a line that grows to fill the step's
height, so consecutive steps stay visually connected even when one
expands its answer. -->
<div :class="$style.rail">
<div :class="$style.indicator">
<N8nIcon
v-if="tc.state === 'done'"
icon="circle-check"
size="large"
:class="$style.indicatorDone"
/>
<N8nIcon
v-else-if="tc.state === 'error'"
icon="circle-x"
size="large"
:class="$style.indicatorError"
/>
<N8nIcon
v-else-if="tc.state === 'cancelled'"
icon="circle-x"
size="large"
:class="$style.indicatorCancelled"
/>
<N8nTooltip
v-else-if="tc.state === 'suspended'"
placement="top"
:content="i18n.baseText('agents.chat.toolStep.waitingForInput')"
>
<N8nIcon icon="clock" size="large" :class="$style.indicatorSuspended" />
</N8nTooltip>
<N8nIcon
v-else
icon="spinner"
size="large"
:spin="true"
:class="$style.indicatorLoading"
/>
</div>
<div :class="$style.railLine" />
</div>
<div :class="$style.railLine" />
</div>
<div :class="$style.stepBody">
<component
:is="isExpandable(tc) ? 'button' : 'div'"
:type="isExpandable(tc) ? 'button' : undefined"
:aria-expanded="isExpandable(tc) ? isExpanded(tc) : undefined"
:class="[$style.stepRow, { [$style.stepRowButton]: isExpandable(tc) }]"
@click="toggle(tc)"
>
<span :class="[$style.label, { [$style.shimmer]: tc.state === 'running' }]">
{{ stepLabel(tc) }}
</span>
<span v-if="tc.displaySummary" :class="$style.summary" data-testid="tool-step-summary">
· {{ tc.displaySummary }}
</span>
<span v-if="toolDuration(tc)" :class="$style.duration">
{{ toolDuration(tc) }}
</span>
<N8nIcon
v-if="isExpandable(tc)"
:icon="isExpanded(tc) ? 'chevron-down' : 'chevron-right'"
size="small"
:class="$style.chevron"
/>
</component>
<div v-if="isExpandable(tc) && isExpanded(tc)" :class="$style.answer">
<N8nMarkdownEditor
:model-value="delegateAnswer(tc)"
readonly
variant="ghost"
show-toolbar="never"
max-height="240px"
/>
<div :class="$style.stepBody">
<component
:is="view.expandable ? 'button' : 'div'"
:type="view.expandable ? 'button' : undefined"
:aria-expanded="view.expandable ? view.expanded : undefined"
:class="[$style.stepRow, { [$style.stepRowButton]: view.expandable }]"
@click="toggle(tc, view)"
>
<span :class="[$style.label, { [$style.shimmer]: tc.state === 'running' }]">
{{ view.label }}
</span>
<span v-if="view.summary" :class="$style.summary" data-testid="tool-step-summary">
· {{ view.summary }}
</span>
<N8nIcon
v-if="view.expandable"
:icon="view.expanded ? 'chevron-down' : 'chevron-right'"
size="small"
:class="$style.chevron"
/>
</component>
<div v-if="view.expandable && view.expanded" :class="$style.answer">
<N8nMarkdownEditor
:model-value="view.details"
readonly
variant="ghost"
show-toolbar="never"
max-height="240px"
/>
</div>
</div>
</div>
</template>
</li>
</ol>
</template>
@ -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;

View File

@ -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<string, unknown>;

View File

@ -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);
});
});

View File

@ -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<ReturnType<typeof useI18n>, '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, string>,
): 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, string>): 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);
}

View File

@ -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, string>,
): 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<ToolCall, 'tool' | 'output' | 'state'>,
i18n?: WriteTodosI18n,
subAgentNameById?: Map<string, string>,
): string | undefined {
if (!isSettledState(tc.state)) return undefined;
return formatExpandableDetails(tc.tool, tc.output, i18n, subAgentNameById);
}
export function isToolCallExpandable(
tc: Pick<ToolCall, 'tool' | 'output' | 'state'>,
i18n?: WriteTodosI18n,
subAgentNameById?: Map<string, string>,
): boolean {
return getToolCallDetails(tc, i18n, subAgentNameById) !== undefined;
}

View File

@ -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<typeof writeTodosOutputSchema>;
export type WriteTodosFailedOutput = z.infer<typeof writeTodosFailedOutputSchema>;
export type TodoItem = z.infer<typeof todoItemSchema>;
export type TodoStatus = z.infer<typeof todoStatusSchema>;
export type WriteTodosI18n = Pick<ReturnType<typeof useI18n>, 'baseText'>;
const STATUS_I18N_KEY: Record<TodoStatus, BaseTextKey> = {
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, string>,
): 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, string>,
): 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');
}