feat(core): Add sub-agent executions (#31540)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
bjorger 2026-06-02 17:37:39 +02:00 committed by GitHub
parent ee3b277ff0
commit bfff25f05d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 5193 additions and 678 deletions

View File

@ -9,9 +9,9 @@ Conventions for the `@n8n/agents` package.
- **Builder pattern with lazy build** — all public primitives use a fluent
builder API. **User code never calls `.build()`**. Builders are passed
directly to the consuming method (e.g. `agent.tool(myTool)`) which calls
`.build()` internally. Agent and Network have `run()`/`stream()` directly
on the class, which lazy-build via `ensureBuilt()` on first call. `build()`
is `protected` on Agent and Network to keep it out of the public API.
`.build()` internally. Agent has `generate()`/`stream()` directly on the
class, which lazy-build via `ensureBuilt()` on first call. `build()` is
`protected` on Agent to keep it out of the public API.
- **Zod for schemas** — all input/output schemas use Zod.
## Package Structure
@ -34,7 +34,6 @@ src/
mcp-client.ts # MCP client integration
memory.ts # Memory builder
message.ts # LLM/DB message helpers
network.ts # Network builder
provider-tools.ts # Provider-defined tool factories
telemetry.ts # Telemetry builder (OTel, redaction)
tool.ts # Tool builder

View File

@ -65,7 +65,6 @@ graph TD
| `on(event, handler)` | Register a lifecycle event handler |
| `abort()` | Cancel the currently running agent |
| `getState()` | Return the latest `SerializableAgentState` snapshot |
| `asTool(description)` | Wrap the agent as a `BuiltTool` for multi-agent composition |
`ExecutionOptions` includes `abortSignal?: AbortSignal`, forwarded into
`AgentEventBus.resetAbort()` so callers can cancel via an external signal as
@ -187,18 +186,6 @@ interface SerializableAgentState {
`suspendPayload`, `resumeSchema`) from calls not yet executed (`suspended:
false`) when a batch stops at the first suspension.
---
## asTool()
`agent.asTool(description)` wraps the agent as a `BuiltTool`. The handler calls
`agent.generate(input, { telemetry: ctx.parentTelemetry })`, collects assistant
text, and returns `{ result: string }`. When the sub-run produces usage,
results are wrapped so the parent runtime can merge **`SubAgentUsage`** and
**`totalCost`** into the parent `GenerateResult` / stream `finish` chunk.
---
## Message types
| Type | Definition | Purpose |
@ -395,7 +382,7 @@ readable side immediately; the loop writes chunks in the background.
| `tool-call-delta` | Streaming tool name / arguments |
| `message` | Full assistant or tool message |
| `tool-call-suspended` | Suspension: `runId`, `toolCallId`, tool metadata, optional `resumeSchema`, `suspendPayload` |
| `finish` | `finishReason`, `usage` (with optional **cost**), `model`, optional **`structuredOutput`**, **`subAgentUsage`**, **`totalCost`** |
| `finish` | `finishReason`, `usage` (with optional **cost**), `model`, optional **`structuredOutput`** |
| `error` | Failure or abort |
---
@ -412,7 +399,7 @@ src/
memory-store.ts — saveMessagesToThread helper
messages.ts — AI SDK message conversion
model-factory.ts — createModel / createEmbeddingModel
tool-adapter.ts — buildToolMap, executeTool, toAiSdkTools, suspend / agent-result guards
tool-adapter.ts — buildToolMap, executeTool, toAiSdkTools, suspend guards
stream.ts — convertChunk, toTokenUsage
runtime-helpers.ts — normalizeInput, usage merge, stream error helpers, …
working-memory.ts — instruction text, update_working_memory tool builder

View File

@ -3,7 +3,7 @@
*
* This example demonstrates the complete builder-pattern API for creating
* and running AI agents. It shows: tools, agents, memory, guardrails,
* scorers, multi-agent patterns (agent-as-tool), and tool interrupts.
* scorers, and tool interrupts.
*
* To run with real LLM calls, set ANTHROPIC_API_KEY.
* Without keys, the runtime will throw on actual LLM calls.
@ -90,18 +90,6 @@ const writer = new Agent('writer')
.tool(writeFileTool)
.checkpoint('memory');
// ---------------------------------------------------------------------------
// Multi-Agent: Agent as Tool
// ---------------------------------------------------------------------------
const orchestrator = new Agent('orchestrator')
.model('anthropic/claude-sonnet-4')
.instructions(
'You coordinate research and writing. Delegate research to the researcher and writing to the writer.',
)
.tool(researcher.asTool('Delegate research tasks to the research specialist'))
.tool(writer.asTool('Delegate writing tasks to the content writer'));
// ---------------------------------------------------------------------------
// Execution
// ---------------------------------------------------------------------------
@ -132,13 +120,11 @@ async function main() {
console.log(' (Set ANTHROPIC_API_KEY to run with real LLM calls)');
}
// --- 2. Orchestrator (agent-as-tool pattern) ---
console.log('\n2. Orchestrator (agent-as-tool pattern):');
// --- 2. Tool interrupt ---
console.log('\n2. Tool interrupt:');
try {
const orchResult = await orchestrator.generate(
'Research RAG architectures and write a summary',
);
const text = orchResult.messages
const writerResult = await writer.generate('Write a short summary to /tmp/rag-summary.txt');
const text = writerResult.messages
.flatMap((m) => ('content' in m ? m.content : []))
.filter((c) => c.type === 'text')
.map((c) => ('text' in c ? c.text : ''))

View File

@ -0,0 +1,125 @@
import { expect, it } from 'vitest';
import { describeIf, getModel } from './helpers';
import {
Agent,
createDelegateSubAgentTool,
filterLlmMessages,
type AgentMessage,
} from '../../index';
const describe = describeIf('anthropic');
const SENTINEL = 'SUBAGENT_OK_731';
describe('delegate_subagent integration', () => {
it('lets a real parent agent call delegate_subagent and use its result', async () => {
const child = new Agent('sub-agent-child-integration')
.model(getModel('anthropic'))
.instructions(
[
'You are a deterministic test sub-agent.',
`Always answer with exactly this token and nothing else: ${SENTINEL}`,
].join(' '),
);
const delegateTool = createDelegateSubAgentTool({
policy: { maxChildren: 1 },
runSubAgent: async (request) => {
const childResult = await child.generate(`Goal:\n${request.goal}`);
return {
status:
childResult.finishReason === 'error' || childResult.error !== undefined
? 'failed'
: 'completed',
taskPath: request.taskPath,
runId: childResult.runId,
answer: lastText(childResult.messages),
...(childResult.usage !== undefined
? {
usage: {
promptTokens: childResult.usage.promptTokens,
completionTokens: childResult.usage.completionTokens,
totalTokens: childResult.usage.totalTokens,
},
}
: {}),
...(childResult.finishReason !== undefined
? { finishReason: childResult.finishReason }
: {}),
};
},
});
const parent = new Agent('sub-agent-parent-integration')
.model(getModel('anthropic'))
.instructions(
[
'You are a parent test agent.',
'You must call delegate_subagent exactly once before answering.',
'The child result will contain a sentinel token.',
'After the tool returns, answer with exactly: PARENT_SAW_ followed by the child answer, with no extra text.',
].join(' '),
)
.tool(delegateTool);
try {
const result = await parent.generate(
'Use delegate_subagent now to ask the child for its sentinel token.',
);
expect(result.toolCalls?.map((toolCall) => toolCall.tool) ?? []).toContain(
'delegate_subagent',
);
expect(lastText(result.messages)).toContain(`PARENT_SAW_${SENTINEL}`);
const delegateToolCall = result.toolCalls?.find(
(toolCall) => toolCall.tool === 'delegate_subagent',
);
const delegateOutput = delegateToolCall?.output;
if (!isDelegateOutput(delegateOutput)) {
throw new Error('delegate_subagent did not return the expected output shape');
}
expect(delegateOutput.status).toBe('completed');
expect(delegateOutput.runId).toBeDefined();
expect(delegateOutput.answer).toContain(SENTINEL);
expect(delegateOutput.usage?.totalTokens).toBeGreaterThan(0);
expect(delegateOutput.taskPath).toMatch(/^\/root\/[a-z0-9_]+$/);
} finally {
await parent.close();
await child.close();
}
}, 60_000);
});
function lastText(messages: AgentMessage[]): string {
const llmMessages = filterLlmMessages(messages);
for (let i = llmMessages.length - 1; i >= 0; i--) {
const message = llmMessages[i];
if (!message) continue;
const text = message.content.find((content) => content.type === 'text');
if (text?.type === 'text') return text.text;
}
return '';
}
function isDelegateOutput(value: unknown): value is {
status: 'completed' | 'failed';
taskPath: string;
runId: string;
answer: string;
usage: { totalTokens: number };
} {
return (
typeof value === 'object' &&
value !== null &&
'status' in value &&
'taskPath' in value &&
'runId' in value &&
'answer' in value &&
'usage' in value
);
}

View File

@ -224,56 +224,3 @@ describe('getState()', () => {
expect(state.persistence?.threadId).toBe('thread-abc');
});
});
// ---------------------------------------------------------------------------
// asTool()
// ---------------------------------------------------------------------------
describe('asTool()', () => {
it('wraps the agent as a BuiltTool with the correct name and description', () => {
const agent = createSimpleAgent();
const tool = agent.asTool('A helpful assistant tool');
expect(tool.name).toBe('events-test-agent');
expect(tool.description).toBe('A helpful assistant tool');
expect(tool.inputSchema).toBeDefined();
expect(typeof tool.handler).toBe('function');
});
it('asTool handler calls the agent and returns text result', async () => {
const agent = createSimpleAgent();
const tool = agent.asTool('A helpful assistant tool');
const result = await tool.handler!({ input: 'Say "pong"' }, {});
expect(result).toHaveProperty('result');
expect(typeof (result as { result: string }).result).toBe('string');
expect((result as { result: string }).result.length).toBeGreaterThan(0);
});
it('coordinator agent can use sub-agent via asTool', async () => {
const specialist = new Agent('specialist')
.model(getModel('anthropic'))
.instructions('You are a specialist. When asked, reply with exactly "SPECIALIST_RESPONSE".');
const coordinator = new Agent('coordinator')
.model(getModel('anthropic'))
.instructions(
'You coordinate tasks. Use the specialist tool to answer questions. Relay the exact response.',
)
.tool(specialist.asTool('A specialist agent'));
const result = await coordinator.generate(
'Ask the specialist for their response and tell me what they said.',
);
const text = result.messages
.filter((m) => 'role' in m && m.role === 'assistant')
.flatMap((m) => ('content' in m ? m.content : []))
.filter((c) => c.type === 'text')
.map((c) => ('text' in c ? c.text : ''))
.join('');
expect(text.length).toBeGreaterThan(0);
});
});

View File

@ -1,88 +0,0 @@
import { expect, it } from 'vitest';
import {
chunksOfType,
collectStreamChunks,
collectTextDeltas,
describeIf,
getModel,
} from './helpers';
import { Agent } from '../../index';
const describe = describeIf('anthropic');
describe('sub-agent (asTool) integration', () => {
it('orchestrator calls a sub-agent as a tool and gets its response', async () => {
const mathAgent = new Agent('math-specialist')
.model(getModel('anthropic'))
.instructions(
'You are a math specialist. When given a math problem, compute the answer and reply with just the number. No explanation.',
);
const orchestrator = new Agent('orchestrator')
.model(getModel('anthropic'))
.instructions(
'You are a coordinator. When asked a math question, delegate to the math_specialist tool. ' +
'Pass the question as the prompt. Then relay the answer back.',
)
.tool(mathAgent.asTool('A math specialist that can solve math problems'));
const { stream: fullStream } = await orchestrator.stream('What is 15 * 4?');
const chunks = await collectStreamChunks(fullStream);
const text = collectTextDeltas(chunks);
const toolResults = chunksOfType(chunks, 'tool-result');
// The orchestrator should have called the sub-agent tool
expect(toolResults.length).toBeGreaterThan(0);
const mathCall = toolResults.find((tc) => tc.toolName === 'math-specialist');
expect(mathCall).toBeDefined();
// The output should contain the sub-agent's response
expect(mathCall!.output).toBeDefined();
// The final text should reference 60
expect(text).toBeTruthy();
expect(text).toContain('60');
});
it('handles a chain of two sub-agents', async () => {
const translatorAgent = new Agent('translator')
.model(getModel('anthropic'))
.instructions(
'You are a translator. Translate the given text to French. Reply with only the French translation.',
);
const uppercaseAgent = new Agent('uppercaser')
.model(getModel('anthropic'))
.instructions(
'You convert text to uppercase. Reply with the input text in all uppercase letters. Nothing else.',
);
const orchestrator = new Agent('chain-orchestrator')
.model(getModel('anthropic'))
.instructions(
'You are a coordinator with two tools. ' +
'When asked to translate and uppercase text: ' +
'1. First use the translator tool to translate to French. ' +
'2. Then use the uppercaser tool to convert the French text to uppercase. ' +
'Return the final uppercase French text.',
)
.tool(translatorAgent.asTool('Translates text to French'))
.tool(uppercaseAgent.asTool('Converts text to uppercase'));
const { stream: fullStream } = await orchestrator.stream(
'Translate "hello" to French and then make it uppercase.',
);
const chunks = await collectStreamChunks(fullStream);
const toolResults = chunksOfType(chunks, 'tool-result');
// Should have called both tools
expect(toolResults.length).toBeGreaterThanOrEqual(2);
const text = collectTextDeltas(chunks);
expect(text).toBeTruthy();
// The result should contain BONJOUR (or SALUT) — uppercase French for hello
expect(text).toMatch(/BONJOUR/i);
});
});

View File

@ -74,81 +74,6 @@ describeAnthropic('usage and cost (Anthropic)', () => {
expect(finish.usage!.cost).toBeDefined();
expect(finish.usage!.cost).toBeGreaterThan(0);
});
it('aggregates sub-agent usage when using asTool()', async () => {
const subAgent = new Agent('translator')
.model(getModel('anthropic'))
.instructions('Translate the input to French. Reply with only the translation.');
const parentAgent = new Agent('orchestrator')
.model(getModel('anthropic'))
.instructions(
'You are an orchestrator. When asked to translate, use the translator tool. Be concise.',
)
.tool(subAgent.asTool('Translate text to French'));
const result = await parentAgent.generate('Translate "hello world" to French');
// Parent should have its own usage
expect(result.usage).toBeDefined();
expect(result.usage!.promptTokens).toBeGreaterThan(0);
expect(result.usage!.cost).toBeGreaterThan(0);
expect(result.model).toBe(getModel('anthropic'));
// Sub-agent usage should be captured
expect(result.subAgentUsage).toBeDefined();
expect(result.subAgentUsage!.length).toBeGreaterThan(0);
const translatorUsage = result.subAgentUsage!.find((s) => s.agent === 'translator');
expect(translatorUsage).toBeDefined();
expect(translatorUsage!.usage.promptTokens).toBeGreaterThan(0);
expect(translatorUsage!.usage.cost).toBeGreaterThan(0);
// Total cost should be parent + sub-agent
expect(result.totalCost).toBeDefined();
expect(result.totalCost!).toBeGreaterThan(result.usage!.cost!);
expect(result.totalCost!).toBeCloseTo(result.usage!.cost! + translatorUsage!.usage.cost!, 6);
});
it('aggregates sub-agent usage via stream()', async () => {
const subAgent = new Agent('stream-translator')
.model(getModel('anthropic'))
.instructions('Translate the input to French. Reply with only the translation.');
const parentAgent = new Agent('stream-orchestrator')
.model(getModel('anthropic'))
.instructions(
'You are an orchestrator. When asked to translate, use the stream-translator tool. Be concise.',
)
.tool(subAgent.asTool('Translate text to French'));
const { stream: fullStream } = await parentAgent.stream('Translate "goodbye" to French');
const chunks = await collectStreamChunks(fullStream);
const finishChunks = chunksOfType(chunks, 'finish');
expect(finishChunks.length).toBeGreaterThan(0);
const finish = finishChunks[finishChunks.length - 1] as StreamChunk & { type: 'finish' };
// Should have usage with cost
expect(finish.usage).toBeDefined();
expect(finish.usage!.cost).toBeGreaterThan(0);
// Should include model
expect(finish.model).toBe(getModel('anthropic'));
// Should include sub-agent usage
expect(finish.subAgentUsage).toBeDefined();
expect(finish.subAgentUsage!.length).toBeGreaterThan(0);
const translatorUsage = finish.subAgentUsage!.find((s) => s.agent === 'stream-translator');
expect(translatorUsage).toBeDefined();
expect(translatorUsage!.usage.promptTokens).toBeGreaterThan(0);
expect(translatorUsage!.usage.cost).toBeGreaterThan(0);
// Total cost should include parent + sub-agent
expect(finish.totalCost).toBeDefined();
expect(finish.totalCost!).toBeGreaterThan(finish.usage!.cost!);
});
});
const describeOpenAI = describeIf('openai');

View File

@ -19,7 +19,6 @@ export type {
InterruptibleToolContext,
CheckpointStore,
StreamChunk,
SubAgentUsage,
Provider,
ThinkingConfig,
ThinkingConfigFor,
@ -161,7 +160,6 @@ export type {
CredentialListItem,
} from './types';
export { McpClient } from './sdk/mcp-client';
export { Network } from './sdk/network';
export { providerTools } from './sdk/provider-tools';
export { verify } from './sdk/verify';
export type { VerifyResult } from './sdk/verify';
@ -198,6 +196,29 @@ export { BaseMemory } from './storage/base-memory';
export type { ToolDescriptor } from './types/sdk/tool-descriptor';
export { createModel } from './runtime/model-factory';
export {
ROOT_SUB_AGENT_TASK_PATH,
assertSubAgentPolicyAllowsChild,
assertSubAgentPolicyAllowsChildCount,
assertSubAgentTaskPath,
createChildSubAgentTaskPath,
isSubAgentTaskPath,
sanitizeSubAgentTaskName,
} from './runtime/sub-agent-task-path';
export type { SubAgentTaskPath, SubAgentTaskPathPolicy } from './runtime/sub-agent-task-path';
export {
DELEGATE_SUB_AGENT_TOOL_NAME,
createDelegateSubAgentTool,
generateResultToDelegateSubAgentOutput,
renderDelegateSubAgentPrompt,
} from './runtime/delegate-sub-agent-tool';
export type {
CreateDelegateSubAgentToolOptions,
DelegateSubAgentInput,
DelegateSubAgentPolicy,
DelegateSubAgentRequest,
DelegateSubAgentToolOutput,
} from './runtime/delegate-sub-agent-tool';
export { createEmbeddingModel } from './runtime/model-factory';
export { generateTitleFromMessage } from './runtime/title-generation';
export {

View File

@ -9,7 +9,7 @@ import type { AgentEventData } from '../../types/runtime/event';
import type { StreamChunk } from '../../types/sdk/agent';
import type { BuiltMemory } from '../../types/sdk/memory';
import type { ContentToolCall, Message } from '../../types/sdk/message';
import type { BuiltTool, InterruptibleToolContext } from '../../types/sdk/tool';
import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../../types/sdk/tool';
import type { BuiltTelemetry } from '../../types/telemetry';
import { AgentRuntime } from '../agent-runtime';
import { AgentEventBus } from '../event-bus';
@ -144,6 +144,60 @@ function makeStreamSuccess(text = 'Hello') {
};
}
/**
* streamText response where the model invokes a provider-executed tool (e.g.
* native web search): the SDK streams a `tool-call` and its terminal part
* (`tool-result` on success, `tool-error` on failure) with `providerExecuted`,
* then finishes with `stop` (the provider runs the tool server-side mid-step).
*/
function makeStreamWithProviderTool(opts: {
toolCallId: string;
toolName: string;
input: unknown;
output?: unknown;
error?: unknown;
text?: string;
}) {
const terminal =
opts.error !== undefined
? {
type: 'tool-error',
toolCallId: opts.toolCallId,
toolName: opts.toolName,
input: opts.input,
error: opts.error,
providerExecuted: true,
}
: {
type: 'tool-result',
toolCallId: opts.toolCallId,
toolName: opts.toolName,
input: opts.input,
output: opts.output,
providerExecuted: true,
};
const text = opts.text ?? 'done';
return {
fullStream: makeChunkStream([
{
type: 'tool-call',
toolCallId: opts.toolCallId,
toolName: opts.toolName,
input: opts.input,
providerExecuted: true,
},
terminal,
{ type: 'text-delta', textDelta: text },
]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
messages: [{ role: 'assistant', content: [{ type: 'text', text }] }],
}),
toolCalls: Promise.resolve([]),
};
}
/** Build a default runtime wired to the shared eventBus for inspection. */
function createRuntime(eventBus?: AgentEventBus) {
const bus = eventBus ?? new AgentEventBus();
@ -1435,6 +1489,80 @@ describe('AgentRuntime — concurrent tool execution', () => {
expect(finishChunks.length).toBe(1);
expect((finishChunks[0] as StreamChunk & { type: 'finish' }).finishReason).toBe('tool-calls');
});
it('bridges subagent lifecycle events from tool context into stream chunks', async () => {
const lifecycleTool: BuiltTool = {
name: 'delegate_subagent',
description: 'Delegate work',
inputSchema: z.object({ value: z.string().optional() }),
handler: async (_input, ctx) => {
const toolCtx = ctx as ToolContext;
const base = {
taskName: 'Research API',
taskPath: '/root/research_api',
...(toolCtx.runId !== undefined ? { parentRunId: toolCtx.runId } : {}),
...(toolCtx.toolCallId !== undefined ? { parentToolCallId: toolCtx.toolCallId } : {}),
};
toolCtx.emitEvent?.({
type: AgentEvent.SubAgentStarted,
...base,
startedAt: 100,
});
toolCtx.emitEvent?.({
type: AgentEvent.SubAgentCompleted,
...base,
status: 'completed',
startedAt: 100,
finishedAt: 200,
durationMs: 100,
runId: 'child-run-1',
finishReason: 'stop',
});
return await Promise.resolve({ ok: true });
},
};
const { runtime } = createRuntimeWithTools([lifecycleTool], 1);
streamText
.mockReturnValueOnce({
fullStream: makeChunkStream([{ type: 'text-delta', textDelta: 'thinking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
messages: [
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'delegate_subagent',
args: { value: 'a' },
},
],
},
],
}),
toolCalls: Promise.resolve([
{ toolCallId: 'tc-1', toolName: 'delegate_subagent', input: { value: 'a' } },
]),
})
.mockReturnValueOnce(makeStreamSuccess('done'));
const { stream: readableStream } = await runtime.stream('run tools');
const chunks = await collectChunks(readableStream);
expect(chunks).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'subagent-started', taskPath: '/root/research_api' }),
expect.objectContaining({
type: 'subagent-completed',
status: 'completed',
runId: 'child-run-1',
}),
]),
);
});
});
// Structured output — generate()
@ -1518,6 +1646,80 @@ describe('AgentRuntime.generate() — structured output', () => {
});
});
// ---------------------------------------------------------------------------
// Provider-executed tool timing — stream()
// ---------------------------------------------------------------------------
describe('AgentRuntime.stream() — provider-executed tool timing', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('emits tool-execution-start/end for a provider-executed tool result', async () => {
streamText.mockReturnValue(
makeStreamWithProviderTool({
toolCallId: 'tc-ws',
toolName: 'web_search',
input: { query: 'n8n' },
output: [{ url: 'https://n8n.io' }],
}),
);
const { runtime } = createRuntime();
const { stream } = await runtime.stream('search please');
const chunks = await collectChunks(stream);
const start = chunks.find(
(c): c is Extract<StreamChunk, { type: 'tool-execution-start' }> =>
c.type === 'tool-execution-start' && c.toolCallId === 'tc-ws',
);
const end = chunks.find(
(c): c is Extract<StreamChunk, { type: 'tool-execution-end' }> =>
c.type === 'tool-execution-end' && c.toolCallId === 'tc-ws',
);
expect(start).toBeDefined();
expect(start?.toolName).toBe('web_search');
expect(typeof start?.startTime).toBe('number');
expect(end).toBeDefined();
expect(end?.isError).toBe(false);
expect(typeof end?.endTime).toBe('number');
});
it('emits tool-execution-end with isError on a provider-executed tool error', async () => {
streamText.mockReturnValue(
makeStreamWithProviderTool({
toolCallId: 'tc-ws-err',
toolName: 'web_search',
input: { query: 'n8n' },
error: new Error('search failed'),
}),
);
const { runtime } = createRuntime();
const { stream } = await runtime.stream('search please');
const chunks = await collectChunks(stream);
const end = chunks.find(
(c): c is Extract<StreamChunk, { type: 'tool-execution-end' }> =>
c.type === 'tool-execution-end' && c.toolCallId === 'tc-ws-err',
);
expect(end).toBeDefined();
expect(end?.isError).toBe(true);
expect(typeof end?.endTime).toBe('number');
const toolResult = chunks.find(
(c): c is Extract<StreamChunk, { type: 'tool-result' }> =>
c.type === 'tool-result' && c.toolCallId === 'tc-ws-err',
);
expect(toolResult).toBeDefined();
expect(toolResult?.isError).toBe(true);
expect(toolResult?.output).toEqual(new Error('search failed'));
});
});
// ---------------------------------------------------------------------------
// Structured output — stream()
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,290 @@
import { AgentEvent, type AgentEventData } from '../../types/runtime/event';
import type { GenerateResult } from '../../types/sdk/agent';
import {
DELEGATE_SUB_AGENT_TOOL_NAME,
createDelegateSubAgentTool,
generateResultToDelegateSubAgentOutput,
renderDelegateSubAgentPrompt,
type DelegateSubAgentRequest,
type DelegateSubAgentToolOutput,
} from '../delegate-sub-agent-tool';
const input = {
taskName: 'Research API',
goal: 'Find the API behavior.',
context: 'Focus on auth endpoints.',
expectedOutput: 'A short summary.',
};
describe('createDelegateSubAgentTool', () => {
it('creates the delegate_subagent tool', () => {
const tool = createDelegateSubAgentTool({
runSubAgent: async () =>
await Promise.resolve({
status: 'completed',
taskPath: '/root/research_api',
runId: 'child-run-1',
answer: 'done',
}),
});
expect(tool.name).toBe(DELEGATE_SUB_AGENT_TOOL_NAME);
expect(tool.description).toContain('focused child agent');
expect(tool.inputSchema).toBeDefined();
expect(tool.outputSchema).toBeDefined();
});
it('passes model input and parent runtime context to the runner callback', async () => {
const runSubAgent = vi
.fn<(request: DelegateSubAgentRequest) => Promise<DelegateSubAgentToolOutput>>()
.mockResolvedValue({
status: 'completed',
taskPath: '/root/research_api',
runId: 'child-run-1',
answer: 'done',
});
const tool = createDelegateSubAgentTool({
policy: { maxChildren: 2 },
runSubAgent,
});
await tool.handler?.(input, {
runId: 'parent-run-1',
toolCallId: 'tool-call-1',
});
expect(runSubAgent).toHaveBeenCalledWith({
...input,
taskPath: '/root/research_api_0',
parentRunId: 'parent-run-1',
parentToolCallId: 'tool-call-1',
childCount: 0,
policy: { maxChildren: 2 },
});
});
it('forwards the parent persistence thread id and resource id', async () => {
const runSubAgent = vi
.fn<(request: DelegateSubAgentRequest) => Promise<DelegateSubAgentToolOutput>>()
.mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' });
const tool = createDelegateSubAgentTool({ runSubAgent });
await tool.handler?.(input, {
runId: 'parent-run-1',
persistence: { threadId: 'parent-thread-1', resourceId: 'resource-1' },
});
expect(runSubAgent).toHaveBeenCalledWith(
expect.objectContaining({
parentThreadId: 'parent-thread-1',
parentResourceId: 'resource-1',
}),
);
});
it('omits parent persistence fields when the parent run has no persistence scope', async () => {
const runSubAgent = vi
.fn<(request: DelegateSubAgentRequest) => Promise<DelegateSubAgentToolOutput>>()
.mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' });
const tool = createDelegateSubAgentTool({ runSubAgent });
await tool.handler?.(input, { runId: 'parent-run-1' });
expect(runSubAgent.mock.calls[0]?.[0]).not.toHaveProperty('parentThreadId');
expect(runSubAgent.mock.calls[0]?.[0]).not.toHaveProperty('parentResourceId');
expect(runSubAgent.mock.calls[0]?.[0]).not.toHaveProperty('parentAbortSignal');
});
it('forwards the parent run abort signal to the runner callback', async () => {
const runSubAgent = vi
.fn<(request: DelegateSubAgentRequest) => Promise<DelegateSubAgentToolOutput>>()
.mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' });
const tool = createDelegateSubAgentTool({ runSubAgent });
const controller = new AbortController();
await tool.handler?.(input, { runId: 'parent-run-1', abortSignal: controller.signal });
expect(runSubAgent).toHaveBeenCalledWith(
expect.objectContaining({ parentAbortSignal: controller.signal }),
);
});
it('emits lifecycle events around runner callback execution', async () => {
const events: AgentEventData[] = [];
const tool = createDelegateSubAgentTool({
runSubAgent: async () =>
await Promise.resolve({
status: 'completed',
taskPath: '/root/research_api',
runId: 'child-run-1',
threadId: 'child-thread-1',
answer: 'done',
usage: {
promptTokens: 3,
completionTokens: 2,
totalTokens: 5,
},
finishReason: 'stop',
}),
});
await tool.handler?.(input, {
runId: 'parent-run-1',
toolCallId: 'tool-call-1',
emitEvent: (event) => events.push(event),
});
expect(events.map((event) => event.type)).toEqual([
AgentEvent.SubAgentStarted,
AgentEvent.SubAgentCompleted,
]);
expect(events[0]).toMatchObject({
taskName: 'Research API',
taskPath: '/root/research_api_0',
parentRunId: 'parent-run-1',
parentToolCallId: 'tool-call-1',
});
expect(events[1]).toMatchObject({
status: 'completed',
runId: 'child-run-1',
threadId: 'child-thread-1',
usage: { totalTokens: 5 },
finishReason: 'stop',
});
});
it('tracks child count per parent run id', async () => {
const runSubAgent = vi
.fn<(request: DelegateSubAgentRequest) => Promise<DelegateSubAgentToolOutput>>()
.mockResolvedValue({
status: 'completed',
taskPath: '/root/research_api',
runId: 'child-run-1',
answer: 'done',
});
const tool = createDelegateSubAgentTool({
policy: { maxChildren: 1 },
runSubAgent,
});
await expect(tool.handler?.(input, { runId: 'parent-run-1' })).resolves.toMatchObject({
status: 'completed',
});
await expect(tool.handler?.(input, { runId: 'parent-run-1' })).resolves.toMatchObject({
status: 'failed',
error: 'Sub-agent child count 2 exceeds maxChildren 1',
});
await expect(tool.handler?.(input, { runId: 'parent-run-2' })).resolves.toMatchObject({
status: 'completed',
});
expect(runSubAgent).toHaveBeenCalledTimes(2);
});
it('returns a failed output when the runner callback throws', async () => {
const events: AgentEventData[] = [];
const tool = createDelegateSubAgentTool({
runSubAgent: async () => await Promise.reject(new Error('Runner failed')),
});
await expect(
tool.handler?.(input, {
runId: 'parent-run-1',
emitEvent: (event) => events.push(event),
}),
).resolves.toMatchObject({
status: 'failed',
taskPath: '/root/research_api_0',
answer: '',
error: 'Runner failed',
});
expect(events[events.length - 1]).toMatchObject({
type: AgentEvent.SubAgentCompleted,
status: 'failed',
error: 'Runner failed',
});
});
it('returns a failed output for invalid task names', async () => {
const runSubAgent = vi.fn();
const tool = createDelegateSubAgentTool({ runSubAgent });
await expect(
tool.handler?.({ ...input, taskName: '!!!' }, { runId: 'parent-run-1' }),
).resolves.toMatchObject({
status: 'failed',
answer: '',
error: 'Sub-agent task name must contain at least one alphanumeric character',
});
expect(runSubAgent).not.toHaveBeenCalled();
});
});
describe('renderDelegateSubAgentPrompt', () => {
it('includes the goal and omits unset sections', () => {
const prompt = renderDelegateSubAgentPrompt({ goal: 'Find it.' });
expect(prompt).toContain('Goal:\nFind it.');
expect(prompt).not.toContain('Context:');
expect(prompt).not.toContain('Expected output:');
});
it('includes context and expected output when provided', () => {
const prompt = renderDelegateSubAgentPrompt({
goal: 'Find it.',
context: 'auth endpoints',
expectedOutput: 'a summary',
});
expect(prompt).toContain('Goal:\nFind it.');
expect(prompt).toContain('Context:\nauth endpoints');
expect(prompt).toContain('Expected output:\na summary');
});
});
describe('generateResultToDelegateSubAgentOutput', () => {
it('maps a successful GenerateResult to the tool output', () => {
const result: GenerateResult = {
runId: 'child-run-1',
messages: [
{
role: 'assistant',
type: 'llm',
content: [
{ type: 'text', text: 'preamble' },
{ type: 'text', text: 'answer' },
],
},
],
finishReason: 'stop',
usage: { promptTokens: 3, completionTokens: 2, totalTokens: 5 },
};
expect(
generateResultToDelegateSubAgentOutput('/root/research_api_0', result, 'child-thread-1'),
).toEqual({
status: 'completed',
taskPath: '/root/research_api_0',
runId: 'child-run-1',
threadId: 'child-thread-1',
answer: 'preamble\nanswer',
usage: { promptTokens: 3, completionTokens: 2, totalTokens: 5 },
finishReason: 'stop',
});
});
it('marks an errored result as failed', () => {
const result: GenerateResult = {
runId: 'child-run-2',
messages: [],
finishReason: 'error',
error: new Error('boom'),
};
expect(generateResultToDelegateSubAgentOutput('/root/x_0', result)).toMatchObject({
status: 'failed',
answer: '',
error: 'boom',
});
});
});

View File

@ -3,6 +3,7 @@ import type { TextStreamPart, ToolSet } from 'ai';
import { convertChunk } from '../stream';
type ToolCallChunk = Extract<TextStreamPart<ToolSet>, { type: 'tool-call' }>;
type ToolErrorChunk = Extract<TextStreamPart<ToolSet>, { type: 'tool-error' }>;
type ToolResultChunk = Extract<TextStreamPart<ToolSet>, { type: 'tool-result' }>;
describe('convertChunk — tool-call invalid/error handling', () => {
@ -138,3 +139,25 @@ describe('convertChunk — tool-result output passthrough', () => {
});
});
});
describe('convertChunk — tool-error handling', () => {
it('maps provider-executed tool-error to tool-result with isError', () => {
const error = new Error('search failed');
const chunk = {
type: 'tool-error',
toolCallId: 'tc-err',
toolName: 'web_search',
input: { query: 'n8n' },
error,
providerExecuted: true,
} as unknown as ToolErrorChunk;
expect(convertChunk(chunk)).toEqual({
type: 'tool-result',
toolCallId: 'tc-err',
toolName: 'web_search',
output: error,
isError: true,
});
});
});

View File

@ -0,0 +1,74 @@
import {
ROOT_SUB_AGENT_TASK_PATH,
assertSubAgentPolicyAllowsChild,
assertSubAgentPolicyAllowsChildCount,
assertSubAgentTaskPath,
createChildSubAgentTaskPath,
isSubAgentTaskPath,
sanitizeSubAgentTaskName,
} from '../sub-agent-task-path';
describe('sub-agent task paths', () => {
it('sanitizes display task names into path segments', () => {
expect(sanitizeSubAgentTaskName('Research API')).toBe('research_api');
expect(sanitizeSubAgentTaskName('Check tests!!!')).toBe('check_tests');
expect(sanitizeSubAgentTaskName('__Already---Messy__')).toBe('already_messy');
});
it('rejects task names without alphanumeric content', () => {
expect(() => sanitizeSubAgentTaskName(' ')).toThrow('task name');
expect(() => sanitizeSubAgentTaskName('!!!')).toThrow('task name');
});
it('recognizes valid flat task paths', () => {
expect(isSubAgentTaskPath(ROOT_SUB_AGENT_TASK_PATH)).toBe(true);
expect(isSubAgentTaskPath('/root/research')).toBe(true);
});
it('rejects nested task paths', () => {
expect(isSubAgentTaskPath('/root/research/check_tests')).toBe(false);
});
it('rejects malformed task paths', () => {
for (const path of [
'',
'root',
'/',
'/root/',
'/root//child',
'/root/../child',
'/Root/child',
'/root/child with spaces',
'/root/research/check_tests',
]) {
expect(isSubAgentTaskPath(path)).toBe(false);
expect(() => assertSubAgentTaskPath(path)).toThrow('Invalid sub-agent task path');
}
});
it('creates first-level child paths under root', () => {
expect(createChildSubAgentTaskPath('Research API', 0)).toBe('/root/research_api_0');
});
it('disambiguates same-named siblings by child index', () => {
const first = createChildSubAgentTaskPath('research', 0);
const second = createChildSubAgentTaskPath('research', 1);
expect(first).toBe('/root/research_0');
expect(second).toBe('/root/research_1');
expect(first).not.toBe(second);
});
it('enforces spawn policy before creating a child', () => {
expect(() => assertSubAgentPolicyAllowsChild({ canSpawnSubAgents: false })).toThrow(
'does not allow',
);
expect(() => assertSubAgentPolicyAllowsChild(undefined)).not.toThrow();
});
it('enforces max child count policy', () => {
expect(() => assertSubAgentPolicyAllowsChildCount(1, { maxChildren: 2 })).not.toThrow();
expect(() => assertSubAgentPolicyAllowsChildCount(2, { maxChildren: 2 })).toThrow(
'exceeds maxChildren',
);
});
});

View File

@ -2,7 +2,7 @@ import type { JSONSchema7 } from 'json-schema';
import { z } from 'zod';
import type { BuiltTool } from '../../types';
import { toAiSdkTools } from '../tool-adapter';
import { executeTool, toAiSdkTools } from '../tool-adapter';
// ---------------------------------------------------------------------------
// Module mocks
@ -191,3 +191,34 @@ describe('toAiSdkTools — description forwarding', () => {
expect((result['myTool'] as { description: string }).description).toBe('Does something useful');
});
});
// ---------------------------------------------------------------------------
// executeTool — context propagation
// ---------------------------------------------------------------------------
describe('executeTool — context propagation', () => {
it('passes the run abort signal to the tool handler', async () => {
const handler = vi.fn().mockResolvedValue('ok');
const tool: BuiltTool = { name: 'cancellable', description: 'd', handler };
const { signal } = new AbortController();
await executeTool({}, tool, undefined, undefined, 'call-1', { abortSignal: signal });
expect(handler).toHaveBeenCalledWith({}, expect.objectContaining({ abortSignal: signal }));
});
it('passes the run abort signal to interruptible tool handlers', async () => {
const handler = vi.fn().mockResolvedValue('ok');
const tool: BuiltTool = {
name: 'interruptible',
description: 'd',
handler,
suspendSchema: z.object({}),
};
const { signal } = new AbortController();
await executeTool({}, tool, undefined, undefined, 'call-1', { abortSignal: signal });
expect(handler).toHaveBeenCalledWith({}, expect.objectContaining({ abortSignal: signal }));
});
});

View File

@ -30,7 +30,6 @@ import type {
SerializableAgentState,
StreamChunk,
StreamResult,
SubAgentUsage,
ThinkingConfig,
TitleGenerationConfig,
TokenUsage,
@ -66,7 +65,6 @@ import { hasObservationLogStore, hasObservationLogTaskLockStore } from './observ
import { generateRunId, RunStateManager } from './run-state';
import {
accumulateUsage,
applySubAgentUsage,
extractSettledToolCalls,
makeErrorStream,
normalizeInput,
@ -78,7 +76,6 @@ import { generateThreadTitle } from './title-generation';
import {
buildToolMap,
executeTool,
isAgentToolResult,
isSuspendedToolResult,
toAiSdkProviderTools,
toAiSdkTools,
@ -262,7 +259,6 @@ type ToolCallOutcome =
* LLM saw (rather than the larger raw output).
*/
modelOutput: unknown;
subAgentUsage?: SubAgentUsage[];
customMessage?: AgentMessage;
}
| {
@ -280,7 +276,6 @@ interface ToolCallSuccess {
input: JSONValue;
toolEntry: ToolResultEntry;
modelOutput: unknown;
subAgentUsage?: SubAgentUsage[];
customMessage?: AgentMessage;
}
@ -803,10 +798,9 @@ export class AgentRuntime {
result.runId = runId;
result.usage = this.applyCost(result.usage);
result.model = this.modelIdString;
const finalized = applySubAgentUsage(result);
this.updateState({ status: 'success', messageList: list.serialize() });
this.eventBus.emit({ type: AgentEvent.AgentEnd, messages: finalized.messages });
return finalized;
this.eventBus.emit({ type: AgentEvent.AgentEnd, messages: result.messages });
return result;
}
/** Resolve telemetry: own config wins, then inherited from options, then nothing. */
@ -1011,7 +1005,6 @@ export class AgentRuntime {
let lastFinishReason: FinishReason = 'stop';
let structuredOutput: unknown;
const toolCallSummary: ToolResultEntry[] = [];
const collectedSubAgentUsage: SubAgentUsage[] = [];
// Resolve pending tool calls from a resumed run before the first LLM call.
const runTelemetry = this.resolveTelemetry(options);
@ -1044,7 +1037,6 @@ export class AgentRuntime {
for (const r of batch.results) {
toolCallSummary.push(r.toolEntry);
if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage);
}
if (Object.keys(batch.pending).length > 0) {
@ -1132,7 +1124,6 @@ export class AgentRuntime {
for (const r of batch.results) {
toolCallSummary.push(r.toolEntry);
if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage);
}
if (Object.keys(batch.pending).length > 0) {
@ -1195,7 +1186,6 @@ export class AgentRuntime {
usage: totalUsage,
...(structuredOutput !== undefined && { structuredOutput }),
...(toolCallSummary.length > 0 && { toolCalls: toolCallSummary }),
...(collectedSubAgentUsage.length > 0 && { subAgentUsage: collectedSubAgentUsage }),
};
}
@ -1212,20 +1202,45 @@ export class AgentRuntime {
// can show a mid-flight indicator between the LLM's tool-call message
// and the eventual tool-result message. Writer queues writes in order
// so the fire-and-forget is safe.
const onToolExecutionStart = (data: AgentEventData): void => {
if (data.type !== AgentEvent.ToolExecutionStart) return;
const writeEventChunk = (chunk: StreamChunk): void => {
// Swallow rejections: if the writer is already closed/errored (e.g.
// an abort raced ahead of the subscription cleanup) there is nothing
// useful to do with the chunk.
writer
.write({
type: 'tool-execution-start',
toolCallId: data.toolCallId,
toolName: data.toolName,
})
.catch(() => {});
writer.write(chunk).catch(() => {});
};
const onToolExecutionStart = (data: AgentEventData): void => {
if (data.type !== AgentEvent.ToolExecutionStart) return;
writeEventChunk({
type: 'tool-execution-start',
toolCallId: data.toolCallId,
toolName: data.toolName,
startTime: Date.now(),
});
};
const onToolExecutionEnd = (data: AgentEventData): void => {
if (data.type !== AgentEvent.ToolExecutionEnd) return;
writeEventChunk({
type: 'tool-execution-end',
toolCallId: data.toolCallId,
toolName: data.toolName,
isError: data.isError,
endTime: Date.now(),
});
};
const onSubAgentStarted = (data: AgentEventData): void => {
if (data.type !== AgentEvent.SubAgentStarted) return;
const { type: _type, ...payload } = data;
writeEventChunk({ type: 'subagent-started', ...payload });
};
const onSubAgentCompleted = (data: AgentEventData): void => {
if (data.type !== AgentEvent.SubAgentCompleted) return;
const { type: _type, ...payload } = data;
writeEventChunk({ type: 'subagent-completed', ...payload });
};
this.eventBus.on(AgentEvent.ToolExecutionStart, onToolExecutionStart);
this.eventBus.on(AgentEvent.ToolExecutionEnd, onToolExecutionEnd);
this.eventBus.on(AgentEvent.SubAgentStarted, onSubAgentStarted);
this.eventBus.on(AgentEvent.SubAgentCompleted, onSubAgentCompleted);
this.withTelemetryRootSpan(
'stream',
@ -1246,6 +1261,9 @@ export class AgentRuntime {
})
.finally(() => {
this.eventBus.off(AgentEvent.ToolExecutionStart, onToolExecutionStart);
this.eventBus.off(AgentEvent.ToolExecutionEnd, onToolExecutionEnd);
this.eventBus.off(AgentEvent.SubAgentStarted, onSubAgentStarted);
this.eventBus.off(AgentEvent.SubAgentCompleted, onSubAgentCompleted);
});
return readable;
@ -1265,7 +1283,6 @@ export class AgentRuntime {
let totalUsage: TokenUsage | undefined;
let lastFinishReason: FinishReason = 'stop';
let structuredOutput: unknown;
const collectedSubAgentUsage: SubAgentUsage[] = [];
const maxIterations = options?.maxIterations ?? MAX_LOOP_ITERATIONS;
let iterationCount = options?.iterationCount ?? 0;
let reachedStopCondition = false;
@ -1312,7 +1329,6 @@ export class AgentRuntime {
});
for (const r of batch.results) {
if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage);
await writer.write({
type: 'tool-result',
toolCallId: r.toolCallId,
@ -1398,8 +1414,37 @@ export class AgentRuntime {
// `start-step` / `finish-step` are passed through so consumers
// can use them as LLM-iteration boundaries.
if (chunk.type === 'finish') continue;
// Provider-executed tools (e.g. native web search) skip the
// local execution loop that emits tool-execution lifecycle
// events via the event bus. Stamp them here at chunk-arrival
// time so live chat and the persisted timeline both show a
// duration. A failed call arrives as a `tool-error` part
// (never a `tool-result`), so close its timing there too.
if (
(chunk.type === 'tool-result' || chunk.type === 'tool-error') &&
chunk.providerExecuted
) {
await writeChunk({
type: 'tool-execution-end',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName ?? '',
isError: chunk.type === 'tool-error',
endTime: Date.now(),
});
}
const converted = convertChunk(chunk);
if (converted) await writeChunk(converted);
if (chunk.type === 'tool-call' && chunk.providerExecuted) {
await writeChunk({
type: 'tool-execution-start',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName ?? '',
startTime: Date.now(),
});
}
}
} catch (streamError) {
if (await handleAbort()) return;
@ -1452,7 +1497,6 @@ export class AgentRuntime {
if (await handleAbort()) return;
for (const r of batch.results) {
if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage);
await writer.write({
type: 'tool-result',
toolCallId: r.toolCallId,
@ -1513,18 +1557,12 @@ export class AgentRuntime {
}
const costUsage = this.applyCost(totalUsage);
const parentCost = costUsage?.cost ?? 0;
const subCost = collectedSubAgentUsage.reduce((sum, s) => sum + (s.usage.cost ?? 0), 0);
await writer.write({
type: 'finish',
finishReason: lastFinishReason,
...(costUsage && { usage: costUsage }),
model: this.modelIdString,
...(structuredOutput !== undefined && { structuredOutput }),
...(collectedSubAgentUsage.length > 0 && {
subAgentUsage: collectedSubAgentUsage,
totalCost: parentCost + subCost,
}),
});
try {
@ -1996,7 +2034,6 @@ export class AgentRuntime {
input: toolInput,
toolEntry: result.value.toolEntry,
modelOutput: result.value.modelOutput,
subAgentUsage: result.value.subAgentUsage,
customMessage: result.value.customMessage,
});
} else if (result.value.outcome === 'error') {
@ -2099,7 +2136,6 @@ export class AgentRuntime {
input: resumedEntry.input,
toolEntry: processResult.toolEntry,
modelOutput: processResult.modelOutput,
subAgentUsage: processResult.subAgentUsage,
customMessage: processResult.customMessage,
});
} else if (processResult.outcome === 'error') {
@ -2261,6 +2297,8 @@ export class AgentRuntime {
await executeTool(toolInput, builtTool, resumeData, resolvedTelemetry, toolCallId, {
runId,
persistence,
emitEvent: (event) => this.eventBus.emit(event),
abortSignal: this.eventBus.signal,
}),
);
} catch (error) {
@ -2292,30 +2330,21 @@ export class AgentRuntime {
};
}
let actualResult = toolResult;
let extractedSubAgentUsage: SubAgentUsage[] | undefined;
if (isAgentToolResult(toolResult)) {
actualResult = toolResult.output;
extractedSubAgentUsage = toolResult.subAgentUsage;
}
this.eventBus.emit({
type: AgentEvent.ToolExecutionEnd,
toolCallId,
toolName,
result: actualResult,
result: toolResult,
isError: false,
});
// Apply toModelOutput transform: the raw result goes to history/events,
// but the transformed version is what the LLM sees as the tool result.
const modelResult = builtTool.toModelOutput
? builtTool.toModelOutput(actualResult)
: actualResult;
const modelResult = builtTool.toModelOutput ? builtTool.toModelOutput(toolResult) : toolResult;
list.setToolCallResult(toolCallId, toJsonValue(modelResult));
const customMessage = builtTool?.toMessage?.(actualResult);
const customMessage = builtTool?.toMessage?.(toolResult);
if (customMessage) {
list.addResponse([customMessage]);
}
@ -2325,11 +2354,10 @@ export class AgentRuntime {
toolEntry: {
tool: toolName,
input: toolInput,
output: actualResult,
output: toolResult,
transformed: !!builtTool.toModelOutput,
},
modelOutput: modelResult,
subAgentUsage: extractedSubAgentUsage,
customMessage,
};
}
@ -2557,7 +2585,7 @@ export class AgentRuntime {
/**
* Configured telemetry handle (build-time). Run-time inheritance via
* `ExecutionOptions.parentTelemetry` only applies inside an active
* `ExecutionOptions.telemetry` only applies inside an active
* agentic loop; out-of-band callers like `agent.reflect()` see the
* builder-time value.
*/

View File

@ -0,0 +1,417 @@
import { z } from 'zod';
import {
assertSubAgentPolicyAllowsChild,
assertSubAgentPolicyAllowsChildCount,
createChildSubAgentTaskPath,
type SubAgentTaskPath,
type SubAgentTaskPathPolicy,
} from './sub-agent-task-path';
import { filterLlmMessages } from '../sdk/message';
import { Tool } from '../sdk/tool';
import { AgentEvent } from '../types/runtime/event';
import type { FinishReason, GenerateResult, TokenUsage } from '../types/sdk/agent';
import type { AgentMessage } from '../types/sdk/message';
import type { ToolContext } from '../types/sdk/tool';
export const DELEGATE_SUB_AGENT_TOOL_NAME = 'delegate_subagent';
// Model-facing input: the arguments the LLM fills in when it calls the tool.
// The `.describe(...)` text is what the model reads, so keep it task-oriented.
const delegateSubAgentInputSchema = z.object({
subAgentId: z
.string()
.min(1)
.optional()
.describe('Configured sub-agent ID to run. Use when multiple sub-agents are available.'),
taskName: z
.string()
.min(1)
.describe('Short human-readable name for this delegated task, e.g. "research_api".'),
goal: z.string().min(1).describe('The concrete goal the sub-agent should accomplish.'),
context: z
.string()
.optional()
.describe(
'All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.',
),
expectedOutput: z.string().optional().describe('The expected shape or contents of the answer.'),
});
// Documents the tool result shape for typing/introspection. Note: the handler's
// returned object (not this schema) is what is actually sent back to the model,
// so this is kept in sync with DelegateSubAgentToolOutput by hand.
const delegateSubAgentOutputSchema = z.object({
status: z.enum(['completed', 'failed']),
taskPath: z.string().optional(),
runId: z.string().optional(),
threadId: z.string().optional(),
answer: z.string(),
structuredOutput: z.unknown().optional(),
usage: z
.object({
promptTokens: z.number().optional(),
completionTokens: z.number().optional(),
totalTokens: z.number().optional(),
cost: z.number().optional(),
})
.optional(),
finishReason: z.string().optional(),
error: z.string().optional(),
});
/** The arguments the LLM provides when calling delegate_subagent. */
export type DelegateSubAgentInput = z.infer<typeof delegateSubAgentInputSchema>;
/**
* Limits the delegate tool enforces structurally for a delegation: fan-out and
* the on/off switch (see {@link SubAgentTaskPathPolicy}).
*
* Per-run runtime constraints (e.g. a wall-clock timeout) are intentionally not
* here they're a host concern, enforced inside the `runSubAgent` callback (as
* the n8n CLI runner does).
*/
export type DelegateSubAgentPolicy = SubAgentTaskPathPolicy;
/**
* What a host's `runSubAgent` callback receives: the model's
* {@link DelegateSubAgentInput} plus runtime-derived context the host needs to
* run and link the child. All `parent*` fields come from the parent's tool
* execution context and are used for tracing/linkage, not required to run.
*/
export interface DelegateSubAgentRequest extends DelegateSubAgentInput {
/** Hierarchical id assigned to this delegation (e.g. `/root/research_api`). */
taskPath: SubAgentTaskPath;
/** Parent run id (`ctx.runId`), e.g. for memory scoping / correlation. */
parentRunId?: string;
/** Parent's persisted memory thread id (`ctx.persistence.threadId`). */
parentThreadId?: string;
/** Parent's episodic-memory resource id (`ctx.persistence.resourceId`). */
parentResourceId?: string;
/** Parent's tool-call id that triggered this delegation. */
parentToolCallId?: string;
/**
* Parent run's abort signal (`ctx.abortSignal`). Forward it to the child so
* cancelling the parent run also cancels the delegated work.
*/
parentAbortSignal?: AbortSignal;
/** How many siblings the parent already spawned before this one (0-based). */
childCount: number;
/** Effective policy for this delegation. */
policy?: DelegateSubAgentPolicy;
}
/** The result a delegation returns to the parent model and to lifecycle events. */
export interface DelegateSubAgentToolOutput {
status: 'completed' | 'failed';
/** Echoed back so consumers can correlate the result with the delegation. */
taskPath?: SubAgentTaskPath;
/** The child run's id, when the executor produced one. */
runId?: string;
/**
* The child run's memory thread id (`persistence.threadId`), when the
* executor used one. Surfaced so a consumer can correlate the child run or
* re-supply it to continue the same thread on a later delegation.
*/
threadId?: string;
/** The child's answer — the main payload the parent acts on. */
answer: string;
structuredOutput?: unknown;
/** Child token usage + cost, surfaced so the parent can account for it. */
usage?: Pick<TokenUsage, 'promptTokens' | 'completionTokens' | 'totalTokens' | 'cost'>;
finishReason?: FinishReason;
/** Present when status is 'failed'. */
error?: string;
}
/**
* Options for the `delegate_subagent` tool.
*
* You supply `runSubAgent` the host callback that actually runs the child for
* a delegation and returns its result. Everything else (input/output schema,
* system prompt, task-path bookkeeping, policy enforcement, and the
* `subagent-started` / `-completed` lifecycle events) is owned by
* the tool.
*/
export interface CreateDelegateSubAgentToolOptions {
/**
* Sub-agents the model may choose between. Listed in the system prompt; the
* model selects one by passing its id as `subAgentId`.
*/
availableSubAgents?: Array<{ id: string; name: string; description?: string }>;
/** Fan-out limits and spawn switch enforced before each delegation. */
policy?: DelegateSubAgentPolicy;
/** Run the child for this delegation and return its result. */
runSubAgent: (request: DelegateSubAgentRequest) => Promise<DelegateSubAgentToolOutput>;
}
/**
* Build the generic `delegate_subagent` tool lets a parent agent hand a
* bounded subtask to a child agent and get back a concise result.
*
* The tool owns the cross-cutting concerns: the model-facing input/output
* schema, the description + system instruction that teach the LLM when/how to
* delegate, task-path bookkeeping, policy enforcement (fan-out /
* canSpawnSubAgents), and the `subagent-started` / `-completed`
* lifecycle events. You only supply HOW to run the child, via `runSubAgent`.
*
* @example Host-controlled execution (what the n8n CLI does):
* agent.tool(createDelegateSubAgentTool({
* runSubAgent: (request) => runner.run(request),
* availableSubAgents,
* policy: { maxChildren: 5 },
* }));
*/
export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOptions) {
// Per-parent fan-out counter keyed by run/thread/task — drives maxChildren.
const childCounts = new Map<string, number>();
return new Tool(DELEGATE_SUB_AGENT_TOOL_NAME)
.description(
'Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context (it sees only what you pass in) and returns a concise result. ' +
'Reach for it when a subtask needs substantial search/research/review/reasoning whose intermediate output would clutter your context, or clearly benefits from a fresh perspective. ' +
'Do not use it for trivial work, for steps that depend on this conversation you cannot restate, or to forward the user request wholesale instead of decomposing it.',
)
.systemInstruction(
[
'delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. The child cannot see this conversation, your tools, or your memory, so everything it needs must be in the call.',
'Delegate only when all of these hold: the work is a concrete, self-contained subtask; it can be fully specified without unstated context from this conversation; and it is heavy enough (substantial search/research/review/reasoning) that doing it inline would clutter your context, or a fresh perspective clearly helps.',
'Do not delegate when: the task is trivial or is one or two tool calls you can make directly; it is the core reasoning you are responsible for (never delegate the understanding); it depends on context you cannot restate; or you would just forward the user request without decomposing it. Wanting more thoroughness or research is not by itself a reason to delegate.',
'Write the handoff for a smart colleague who just walked in and has seen none of this conversation: put the concrete outcome in goal; put every detail the child needs in context (constraints, paths, data, prior decisions, acceptance criteria, what you have already tried or ruled out); state exactly what you need back, and how concise, in expectedOutput; and give it a short descriptive taskName.',
...formatAvailableSubAgents(options.availableSubAgents),
'When the child returns: inspect the answer before relying on it, do not blindly trust self-reported success, synthesize it into your own response instead of copying it verbatim, and if it is incomplete or failed either retry with a sharper handoff or do the task yourself.',
].join('\n'),
)
.input(delegateSubAgentInputSchema)
.output(delegateSubAgentOutputSchema)
.handler(async (input, ctx) => await handleDelegateSubAgent(input, ctx, options, childCounts))
.build();
}
function formatAvailableSubAgents(
availableSubAgents: CreateDelegateSubAgentToolOptions['availableSubAgents'],
): string[] {
if (!availableSubAgents?.length) return [];
return [
'Configured subagents are available. Pick the most relevant one and pass its id as subAgentId:',
...availableSubAgents.map((subAgent) => {
const description = subAgent.description ? ` - ${subAgent.description}` : '';
return `- ${subAgent.id}: ${subAgent.name}${description}`;
}),
];
}
/**
* Tool handler: enforce policy (fan-out), assign the child's task path,
* assemble the {@link DelegateSubAgentRequest} from the model input plus the
* parent tool context, then run the child via the host `runSubAgent` callback
* while emitting started/progress/completed lifecycle events. Any error is
* converted into a `status: 'failed'` output (never thrown) so one failed
* delegation can't abort the parent's run.
*/
async function handleDelegateSubAgent(
input: DelegateSubAgentInput,
ctx: ToolContext,
options: CreateDelegateSubAgentToolOptions,
childCounts: Map<string, number>,
): Promise<DelegateSubAgentToolOutput> {
let taskPath: SubAgentTaskPath | undefined;
let request: DelegateSubAgentRequest | undefined;
let startedAt: number | undefined;
try {
assertSubAgentPolicyAllowsChild(options.policy);
const childCountKey = getChildCountKey(ctx);
const childCount = childCounts.get(childCountKey) ?? 0;
assertSubAgentPolicyAllowsChildCount(childCount, options.policy);
taskPath = createChildSubAgentTaskPath(input.taskName, childCount);
childCounts.set(childCountKey, childCount + 1);
request = {
...input,
taskPath,
childCount,
...(ctx.runId !== undefined ? { parentRunId: ctx.runId } : {}),
...(ctx.persistence?.threadId !== undefined
? { parentThreadId: ctx.persistence.threadId }
: {}),
...(ctx.persistence?.resourceId !== undefined
? { parentResourceId: ctx.persistence.resourceId }
: {}),
...(ctx.abortSignal !== undefined ? { parentAbortSignal: ctx.abortSignal } : {}),
...(ctx.toolCallId !== undefined ? { parentToolCallId: ctx.toolCallId } : {}),
...(options.policy !== undefined ? { policy: options.policy } : {}),
};
startedAt = Date.now();
emitSubAgentStarted(ctx, request, startedAt);
const output = await options.runSubAgent(request);
emitSubAgentCompleted(ctx, request, output, startedAt);
return output;
} catch (error) {
if (request !== undefined && startedAt !== undefined) {
emitSubAgentCompleted(
ctx,
request,
{
status: 'failed',
...(taskPath !== undefined ? { taskPath } : {}),
answer: '',
error: stringifyUnknown(error),
},
startedAt,
);
}
return {
status: 'failed',
...(taskPath !== undefined ? { taskPath } : {}),
answer: '',
error: error instanceof Error ? error.message : String(error),
};
}
}
function emitSubAgentStarted(
ctx: ToolContext,
request: DelegateSubAgentRequest,
startedAt: number,
): void {
ctx.emitEvent?.({
type: AgentEvent.SubAgentStarted,
...subAgentLifecycleBase(request),
startedAt,
});
}
function emitSubAgentCompleted(
ctx: ToolContext,
request: DelegateSubAgentRequest,
output: DelegateSubAgentToolOutput,
startedAt: number,
): void {
const finishedAt = Date.now();
ctx.emitEvent?.({
type: AgentEvent.SubAgentCompleted,
...subAgentLifecycleBase(request),
status: output.status,
startedAt,
finishedAt,
durationMs: finishedAt - startedAt,
...(output.runId !== undefined ? { runId: output.runId } : {}),
...(output.threadId !== undefined ? { threadId: output.threadId } : {}),
...(output.usage !== undefined ? { usage: output.usage } : {}),
...(output.finishReason !== undefined ? { finishReason: output.finishReason } : {}),
...(output.error !== undefined ? { error: output.error } : {}),
});
}
function subAgentLifecycleBase(request: DelegateSubAgentRequest) {
return {
taskName: request.taskName,
taskPath: request.taskPath,
...(request.parentRunId !== undefined ? { parentRunId: request.parentRunId } : {}),
...(request.parentToolCallId !== undefined
? { parentToolCallId: request.parentToolCallId }
: {}),
...(request.subAgentId !== undefined ? { subAgentId: request.subAgentId } : {}),
};
}
function getChildCountKey(ctx: ToolContext): string {
return ctx.runId ?? ctx.persistence?.threadId ?? ctx.persistence?.resourceId ?? 'adhoc';
}
function stringifyUnknown(value: unknown): string {
if (value instanceof Error) return value.message;
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return 'Unknown error';
}
}
/**
* Optional helpers for a `runSubAgent` implementation.
*
* A host that runs the child by calling `agent.generate(...)`/`stream(...)` can
* reuse these instead of hand-rolling the delegation prompt and the result
* mapping. They are NOT wired into the tool call them from your `runSubAgent`
* (the n8n CLI runner does).
*/
/** Render the default delegation prompt from a request's goal / context / expectedOutput. */
export function renderDelegateSubAgentPrompt(request: {
goal: string;
context?: string;
expectedOutput?: string;
}): string {
const sections = [
'You are running as a delegated sub-agent on a single, self-contained task. You have no access to the parent conversation beyond what is written below, and you cannot ask follow-up questions during this run. Complete the task independently and reply with a concise, self-contained answer.',
`Goal:\n${request.goal}`,
];
if (request.context) {
sections.push(`Context:\n${request.context}`);
}
if (request.expectedOutput) {
sections.push(`Expected output:\n${request.expectedOutput}`);
}
sections.push(
'If the information above is insufficient, do your best with explicitly stated assumptions and note what was missing, rather than stopping to ask.',
);
return sections.join('\n\n');
}
/** Map an agent {@link GenerateResult} into the delegate tool's output shape. */
export function generateResultToDelegateSubAgentOutput(
taskPath: SubAgentTaskPath,
result: GenerateResult,
threadId?: string,
): DelegateSubAgentToolOutput {
return {
status: result.finishReason === 'error' || result.error !== undefined ? 'failed' : 'completed',
taskPath,
runId: result.runId,
...(threadId !== undefined ? { threadId } : {}),
answer: lastText(result.messages),
...(result.structuredOutput !== undefined ? { structuredOutput: result.structuredOutput } : {}),
...(result.usage !== undefined
? {
usage: {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
...(result.usage.cost !== undefined ? { cost: result.usage.cost } : {}),
},
}
: {}),
...(result.finishReason !== undefined ? { finishReason: result.finishReason } : {}),
...(result.error !== undefined ? { error: stringifyUnknown(result.error) } : {}),
};
}
/** Last non-empty assistant text across the run's messages. */
function lastText(messages: AgentMessage[]): string {
const llmMessages = filterLlmMessages(messages);
for (let i = llmMessages.length - 1; i >= 0; i--) {
const message = llmMessages[i];
if (!message) continue;
const text = message.content
.filter((content) => content.type === 'text')
.map((content) => content.text)
.join('\n')
.trim();
if (text) return text;
}
return '';
}

View File

@ -2,7 +2,7 @@
* Pure utility functions used by AgentRuntime that require no class context.
* These are extracted here to keep agent-runtime.ts focused on orchestration logic.
*/
import type { GenerateResult, StreamChunk, TokenUsage } from '../types';
import type { StreamChunk, TokenUsage } from '../types';
import { toTokenUsage } from './stream';
import type { AgentMessage, ContentToolCall } from '../types/sdk/message';
@ -95,13 +95,3 @@ export function accumulateUsage(
if (!raw) return current;
return mergeUsage(current, toTokenUsage(raw));
}
/** Compute totalCost from sub-agent usage already present on the result. */
export function applySubAgentUsage(result: GenerateResult): GenerateResult {
if (!result.subAgentUsage || result.subAgentUsage.length === 0) return result;
const parentCost = result.usage?.cost ?? 0;
const subCost = result.subAgentUsage.reduce((sum, s) => sum + (s.usage.cost ?? 0), 0);
return { ...result, totalCost: parentCost + subCost };
}

View File

@ -108,6 +108,18 @@ export function convertChunk(c: TextStreamPart<ToolSet>): StreamChunk | undefine
output: c.output,
};
case 'tool-error':
// Provider-executed tools (e.g. native web search) surface failures
// as `tool-error` rather than `tool-result`. Map to our tool-result
// shape so stream consumers receive the error payload.
return {
type: 'tool-result',
toolCallId: c.toolCallId ?? '',
toolName: c.toolName ?? '',
output: c.error,
isError: true,
};
case 'error':
return { type: 'error', error: c.error };

View File

@ -0,0 +1,149 @@
/**
* Task paths for sub-agent delegation.
*
* A "task path" is a filesystem-like address that gives each delegated run a
* stable, human-readable identity, e.g.:
*
* /root the top-level (orchestrating) agent
* /root/research_api_0 a first-level delegated child
*
* Each child segment carries the parent's 0-based child index (`_0`, `_1`, ) so
* that delegations with the same task name stay distinct.
*
* Why this concept exists:
* - Identity: each delegated unit of work gets a unique, traceable name we can
* log and surface in the timeline without the parent having to invent ids.
* (Memory/session ids are independent a run gets its own thread id.)
* - Policy enforcement: together with {@link SubAgentTaskPathPolicy}, the path
* supports per-parent fan-out limits so a misbehaving agent cannot spawn
* hundreds of parallel children, which would blow up cost, latency, and
* resources.
*
* Nesting is not supported: only `/root` and single-segment child paths under
* `/root` are valid. Sub-agents cannot spawn other sub-agents.
*
* Everything in this file is pure (no I/O, no n8n-specific concepts), which is
* why it lives in the runtime SDK: it is shared verbatim by both the generic
* `delegate_subagent` tool and the n8n CLI runner.
*/
/**
* A delegation task path: `/root` or `/root/<segment>` only. Modeled as a
* template-literal type so a plain string can be narrowed to a validated path
* via {@link assertSubAgentTaskPath}.
*/
export type SubAgentTaskPath = `/root${'' | `/${string}`}`;
/**
* Guardrails applied when a parent tries to spawn a child sub-agent. Every limit
* is optional; an undefined field means "no limit for that dimension".
*/
export interface SubAgentTaskPathPolicy {
/** Maximum number of children a single parent may spawn. Bounds fan-out width. */
maxChildren?: number;
/** Hard on/off switch: when false the parent may not delegate at all. */
canSpawnSubAgents?: boolean;
}
/** Top-level orchestrating agent path. */
export const ROOT_SUB_AGENT_TASK_PATH = '/root' satisfies SubAgentTaskPath;
/** Upper bound on a single path segment, so paths stay bounded and readable. */
const MAX_TASK_NAME_LENGTH = 64;
/** Valid paths: `/root` or `/root/<one segment>` — no nested delegation. */
const SUB_AGENT_TASK_PATH_PATTERN = /^\/root(?:\/[a-z0-9_]+)?$/;
/**
* Turn a free-text, model-supplied task name (e.g. "Research API pricing!")
* into a safe, deterministic path segment (e.g. "research_api_pricing").
*
* The task name comes from the LLM, so it can contain anything. We normalize to
* lowercase, collapse each run of non-alphanumerics into a single underscore,
* strip leading/trailing underscores, and cap the length producing segments
* that are collision-resistant, log/URL-safe, and accepted by
* {@link SUB_AGENT_TASK_PATH_PATTERN}.
*
* @throws if nothing alphanumeric survives (we refuse to build a nameless path).
*/
export function sanitizeSubAgentTaskName(taskName: string): string {
const sanitized = taskName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, MAX_TASK_NAME_LENGTH)
.replace(/_+$/g, '');
if (!sanitized) {
throw new Error('Sub-agent task name must contain at least one alphanumeric character');
}
return sanitized;
}
/** Type guard: does this string match the flat `/root[/segment]?` shape? */
export function isSubAgentTaskPath(value: string): value is SubAgentTaskPath {
return SUB_AGENT_TASK_PATH_PATTERN.test(value);
}
/**
* Assert (and type-narrow) that a string is a valid task path. Used to validate
* paths that were constructed here or received from elsewhere before we rely on
* their shape.
*/
export function assertSubAgentTaskPath(value: string): asserts value is SubAgentTaskPath {
if (!isSubAgentTaskPath(value)) {
throw new Error(`Invalid sub-agent task path: ${value}`);
}
}
/**
* Build a first-level child path: `/root/<sanitized task name>_<childCount>`.
*
* `childCount` is the parent's 0-based index for this child (the number of
* children it had already spawned). Appending it disambiguates same-named
* siblings within a single parent run.
*/
export function createChildSubAgentTaskPath(
taskName: string,
childCount: number,
): SubAgentTaskPath {
const childPath = `${ROOT_SUB_AGENT_TASK_PATH}/${sanitizeSubAgentTaskName(taskName)}_${childCount}`;
assertSubAgentTaskPath(childPath);
return childPath;
}
/**
* Spawn gate, checked BEFORE a child is spawned.
*
* Rejects when delegation is switched off outright (`canSpawnSubAgents === false`).
*/
export function assertSubAgentPolicyAllowsChild(policy: SubAgentTaskPathPolicy | undefined): void {
if (policy?.canSpawnSubAgents === false) {
throw new Error('Sub-agent policy does not allow spawning child sub-agents');
}
}
/**
* Fan-out-dimension gate, checked BEFORE a child is spawned.
*
* `childCount` is how many children the parent has ALREADY spawned. If that has
* reached `maxChildren`, spawning one more (the `+ 1` in the message is the
* would-be new total) exceeds the limit, so we reject. This stops a single agent
* from fanning out to an unbounded number of parallel sub-agents. When
* `maxChildren` is undefined, fan-out is unbounded.
*/
export function assertSubAgentPolicyAllowsChildCount(
childCount: number,
policy: SubAgentTaskPathPolicy | undefined,
): void {
if (policy?.maxChildren === undefined) return;
if (childCount >= policy.maxChildren) {
throw new Error(
`Sub-agent child count ${childCount + 1} exceeds maxChildren ${policy.maxChildren}`,
);
}
}

View File

@ -11,7 +11,6 @@ import {
type ToolExecutionContext,
type ToolContext,
} from '../types';
import type { SubAgentUsage } from '../types/sdk/agent';
import { isZodSchema } from '../utils/zod';
type AiSdkProviderTool = AiSdkTool & {
@ -24,13 +23,6 @@ type AiSdkProviderTool = AiSdkTool & {
*/
const SUSPEND_BRAND = Symbol('SuspendBrand');
/**
* Branded symbol used to tag tool results from agent-as-tool calls.
* Carries sub-agent usage so the parent runtime can aggregate costs
* without any external state (WeakMap, mutable tool fields, etc.).
*/
const AGENT_TOOL_BRAND = Symbol('AgentToolBrand');
export interface SuspendedToolResult {
readonly [SUSPEND_BRAND]: true;
payload: unknown;
@ -41,32 +33,6 @@ export function isSuspendedToolResult(value: unknown): value is SuspendedToolRes
return typeof value === 'object' && value !== null && SUSPEND_BRAND in value;
}
export interface AgentToolResult {
readonly [AGENT_TOOL_BRAND]: true;
/** The actual tool output (passed back to the LLM). */
readonly output: unknown;
/** Sub-agent usage entries to aggregate into the parent's result. */
readonly subAgentUsage: SubAgentUsage[];
}
/** Type guard: returns true when a tool result carries sub-agent usage. */
export function isAgentToolResult(value: unknown): value is AgentToolResult {
return typeof value === 'object' && value !== null && AGENT_TOOL_BRAND in value;
}
/**
* Create a branded agent-tool result that carries sub-agent usage alongside the output.
* The output properties are spread onto the object so it remains a valid tool output
* even when accessed directly (e.g. in tests). The runtime detects the brand via
* isAgentToolResult() and extracts the sub-agent usage.
* Typed as `never` so `return createAgentToolResult(...)` satisfies any handler return type
* (same pattern as ctx.suspend).
*/
export function createAgentToolResult(output: unknown, subAgentUsage: SubAgentUsage[]): never {
const base = typeof output === 'object' && output !== null ? output : {};
return { ...base, [AGENT_TOOL_BRAND]: true, output, subAgentUsage } as never;
}
/**
* Convert an array of BuiltProviderTools into a Record of AI SDK provider-defined tool objects.
* Provider tools are executed on the provider's infrastructure (e.g. Anthropic web search,
@ -162,6 +128,8 @@ export async function executeTool(
toolCallId,
runId: executionContext.runId,
persistence: executionContext.persistence,
emitEvent: executionContext.emitEvent,
abortSignal: executionContext.abortSignal,
};
return await builtTool.handler(args, ctx);
}
@ -171,6 +139,8 @@ export async function executeTool(
toolCallId,
runId: executionContext.runId,
persistence: executionContext.persistence,
emitEvent: executionContext.emitEvent,
abortSignal: executionContext.abortSignal,
};
return await builtTool.handler(args, ctx);
}

View File

@ -1,15 +1,14 @@
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import { z } from 'zod';
import type { z } from 'zod';
import type { Eval } from './eval';
import type { McpClient } from './mcp-client';
import { Memory, normalizeMemoryConfig, resolveMemoryConfigDefaults } from './memory';
import { Telemetry } from './telemetry';
import { Tool, wrapToolForApproval } from './tool';
import { wrapToolForApproval } from './tool';
import { AgentRuntime } from '../runtime/agent-runtime';
import { LOAD_TOOL_TOOL_NAME, SEARCH_TOOLS_TOOL_NAME } from '../runtime/deferred-tool-manager';
import { AgentEventBus } from '../runtime/event-bus';
import { createAgentToolResult } from '../runtime/tool-adapter';
import {
appendSkillCatalogToInstructions,
createRuntimeSkillSource,
@ -36,7 +35,6 @@ import type {
RunOptions,
SerializableAgentState,
StreamResult,
SubAgentUsage,
ThinkingConfig,
ThinkingConfigFor,
ResumeOptions,
@ -491,67 +489,6 @@ export class Agent implements BuiltAgent, AgentBuilder {
this.eventBus.off(event, handler);
}
/**
* Wrap this agent as a tool for use in multi-agent composition.
* The tool sends a text prompt to this agent and returns the text of the response.
*
* @example
* ```typescript
* const coordinatorAgent = new Agent('coordinator')
* .model('anthropic/claude-sonnet-4-5')
* .instructions('Route tasks to specialist agents.')
* .tool(writerAgent.asTool('Write content given a topic'));
* ```
*/
asTool(description: string): BuiltTool {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const agent = this;
const tool = new Tool(this.name)
.description(description)
.input(
z.object({
input: z.string().describe('The input to send to the agent'),
}),
)
.output(
z.object({
result: z.string().describe('The result of the agent'),
}),
)
.handler(async (rawInput, ctx) => {
const { input } = rawInput as { input: string };
const result = await agent.generate(input, {
telemetry: ctx.parentTelemetry,
} as RunOptions & ExecutionOptions);
const text = result.messages
.filter((m) => 'role' in m && m.role === 'assistant')
.flatMap((m) => ('content' in m ? m.content : []))
.filter((c) => c.type === 'text')
.map((c) => ('text' in c ? c.text : ''))
.join('');
// Collect sub-agent usage: this agent's own + any nested sub-agents
const subAgentUsage: SubAgentUsage[] = [];
if (result.usage) {
subAgentUsage.push({ agent: agent.name, model: result.model, usage: result.usage });
}
if (result.subAgentUsage) {
subAgentUsage.push(...result.subAgentUsage);
}
// Return branded result — the runtime unwraps it to extract sub-agent usage.
// createAgentToolResult returns `never`, same pattern as ctx.suspend().
if (subAgentUsage.length > 0) {
return createAgentToolResult({ result: text }, subAgentUsage);
}
return { result: text };
});
return tool.build();
}
/**
* Return a lightweight read-only snapshot of the agent's configured state.
* Useful for testing and debugging does not trigger a build.

View File

@ -1,90 +0,0 @@
import type { Agent } from './agent';
import type { GenerateResult, RunOptions } from '../types';
import type { Message } from '../types/sdk/message';
interface BuiltNetwork {
readonly name: string;
run(prompt: string, options?: RunOptions): Promise<GenerateResult>;
}
/**
* Builder for creating multi-agent networks with a coordinator.
*
* Usage:
* ```typescript
* const network = new Network('content-team')
* .coordinator(coordinatorAgent)
* .agent(researcher)
* .agent(writer);
*
* const result = await network.run('Research and write about RAG');
* ```
*/
export class Network {
private networkName: string;
private coordinatorAgent?: Agent;
private agents: Agent[] = [];
private built?: BuiltNetwork;
constructor(name: string) {
this.networkName = name;
}
/** Set the coordinator agent that routes tasks to specialists. */
coordinator(a: Agent): this {
this.coordinatorAgent = a;
return this;
}
/** Add a specialist agent to the network. */
agent(a: Agent): this {
this.agents.push(a);
return this;
}
/** @internal Lazy-build the network on first use. */
private ensureBuilt(): BuiltNetwork {
this.built ??= this.build();
return this.built;
}
/** The network name. */
get name(): string {
return this.networkName;
}
/** Run the network with a prompt. Lazy-builds on first call. */
async run(prompt: string, options?: RunOptions): Promise<GenerateResult> {
return await this.ensureBuilt().run(prompt, options);
}
/** @internal */
protected build(): BuiltNetwork {
if (!this.coordinatorAgent) {
throw new Error(`Network "${this.networkName}" requires a coordinator`);
}
if (this.agents.length === 0) {
throw new Error(`Network "${this.networkName}" requires at least one agent`);
}
// TODO: Specialist agents are stored for validation but not yet wired
// to the coordinator automatically. For now, specialists must be added
// as tools on the coordinator agent manually (via agent.asTool()).
// Multi-agent routing will be implemented in a future iteration.
const coordinator = this.coordinatorAgent;
const name = this.networkName;
return {
name,
async run(prompt: string, options?: RunOptions): Promise<GenerateResult> {
const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
return await coordinator.generate(messages, options);
},
};
}
}

View File

@ -41,7 +41,6 @@ export type {
ResumeOptions,
GenerateResult,
StreamResult,
SubAgentUsage,
BuiltAgent,
AgentRunState,
AgentResumeData,

View File

@ -1,5 +1,36 @@
import type { FinishReason, TokenUsage } from '../sdk/agent';
import type { AgentMessage, ContentToolCall } from '../sdk/message';
export type SubAgentLifecycleUsage = Pick<
TokenUsage,
'promptTokens' | 'completionTokens' | 'totalTokens' | 'cost'
>;
export interface SubAgentLifecycleBase {
taskName: string;
taskPath: string;
parentRunId?: string;
parentToolCallId?: string;
subAgentId?: string;
}
export interface SubAgentStartedPayload extends SubAgentLifecycleBase {
startedAt: number;
}
export interface SubAgentCompletedPayload extends SubAgentLifecycleBase {
status: 'completed' | 'failed';
startedAt: number;
finishedAt: number;
durationMs: number;
runId?: string;
/** The child run's memory thread id (`persistence.threadId`), so consumers can correlate or continue it. */
threadId?: string;
usage?: SubAgentLifecycleUsage;
finishReason?: FinishReason;
error?: string;
}
export const enum AgentEvent {
AgentStart = 'agent_start',
AgentEnd = 'agent_end',
@ -7,6 +38,8 @@ export const enum AgentEvent {
TurnEnd = 'turn_end',
ToolExecutionStart = 'tool_execution_start',
ToolExecutionEnd = 'tool_execution_end',
SubAgentStarted = 'subagent_started',
SubAgentCompleted = 'subagent_completed',
Error = 'error',
}
@ -23,6 +56,8 @@ export type AgentEventData =
result: unknown;
isError: boolean;
}
| ({ type: AgentEvent.SubAgentStarted } & SubAgentStartedPayload)
| ({ type: AgentEvent.SubAgentCompleted } & SubAgentCompletedPayload)
| {
type: AgentEvent.Error;
message: string;

View File

@ -3,9 +3,13 @@ import type { LanguageModel } from 'ai';
import type { JsonSchema7Type } from 'zod-to-json-schema';
import type { AgentMessage, ContentMetadata } from './message';
import type { BuiltTool } from './tool';
import type { ProviderId, ProviderCredentials } from '../../runtime/provider-credentials';
import type { AgentEvent, AgentEventHandler } from '../runtime/event';
import type {
AgentEvent,
AgentEventHandler,
SubAgentCompletedPayload,
SubAgentStartedPayload,
} from '../runtime/event';
import type { SerializedMessageList } from '../runtime/message-list';
import type { BuiltTelemetry } from '../telemetry';
import type { JSONValue } from '../utils/json';
@ -90,6 +94,22 @@ export type StreamChunk = ContentMetadata &
type: 'tool-execution-start';
toolCallId: string;
toolName: string;
/** Epoch ms when the handler started, measured on the runtime. */
startTime: number;
}
| {
/**
* Emitted as soon as an individual tool handler settles, bridged from
* the runtime event bus. Lets consumers flip a concurrent tool call to
* its terminal state immediately, instead of waiting for the batched
* `tool-result` chunks emitted only after the whole batch settles.
*/
type: 'tool-execution-end';
toolCallId: string;
toolName: string;
isError: boolean;
/** Epoch ms when the handler settled, measured on the runtime. */
endTime: number;
}
| {
type: 'tool-result';
@ -110,14 +130,14 @@ export type StreamChunk = ContentMetadata &
}
// `message` is reserved for sub-agent / app-defined `CustomAgentMessage`
| { type: 'message'; message: AgentMessage }
| ({ type: 'subagent-started' } & SubAgentStartedPayload)
| ({ type: 'subagent-completed' } & SubAgentCompletedPayload)
| {
type: 'finish';
finishReason: FinishReason;
usage?: TokenUsage;
model?: string;
structuredOutput?: unknown;
subAgentUsage?: SubAgentUsage[];
totalCost?: number;
}
| { type: 'error'; error: unknown }
);
@ -136,7 +156,7 @@ export interface ExecutionOptions {
maxIterations?: number;
abortSignal?: AbortSignal;
providerOptions?: ProviderOptions;
/** Inherited telemetry from a parent agent. Used internally by asTool(). */
/** Inherited telemetry from a host runtime. */
telemetry?: BuiltTelemetry;
/** Inherited execution counter from the host runtime. Used for aggregate heartbeat telemetry. */
executionCounter?: AgentExecutionCounter;
@ -153,16 +173,6 @@ export interface ToolResultEntry {
transformed?: boolean;
}
/** Token usage from a sub-agent called via .asTool(). */
export interface SubAgentUsage {
/** Name of the sub-agent. */
agent: string;
/** Model used by the sub-agent. */
model?: string;
/** Token usage for the sub-agent call. */
usage: TokenUsage;
}
export interface GenerateResult {
/** Unique identifier for this run. Useful for HITL resume and correlation/logging. */
runId: string;
@ -175,10 +185,6 @@ export interface GenerateResult {
providerMetadata?: Record<string, unknown>;
/** Tool calls made during the run (with merged results when available). */
toolCalls?: ToolResultEntry[];
/** Token usage from sub-agents called via .asTool(). */
subAgentUsage?: SubAgentUsage[];
/** Total cost (USD) including this agent + all sub-agents. */
totalCost?: number;
/**
* Present when the run suspended awaiting tool resume (HITL).
* Call `agent.resume('generate', data, { runId, toolCallId })` to resume.
@ -226,8 +232,6 @@ export interface BuiltAgent {
on(event: AgentEvent, handler: AgentEventHandler): void;
asTool(description: string): BuiltTool;
getState(): SerializableAgentState;
/** Cancel the currently running agent. Synchronous — sets an abort flag that the agentic loop checks asynchronously. */

View File

@ -2,6 +2,7 @@ import type { JSONSchema7 } from 'json-schema';
import type { ZodType } from 'zod';
import type { AgentMessage } from './message';
import type { AgentEventData } from '../runtime/event';
import type { BuiltTelemetry } from '../telemetry';
import type { JSONObject } from '../utils/json';
@ -18,6 +19,14 @@ export interface ToolExecutionContext {
threadId: string;
resourceId: string;
};
/** Internal runtime event bridge for platform-managed tools. */
emitEvent?: (event: AgentEventData) => void;
/**
* The current run's abort signal. Long-running tools (e.g. ones that spawn a
* child agent) should forward it so cancelling the parent run also cancels
* the work they started.
*/
abortSignal?: AbortSignal;
}
export interface ToolContext {
@ -27,8 +36,12 @@ export interface ToolContext {
runId?: string;
/** Current persisted thread scope when the run is backed by memory. */
persistence?: ToolExecutionContext['persistence'];
/** Telemetry config from the parent agent, for sub-agent propagation. */
/** Telemetry config from the parent agent. */
parentTelemetry?: BuiltTelemetry;
/** Internal runtime event bridge for platform-managed tools. */
emitEvent?: ToolExecutionContext['emitEvent'];
/** The current run's abort signal, for tools that start cancellable work. */
abortSignal?: ToolExecutionContext['abortSignal'];
}
export interface InterruptibleToolContext<S = unknown, R = unknown> {
@ -46,8 +59,12 @@ export interface InterruptibleToolContext<S = unknown, R = unknown> {
runId?: string;
/** Current persisted thread scope when the run is backed by memory. */
persistence?: ToolExecutionContext['persistence'];
/** Telemetry config from the parent agent, for sub-agent propagation. */
/** Telemetry config from the parent agent. */
parentTelemetry?: BuiltTelemetry;
/** Internal runtime event bridge for platform-managed tools. */
emitEvent?: ToolExecutionContext['emitEvent'];
/** The current run's abort signal, for tools that start cancellable work. */
abortSignal?: ToolExecutionContext['abortSignal'];
}
export interface BuiltTool {

View File

@ -83,7 +83,7 @@ export const askQuestionInputSchema = z.object({
options: z
.array(askQuestionOptionSchema)
.describe(
'Choices to present. Pass an empty array for an open-ended question (the card shows only a freeform input). With a single option the tool auto-resolves to that option without rendering a card.',
'Choices to present. Pass an empty array for an open-ended question (the card shows only a freeform input). With a single non-multiple option the tool auto-resolves to that option without rendering a card.',
),
allowMultiple: z
.boolean()

View File

@ -69,6 +69,21 @@ export type AgentSseEvent =
type: 'tool-execution-start';
toolCallId: string;
toolName: string;
/** Epoch ms when the handler started, measured on the backend. */
startTime: number;
}
| {
/**
* Emitted as soon as an individual tool settles, so the FE can flip a
* concurrent tool call to its terminal state immediately instead of
* waiting for the batched `tool-result` events.
*/
type: 'tool-execution-end';
toolCallId: string;
toolName: string;
isError: boolean;
/** Epoch ms when the handler settled, measured on the backend. */
endTime: number;
}
| {
type: 'tool-result';

View File

@ -77,6 +77,16 @@ const WebSearchConfigSchema = z.object({
credential: z.string().optional(),
});
const SubAgentConfigSchema = z.object({
agentId: z.string().trim().min(1),
});
const SubAgentsConfigSchema = z
.object({
agents: z.array(SubAgentConfigSchema).optional(),
})
.strict();
const NodeToolCredentialSchema = z.object({
id: z.string(),
name: z.string(),
@ -240,6 +250,7 @@ export const AgentJsonConfigSchema = z.object({
credential: z.string().optional(),
instructions: z.string(),
memory: MemoryConfigSchema.optional(),
subAgents: SubAgentsConfigSchema.optional(),
tools: z.array(AgentJsonToolConfigSchema).optional(),
skills: z.array(AgentJsonSkillConfigSchema).optional(),
tasks: z.array(AgentJsonTaskConfigSchema).optional(),
@ -285,6 +296,7 @@ export const RunnableAgentJsonConfigSchema = AgentJsonConfigSchema.extend({
export const AgentJsonConfigPartialSchema = AgentJsonConfigSchema.partial();
export type AgentJsonConfig = z.infer<typeof AgentJsonConfigSchema>;
export type RunnableAgentJsonConfig = z.infer<typeof RunnableAgentJsonConfigSchema>;
export type AgentJsonToolConfig = z.infer<typeof AgentJsonToolConfigSchema>;
export type AgentJsonWorkflowToolConfig = Extract<AgentJsonToolConfig, { type: 'workflow' }>;
export type AgentJsonNodeToolConfig = Extract<AgentJsonToolConfig, { type: 'node' }>;
@ -326,3 +338,7 @@ export function formatZodErrors(error: ZodError): ConfigValidationError[] {
export function isNodeToolsEnabled(config: AgentJsonConfig['config']): boolean {
return config?.nodeTools?.enabled === true;
}
export function isSubAgentsEnabled(subAgents: AgentJsonConfig['subAgents']): boolean {
return (subAgents?.agents?.length ?? 0) > 0;
}

View File

@ -5,6 +5,7 @@ export * from './agent-task.schema';
export * from './dto';
export * from './model-providers';
export * from './provider-capabilities';
export type * from './sub-agent.schema';
export * from './types';
export type { AgentSseEvent, AgentSseMessage, ToolSuspendedPayload } from '../agent-sse';
export {

View File

@ -0,0 +1,52 @@
import type { RunnableAgentJsonConfig } from './agent-json-config.schema';
/**
* A sub-agent is always a saved n8n agent optionally pinned to a published
* version.
*/
export type SubAgentSource = {
agentId: string;
versionId?: string;
};
export interface ResolvedSubAgentSource {
config: RunnableAgentJsonConfig;
sourceId: string;
versionId?: string;
}
export interface SubAgentRunPolicy {
maxChildren?: number;
/** Host-enforced wall-clock timeout for a child run — the n8n runner aborts the child. */
timeoutMs?: number;
}
/**
* In-process contract for spawning a child agent. Built by the SDK delegate tool
* from the model's already-validated input and handed straight to the runner, so
* it never crosses an untrusted boundary and needs no runtime schema.
*/
export interface SubAgentSpawnRequest {
goal: string;
context?: string;
expectedOutput?: string;
source: SubAgentSource;
/**
* 'foreground' blocks the parent turn until the subagent completes the only
* mode implemented today. 'background' (dispatch, return a receipt, reconcile
* the result later) is not yet implemented and is a consumer/product concern,
* not an SDK one. Tracked in AGENT-186:
* https://linear.app/n8n/issue/AGENT-186
*/
executionMode?: 'foreground' | 'background';
policy?: SubAgentRunPolicy;
parentThreadId?: string;
/** Parent's episodic-memory resource id, inherited so the child shares its scope. */
parentResourceId?: string;
/**
* This delegation's task path already assigned and policy-checked by the SDK
* delegate tool, then validated by `@n8n/agents` (`assertSubAgentTaskPath`)
* before the child runs, so a plain string suffices here.
*/
taskPath: string;
}

View File

@ -139,6 +139,10 @@ export interface AgentPersistedMessageContentPart {
state?: string;
output?: unknown;
error?: string;
/** Epoch ms when the tool handler started executing. */
startTime?: number;
/** Epoch ms when the tool handler settled. */
endTime?: number;
}
export interface AgentPersistedMessageDto {

View File

@ -30,6 +30,14 @@ export class AgentsConfig {
@Env('N8N_AGENTS_CHECKPOINT_TTL')
checkpointTtlSeconds: number = 345600; // 96 hours
/** Maximum number of sub-agents a single parent run may spawn. Bounds fan-out width. */
@Env('N8N_AGENTS_SUBAGENT_MAX_CHILDREN')
subAgentMaxChildren: number = 5;
/** Abort an individual sub-agent run after this many milliseconds. */
@Env('N8N_AGENTS_SUBAGENT_TIMEOUT_MS')
subAgentTimeoutMs: number = 300000; // 5 minutes
/**
* Comma-separated list of agent sub-feature modules to enable. Each entry
* gates a specific frontend/runtime capability inside the agents module.

View File

@ -560,6 +560,8 @@ describe('GlobalConfig', () => {
},
agents: {
checkpointTtlSeconds: 345600,
subAgentMaxChildren: 5,
subAgentTimeoutMs: 300000,
modules: [],
},
} satisfies GlobalConfigShape;

View File

@ -54,10 +54,38 @@ describe('AgentExecutionThreadRepository', () => {
taskId: null,
taskVersionId: null,
sessionNumber: 8,
parentThreadId: null,
parentAgentId: null,
});
expect(result).toEqual({ thread: saved, created: true });
});
it('stores subagent origin metadata when creating a thread', async () => {
const saved = mock<AgentExecutionThread>({ id: 'thread-1', sessionNumber: 8 });
const scopedRepository = makeScopedRepository(saved);
const trx = { getRepository: jest.fn().mockReturnValue(scopedRepository) };
entityManager.transaction.mockImplementationOnce(async (_isolation, callback) => {
return await callback(trx as never);
});
await repository.findOrCreate('thread-1', 'agent-1', 'Support agent', 'project-1', {
parentThreadId: 'parent-thread-1',
parentAgentId: 'parent-agent-1',
});
expect(scopedRepository.create).toHaveBeenCalledWith({
id: 'thread-1',
agentId: 'agent-1',
agentName: 'Support agent',
projectId: 'project-1',
taskId: null,
taskVersionId: null,
sessionNumber: 8,
parentThreadId: 'parent-thread-1',
parentAgentId: 'parent-agent-1',
});
});
it('stores the published task snapshot version when supplied', async () => {
const saved = mock<AgentExecutionThread>({ id: 'thread-1', sessionNumber: 8 });
const scopedRepository = makeScopedRepository(saved);
@ -71,6 +99,7 @@ describe('AgentExecutionThreadRepository', () => {
'agent-1',
'Support agent',
'project-1',
undefined,
'task-1',
'version-1',
);

View File

@ -19,6 +19,8 @@ function makeThread(overrides: Partial<AgentExecutionThread> = {}): AgentExecuti
projectId: 'project-1',
title: null,
emoji: null,
parentThreadId: null,
parentAgentId: null,
sessionNumber: 1,
totalPromptTokens: 0,
totalCompletionTokens: 0,
@ -71,6 +73,52 @@ describe('AgentExecutionService', () => {
});
describe('recordMessage', () => {
it('passes thread metadata when creating a subagent execution session', async () => {
const thread = makeThread({ parentThreadId: 'parent-thread-1' });
const record: MessageRecord = {
assistantResponse: 'Done',
model: 'anthropic/claude-sonnet-4-5',
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
totalCost: 0.01,
toolCalls: [],
timeline: [],
startTime: Date.parse('2026-05-07T10:00:00Z'),
duration: 1234,
error: null,
};
agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ thread, created: true });
agentExecutionRepository.create.mockImplementation((entity) => entity as AgentExecution);
agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution);
await service.recordMessage({
threadId: 'thread-1',
agentId: 'agent-1',
agentName: 'Agent',
projectId: 'project-1',
userMessage: 'Goal:\nResearch API behavior.',
record,
source: 'subagent',
threadMetadata: {
parentThreadId: 'parent-thread-1',
parentAgentId: 'parent-agent-1',
},
});
expect(agentExecutionThreadRepository.findOrCreate).toHaveBeenCalledWith(
'thread-1',
'agent-1',
'Agent',
'project-1',
{
parentThreadId: 'parent-thread-1',
parentAgentId: 'parent-agent-1',
},
undefined,
undefined,
);
});
it('stamps the task snapshot version on newly created task sessions', async () => {
agentExecutionThreadRepository.findOrCreate.mockResolvedValue({
thread: makeThread({ title: 'Task run' }),
@ -96,6 +144,7 @@ describe('AgentExecutionService', () => {
'agent-1',
'Agent',
'project-1',
undefined,
'task-1',
'version-1',
);

View File

@ -1,4 +1,9 @@
import { AgentJsonConfigSchema, isNodeToolsEnabled, type AgentJsonConfig } from '@n8n/api-types';
import {
AgentJsonConfigSchema,
isNodeToolsEnabled,
isSubAgentsEnabled,
type AgentJsonConfig,
} from '@n8n/api-types';
const baseConfig: AgentJsonConfig = {
name: 'Test Agent',
@ -90,6 +95,33 @@ describe('isNodeToolsEnabled', () => {
});
});
describe('AgentJsonConfigSchema — subAgents', () => {
it('accepts saved agent references', () => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
subAgents: { agents: [{ agentId: 'agent-1' }] },
});
expect(parsed.success).toBe(true);
});
it('rejects the removed subAgents.enabled flag', () => {
expect(
AgentJsonConfigSchema.safeParse({ ...baseConfig, subAgents: { enabled: true } }).success,
).toBe(false);
});
});
describe('isSubAgentsEnabled', () => {
it('returns false when subAgents is undefined', () => {
expect(isSubAgentsEnabled(undefined)).toBe(false);
});
it('returns true only when at least one subagent ref exists', () => {
expect(isSubAgentsEnabled({ agents: [] })).toBe(false);
expect(isSubAgentsEnabled({ agents: [{ agentId: 'agent-1' }] })).toBe(true);
});
});
describe('AgentJsonConfigSchema — memory.observationalMemory', () => {
const memoryBase = { enabled: true, storage: 'n8n' as const };

View File

@ -65,3 +65,47 @@ describe('agent-sse-stream — stringifyError (via pumpChunks error chunk)', ()
expect(events).toEqual([{ type: 'error', message: 'null' }]);
});
});
describe('agent-sse-stream — tool execution lifecycle chunks', () => {
it('forwards tool-execution-start with its server startTime', async () => {
const events = await collectEvents([
{
type: 'tool-execution-start',
toolCallId: 'tc-1',
toolName: 'delegate_subagent',
startTime: 1_000,
},
]);
expect(events).toEqual([
{
type: 'tool-execution-start',
toolCallId: 'tc-1',
toolName: 'delegate_subagent',
startTime: 1_000,
},
]);
});
it('forwards tool-execution-end with its server endTime', async () => {
const events = await collectEvents([
{
type: 'tool-execution-end',
toolCallId: 'tc-1',
toolName: 'delegate_subagent',
isError: false,
endTime: 1_014,
},
]);
expect(events).toEqual([
{
type: 'tool-execution-end',
toolCallId: 'tc-1',
toolName: 'delegate_subagent',
isError: false,
endTime: 1_014,
},
]);
});
});

View File

@ -152,6 +152,53 @@ describe('AgentsBuilderToolsService', () => {
]);
});
it('list_sub_agents returns published same-project agents except the target agent', async () => {
const { service, agentsService } = makeService();
agentsService.findByProjectId.mockResolvedValue([
{
id: agentId,
name: 'Current Agent',
description: 'The agent being edited',
activeVersionId: 'active-current',
},
{
id: 'agent-research',
name: 'Research Agent',
description: 'Finds information on the web',
activeVersionId: 'active-research',
},
{
id: 'agent-draft',
name: 'Draft Agent',
description: 'Not published yet',
activeVersionId: null,
},
{
id: 'agent-risk',
name: 'Risk Agent',
description: null,
activeVersionId: 'active-risk',
},
] as Agent[]);
const result = await getJsonTool(service, BUILDER_TOOLS.LIST_SUB_AGENTS).handler!({}, ctx);
expect(agentsService.findByProjectId).toHaveBeenCalledWith(projectId);
expect(result).toEqual({
agents: [
{
agentId: 'agent-research',
name: 'Research Agent',
description: 'Finds information on the web',
},
{
agentId: 'agent-risk',
name: 'Risk Agent',
},
],
});
});
it('patch_config applies a patch when baseConfigHash matches', async () => {
const { service, agentsService } = makeService();
const currentConfig = { ...baseConfig, integrations: [] };

View File

@ -558,6 +558,59 @@ describe('AgentsService', () => {
// description column on the entity also stays untouched.
expect(savedEntity.description).toBe(agent.description);
});
it('stores subAgents when the inbound config provides saved agent refs', async () => {
const agent = makeAgent();
const subAgent = makeAgent({ id: 'agent-2', activeVersionId: 'published-version-2' });
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
agentRepository.findByIdAndProjectId.mockImplementation(async (id) => {
if (id === agentId) return agent;
if (id === 'agent-2') return subAgent;
return null;
});
const configWithSubAgents = {
name: 'Test Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Be helpful',
subAgents: { agents: [{ agentId: 'agent-2' }] },
} as AgentJsonConfig;
jest.spyOn(service, 'validateConfig').mockResolvedValue({
valid: true,
config: configWithSubAgents,
});
await service.updateConfig(agentId, projectId, configWithSubAgents);
const savedEntity = agentRepository.save.mock.calls[0][0] as Agent;
expect(savedEntity.schema?.subAgents).toEqual({ agents: [{ agentId: 'agent-2' }] });
});
it('rejects unpublished subagent references', async () => {
const agent = makeAgent();
const unpublishedSubAgent = makeAgent({ id: 'agent-2', activeVersionId: null });
agentRepository.findByIdAndProjectId.mockImplementation(async (id) => {
if (id === agentId) return agent;
if (id === 'agent-2') return unpublishedSubAgent;
return null;
});
const configWithSubAgents = {
name: 'Test Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Be helpful',
subAgents: { agents: [{ agentId: 'agent-2' }] },
} as AgentJsonConfig;
jest.spyOn(service, 'validateConfig').mockResolvedValue({
valid: true,
config: configWithSubAgents,
});
await expect(service.updateConfig(agentId, projectId, configWithSubAgents)).rejects.toThrow(
'Invalid agent config: Subagent "agent-2" must be published',
);
expect(agentRepository.save).not.toHaveBeenCalled();
});
});
describe('publishAgent', () => {

View File

@ -11,6 +11,82 @@ function makeToolResultChunk(toolName: string, output: unknown, toolCallId = 'tc
}
describe('ExecutionRecorder', () => {
describe('per-tool execution timing', () => {
afterEach(() => {
jest.useRealTimers();
});
it('records distinct per-tool end times from tool-execution-end for concurrent tools', () => {
jest.useFakeTimers();
jest.setSystemTime(1_000);
const recorder = new ExecutionRecorder();
// Two concurrent tool calls emitted together by the model.
recorder.record(makeToolCallChunk('a', {}, 'tc-1'));
recorder.record(makeToolCallChunk('b', {}, 'tc-2'));
// Both start executing together (server-stamped on the chunk).
recorder.record({
type: 'tool-execution-start',
toolCallId: 'tc-1',
toolName: 'a',
startTime: 1_100,
});
recorder.record({
type: 'tool-execution-start',
toolCallId: 'tc-2',
toolName: 'b',
startTime: 1_100,
});
// They finish at different real times (server-stamped on the chunk).
recorder.record({
type: 'tool-execution-end',
toolCallId: 'tc-1',
toolName: 'a',
isError: false,
endTime: 1_500,
});
recorder.record({
type: 'tool-execution-end',
toolCallId: 'tc-2',
toolName: 'b',
isError: false,
endTime: 3_000,
});
// The batched tool-results arrive together, after the slowest finished.
jest.setSystemTime(3_001);
recorder.record(makeToolResultChunk('a', 'ra', 'tc-1'));
recorder.record(makeToolResultChunk('b', 'rb', 'tc-2'));
recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);
const { timeline } = recorder.getMessageRecord();
const a = timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-1');
const b = timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-2');
// startTime from tool-execution-start, endTime from tool-execution-end —
// NOT the shared batched tool-result timestamp (3001).
expect(a).toMatchObject({ startTime: 1_100, endTime: 1_500, output: 'ra', success: true });
expect(b).toMatchObject({ startTime: 1_100, endTime: 3_000, output: 'rb', success: true });
});
it('falls back to the tool-result time when tool-execution-end is absent', () => {
jest.useFakeTimers();
jest.setSystemTime(1_000);
const recorder = new ExecutionRecorder();
recorder.record(makeToolCallChunk('a', {}, 'tc-1'));
jest.setSystemTime(2_000);
recorder.record(makeToolResultChunk('a', 'ra', 'tc-1'));
recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);
const { timeline } = recorder.getMessageRecord();
const a = timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-1');
expect(a).toMatchObject({ endTime: 2_000, output: 'ra', success: true });
});
});
describe('timeline ordering', () => {
it('captures text → tool call → text in order', () => {
const recorder = new ExecutionRecorder();

View File

@ -57,6 +57,8 @@ describe('execution-to-message-mapper', () => {
toolName: 'search_tool',
toolCallId: 'call-1',
input: { query: 'n8n' },
startTime: 111,
endTime: 120,
state: 'resolved',
output: { items: [1] },
},
@ -100,6 +102,8 @@ describe('execution-to-message-mapper', () => {
toolName: 'failing_tool',
toolCallId: 'call-1',
input: { id: '123' },
startTime: 100,
endTime: 120,
state: 'rejected',
error: 'Tool failed',
},

View File

@ -6,6 +6,7 @@ import { AgentExecution } from './entities/agent-execution.entity';
import type { MessageRecord } from './execution-recorder';
import { N8nMemory } from './integrations/n8n-memory';
import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository';
import type { AgentExecutionThreadMetadata } from './repositories/agent-execution-thread.repository';
import { AgentExecutionRepository } from './repositories/agent-execution.repository';
export interface RecordMessageParams {
@ -19,6 +20,8 @@ export interface RecordMessageParams {
hitlStatus?: 'suspended' | 'resumed';
/** Where the message originated from, e.g. 'chat', 'slack', 'task'. */
source?: string;
/** Optional metadata persisted on the thread when it is first created. */
threadMetadata?: AgentExecutionThreadMetadata;
/** When the run was triggered by a scheduled task, the task's id (stamped on the session). */
taskId?: string;
/** Published agent_history version that supplied the scheduled task snapshot. */
@ -56,6 +59,7 @@ export class AgentExecutionService {
record,
source,
hitlStatus,
threadMetadata,
taskId,
taskVersionId,
} = params;
@ -66,6 +70,7 @@ export class AgentExecutionService {
agentId,
agentName,
projectId,
threadMetadata,
taskId,
taskVersionId,
);

View File

@ -113,6 +113,7 @@ function emitToolChunk(
| 'tool-input-delta'
| 'tool-call'
| 'tool-execution-start'
| 'tool-execution-end'
| 'tool-result'
| 'tool-call-suspended';
}
@ -149,6 +150,16 @@ function emitToolChunk(
type: 'tool-execution-start',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
startTime: chunk.startTime,
});
break;
case 'tool-execution-end':
send({
type: 'tool-execution-end',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
isError: chunk.isError,
endTime: chunk.endTime,
});
break;
case 'tool-result':
@ -202,6 +213,7 @@ function emitChunkEvents(chunk: StreamChunk, ctx: ChunkHandlerCtx): { suspended:
case 'tool-input-delta':
case 'tool-call':
case 'tool-execution-start':
case 'tool-execution-end':
case 'tool-result':
case 'tool-call-suspended':
return emitToolChunk(chunk, ctx);

View File

@ -12,6 +12,7 @@ import {
AgentIntegrationSchema,
AgentJsonConfigSchema,
isNodeToolsEnabled,
isSubAgentsEnabled,
AgentModelSchema,
type AgentIntegrationConfig,
type AgentJsonConfig,
@ -23,6 +24,8 @@ import {
type AgentVersionListItemDto,
type ChatIntegrationDescriptor,
AgentPersistedMessageDto,
type SubAgentSource,
type SubAgentRunPolicy,
} from '@n8n/api-types';
import { extractFromAIParameters } from '@n8n/ai-utilities/fromai-helpers';
import { Logger } from '@n8n/backend-common';
@ -106,6 +109,8 @@ import { buildToolRegistry, type ToolRegistry } from './tool-registry';
import { ChatIntegrationService } from './integrations/chat-integration.service';
import { AgentKnowledgeCommandService } from './agent-knowledge-command.service';
import { AgentKnowledgeService } from './agent-knowledge.service';
import { createN8nDelegateSubAgentTool } from './sub-agents/delegate-sub-agent-tool';
import { SubAgentForegroundRunner } from './sub-agents/sub-agent-foreground-runner';
type AgentToolEntries = Agent['tools'];
@ -115,11 +120,17 @@ interface InjectRuntimeDependenciesParams {
projectId: string;
credentialProvider: CredentialProvider;
nodeToolsEnabled: boolean;
subAgentDelegation?: SubAgentDelegationConfig;
credentialIntegrations: AgentIntegrationConfig[];
/** Chat platform the runtime is being reconstructed for — drives the rich_interaction tool's capability profile. */
integrationType?: string;
}
interface SubAgentDelegationConfig {
sourcesById: Record<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}`;
@ -398,6 +409,10 @@ export class AgentsService {
return this.isNodeToolsModuleEnabled() && isNodeToolsEnabled(config);
}
private shouldAttachSubAgents(config: AgentJsonConfig): boolean {
return isSubAgentsEnabled(config.subAgents);
}
/**
* Return the list of registered chat platform integrations with their
* FE display metadata. Used by `GET /agents/integrations`.
@ -982,6 +997,7 @@ export class AgentsService {
projectId,
credentialProvider,
nodeToolsEnabled,
subAgentDelegation,
credentialIntegrations,
integrationType,
} = params;
@ -1062,6 +1078,16 @@ export class AgentsService {
this.attachNodeToolChain(agent, credentialProvider, projectId);
}
if (subAgentDelegation !== undefined) {
this.attachSubAgentDelegationTool({
agent,
agentId,
projectId,
credentialProvider,
delegation: subAgentDelegation,
});
}
// Inject checkpoint storage
if (!agent.hasCheckpointStorage()) {
agent.checkpoint(this.n8nCheckpointStorage);
@ -1082,6 +1108,38 @@ export class AgentsService {
agent.tool(this.agentsToolsService.getRuntimeTools(credentialProvider, projectId));
}
private attachSubAgentDelegationTool(params: {
agent: RuntimeAgent;
agentId: string;
projectId: string;
credentialProvider: CredentialProvider;
delegation: SubAgentDelegationConfig;
}): void {
const { agent, agentId, projectId, credentialProvider, delegation } = params;
agent.tool(
createN8nDelegateSubAgentTool({
runner: Container.get(SubAgentForegroundRunner),
...delegation,
projectId,
parentAgentId: agentId,
credentialProvider,
policy: this.buildSubAgentPolicy(),
createToolExecutor: (toolCodeByName) =>
this.secureRuntime.createToolExecutor(toolCodeByName),
createMemoryFactory: (memoryOwnerAgentId) => this.getMemoryFactory(memoryOwnerAgentId),
}),
);
this.logger.debug('Injected delegate_subagent tool', { agentId });
}
/** Delegation guardrails sourced from {@link AgentsConfig} (env-configurable). */
private buildSubAgentPolicy(): SubAgentRunPolicy {
return {
maxChildren: this.agentsConfig.subAgentMaxChildren,
timeoutMs: this.agentsConfig.subAgentTimeoutMs,
};
}
/**
* Resume a suspended tool call and yield the resulting stream chunks.
* Used by chat integration handlers to continue an agent run after
@ -1781,6 +1839,7 @@ export class AgentsService {
throw new UserError(`Invalid agent config: ${result.error}`);
}
await this.validateSubAgentRefs(result.config, entity);
this.validateConfigRefs(result.config, entity);
// Task refs resolve against task definition bodies (a separate table), so
@ -1820,6 +1879,7 @@ export class AgentsService {
const descriptionProvided = result.config.description !== undefined;
const credentialProvided = result.config.credential !== undefined;
const memoryProvided = result.config.memory !== undefined;
const subAgentsProvided = result.config.subAgents !== undefined;
const providerToolsProvided = result.config.providerTools !== undefined;
const configBlockProvided = result.config.config !== undefined;
const mcpServersProvided = result.config.mcpServers !== undefined;
@ -1840,6 +1900,7 @@ export class AgentsService {
...(descriptionProvided ? { description: decomposedSchema.description } : {}),
...(credentialProvided ? { credential: decomposedSchema.credential } : {}),
...(memoryProvided ? { memory: decomposedSchema.memory } : {}),
...(subAgentsProvided ? { subAgents: decomposedSchema.subAgents } : {}),
...(toolsProvided ? { tools: decomposedSchema.tools } : {}),
...(skillsProvided ? { skills: decomposedSchema.skills } : {}),
...(tasksProvided ? { tasks: decomposedSchema.tasks } : {}),
@ -1847,6 +1908,7 @@ export class AgentsService {
...(configBlockProvided ? { config: decomposedSchema.config } : {}),
...(mcpServersProvided ? { mcpServers: decomposedSchema.mcpServers } : {}),
};
nextSchema.subAgents = normalizeSubAgentsConfig(nextSchema.subAgents);
entity.schema = nextSchema;
entity.name = result.config.name;
@ -2144,6 +2206,46 @@ export class AgentsService {
}
}
/**
* Resolve configured sub-agent refs to their saved agents, de-duplicated
* (order preserved) and fetched once each. `agent` is null when the id no
* longer resolves in the project. Shared by save-time validation and
* runtime delegation config.
*/
private async fetchUniqueSubAgents(
refs: Array<{ agentId: string }>,
projectId: string,
): Promise<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 async validateSubAgentRefs(config: AgentJsonConfig, entity: Agent) {
const refs = config.subAgents?.agents ?? [];
if (refs.length === 0) return;
for (const { agentId, agent } of await this.fetchUniqueSubAgents(refs, entity.projectId)) {
if (agentId === entity.id) {
throw new UserError('Invalid agent config: An agent cannot use itself as a subagent');
}
if (!agent) {
throw new UserError(`Invalid agent config: Subagent "${agentId}" was not found`);
}
if (!agent.activeVersionId) {
throw new UserError(`Invalid agent config: Subagent "${agentId}" must be published`);
}
}
}
private getMissingCustomToolIds(
config: AgentJsonConfig | null,
tools: AgentToolEntries,
@ -2316,12 +2418,17 @@ export class AgentsService {
buildMcpClient,
});
const subAgentDelegation = this.shouldAttachSubAgents(config)
? await this.createSubAgentDelegationConfig(config, agentEntity.projectId)
: undefined;
await this.injectRuntimeDependencies({
agent: reconstructed,
agentId: agentEntity.id,
projectId: agentEntity.projectId,
credentialProvider,
nodeToolsEnabled: this.shouldAttachNodeTools(config.config),
...(subAgentDelegation !== undefined ? { subAgentDelegation } : {}),
credentialIntegrations: agentEntity.integrations ?? [],
integrationType,
});
@ -2329,9 +2436,40 @@ export class AgentsService {
const toolRegistry = buildToolRegistry(resolvedTools);
return { agent: reconstructed, toolRegistry };
}
private async createSubAgentDelegationConfig(
config: AgentJsonConfig,
projectId: string,
): Promise<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;
}
}
function getProviderPrefix(modelId: string): string {
const slashIdx = modelId.indexOf('/');
return slashIdx === -1 ? '' : modelId.slice(0, slashIdx);
}
function normalizeSubAgentsConfig(
subAgents: AgentJsonConfig['subAgents'],
): AgentJsonConfig['subAgents'] {
if (!subAgents) return undefined;
return { agents: subAgents.agents ?? [] };
}

View File

@ -118,6 +118,7 @@ describe('builder model recommendations', () => {
expect(prompt).toContain('## LLM Selection Guidance');
expect(prompt).toContain('## Memory Guidance');
expect(prompt).toContain('## Tool Guidance');
expect(prompt).toContain('## Sub Agents');
expect(prompt).toContain('Additional specialized builder guidance is available');
expect(prompt).toContain('chat integration/trigger or a node/workflow tool');
expect(prompt).toContain('use Linear node tools for ordinary issue search/create/update');
@ -127,6 +128,31 @@ describe('builder model recommendations', () => {
expect(prompt).not.toContain('agent-builder-tools');
});
it('tells the builder to write target agent descriptions', () => {
const prompt = buildPrompt(null);
expect(prompt).toContain('Fresh agents must include a brief `description`');
expect(prompt).toContain(
'Requires `name`, `description`, `model`, `credential`, and `instructions`',
);
expect(prompt).toContain(
'"description": "Answers support questions and helps triage customer issues."',
);
});
it('teaches the builder how to configure subagent delegation', () => {
const prompt = buildPrompt(null);
expect(prompt).toContain('`subAgents: { "agents": [{ "agentId": "<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', () => {
const prompt = buildPrompt(null);

View File

@ -114,6 +114,9 @@ Converse".
choices from a known small set, or an empty \`options\` array for an open-ended
question (renders a freeform card). Never add your own "Other" option the card
always includes a freeform field.
- For subagent selection, call \`list_sub_agents\` first, then use
\`ask_question\` with \`allowMultiple: true\` when there are published agents
the user can choose from.
- Never call two interactive tools in parallel. The run suspends on the first.
- Never re-ask a question the user already answered in this thread.
- After resume, continue with the next concrete tool action. Do not narrate the
@ -134,6 +137,35 @@ Prefer \`$fromAI\` whenever the target agent should decide a value at runtime.
Always wrap expressions in \`={{ }}\`. Never pipe AI-chosen node-tool fields
through \`$json\`; use \`$fromAI\` for those fields instead.`;
export const SUB_AGENTS_SECTION = `\
## Sub Agents
The target agent supports optional subagent delegation through
\`subAgents: { "agents": [{ "agentId": "<published-agent-id>" }] }\`.
When \`subAgents.agents\` has at least one entry, the runtime injects
\`delegate_subagent\` and extra target-agent system guidance. If no saved agents
are configured, no subagent tool is available.
- Configure subagents only when the user asks for subagents, delegation, helper
agents, independent review, or research-style task decomposition.
- Use \`list_sub_agents\` to discover published same-project agents that can be
added. Do not write agent ids from memory, prose, or user-entered free text.
- If published agents are available and the user has not named exact agents,
call \`ask_question\` with \`allowMultiple: true\`. Use each option's
\`value\` as the returned \`agentId\`, and include descriptions when present.
- If no published agents are available, do not configure subagents. Tell the
user they need to publish an agent in this project first.
- Patch selected agents into \`subAgents.agents\` as
\`{ "agentId": "<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.`;
export const READ_CONFIG_FRESHNESS_SECTION = `\
## Config Freshness
@ -259,6 +291,14 @@ export const FEW_SHOT_FLOWS_SECTION = `\
4. \`patch_config(...)\` adding the tool and omitting only the skipped
credential slot. Do not abort the tool addition.
### Enable subagents with saved agents
1. \`list_sub_agents()\`.
2. If it returns one or more agents and the user has not named exact ones, call
\`ask_question({ allowMultiple: true, ... })\` with those agents as options.
3. \`read_config()\`.
4. \`patch_config(...)\` adding selected \`{ "agentId": "<returned-agent-id>" }\`
refs to \`/subAgents/agents\`.
### Add MCP integration: "Connect Notion MCP"
1. \`load_skill({ "skillId": "agent-builder-mcp" })\`.
2. \`search_mcp_servers({ queries: ["notion"] })\`.
@ -305,6 +345,7 @@ export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
getBuilderSkillRoutingSection(),
INTERACTIVE_TOOLS_SECTION,
N8N_EXPRESSIONS_SECTION,
SUB_AGENTS_SECTION,
READ_CONFIG_FRESHNESS_SECTION,
WORKFLOW_SECTION,
FEW_SHOT_FLOWS_SECTION,

View File

@ -423,6 +423,27 @@ export class AgentsBuilderToolsService {
.handler(async () => this.agentsService.listChatIntegrations())
.build();
const listSubAgentsTool = new Tool(BUILDER_TOOLS.LIST_SUB_AGENTS)
.description(
'List published agents in the same project that can be added to the target agent as subagents. ' +
'Excludes the target agent itself and unpublished agents. Use before asking the user which ' +
'subagents to add. Returned `agentId` values are the only valid values to write into `subAgents.agents[].agentId`.',
)
.input(z.object({}))
.handler(async () => {
const agents = await this.agentsService.findByProjectId(projectId);
return {
agents: agents
.filter((agent) => agent.id !== agentId && agent.activeVersionId !== null)
.map((agent) => ({
agentId: agent.id,
name: agent.name,
...(agent.description ? { description: agent.description } : {}),
})),
};
})
.build();
const modelLookup: ModelLookup = {
list: async (credentialId, credentialType, lookup) =>
await this.builderModelLookupService.list(user, credentialId, credentialType, lookup),
@ -433,6 +454,7 @@ export class AgentsBuilderToolsService {
writeConfigTool,
patchConfigTool,
listIntegrationTypesTool,
listSubAgentsTool,
buildResolveLlmTool({ credentialProvider, modelLookup }),
buildAskCredentialTool({
credentialProvider,

View File

@ -10,6 +10,7 @@ export const BUILDER_TOOLS = {
CREATE_SKILL: 'create_skill',
CREATE_TASK: 'create_task',
LIST_INTEGRATION_TYPES: 'list_integration_types',
LIST_SUB_AGENTS: 'list_sub_agents',
RESOLVE_LLM: 'resolve_llm',
SEARCH_MCP_SERVERS: 'search_mcp_servers',
VERIFY_MCP_SERVER: 'verify_mcp_server',

View File

@ -25,6 +25,20 @@ describe('ask_question tool', () => {
expect(result).toEqual({ values: ['slack'] });
});
it('suspends for a multi-select question with one option', async () => {
const ctx = makeCtx();
await tool.handler!(
{
question: 'Pick subagents',
options: [{ label: 'Research Agent', value: 'agent-research' }],
allowMultiple: true,
},
ctx as never,
);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
});
it('suspends when there are multiple options', async () => {
const ctx = makeCtx();
await tool.handler!(

View File

@ -27,11 +27,12 @@ export function buildAskQuestionTool(): BuiltTool {
ctx: InterruptibleToolContext<AskQuestionInput, AskQuestionResume>,
) => {
if (ctx.resumeData !== undefined) return ctx.resumeData;
// A single concrete option has no real choice — auto-pick it so the
// LLM doesn't render a card the user can only confirm. Open-ended
// questions use an empty options array and still suspend (the card
// renders a freeform-only input).
if (input.options.length === 1) {
// A single single-select option has no real choice — auto-pick it so
// the LLM doesn't render a card the user can only confirm. Multi-select
// questions still render so the user can decide whether to select the
// one option, and open-ended questions (empty options array) still
// suspend to show the freeform-only card.
if (input.options.length === 1 && input.allowMultiple !== true) {
return { values: [input.options[0].value] };
}
return await ctx.suspend(input);

View File

@ -29,7 +29,7 @@ ${getSchemaReferenceSection()}
- Follow the Config schema reference exactly; do not invent top-level fields.
- Keep each feature in the schema path where it belongs.
- Preserve unrelated existing config unless the user asked to change it.
- Never write placeholder instructions.
- Never write placeholder instructions or descriptions.
- Never copy credential IDs from \`list_credentials\`; use \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`.
- Valid provider tool keys are complete provider tool IDs documented in the Tool Guidance section.
- \`providerTools\` keys must be complete provider tool IDs from the valid key list.
@ -38,13 +38,15 @@ ${getSchemaReferenceSection()}
#### Create Or Replace A Fresh Runnable Agent
- Requires \`name\`, \`model\`, \`credential\`, and \`instructions\`.
- Requires \`name\`, \`description\`, \`model\`, \`credential\`, and \`instructions\`.
- \`description\` must be a brief, specific summary of what the agent does.
- Keep \`tools\` and \`skills\` arrays if present.
Good minimal shape:
\`\`\`json
{
"name": "Support assistant",
"description": "Answers support questions and helps triage customer issues.",
"model": "openrouter/openai/gpt-5.5",
"credential": "<main-llm-credential-id>",
"instructions": "Help the user with support questions.",
@ -66,6 +68,20 @@ Use \`patch_config\` with:
- If \`skills\` is missing, add \`/skills\` with an array.
- Ref shape: \`{ "type": "skill", "id": "<returned-id>" }\`.
#### Add Saved Agents As Subagents
- Call \`list_sub_agents\` before asking or writing. Only persist agent IDs
returned by that tool.
- If \`subAgents\` is missing, add it as
\`{ "agents": [{ "agentId": "<selected-agent-id>" }] }\`.
- If \`subAgents\` exists without \`agents\`, add \`/subAgents/agents\` with the
selected refs.
- If \`subAgents.agents\` exists, append new refs to \`/subAgents/agents/-\`.
- Avoid duplicate refs. Ref shape: \`{ "agentId": "<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.
#### Configure Native Provider Features
- Thinking lives under \`config.thinking\`.
@ -124,7 +140,7 @@ Bad: replacing \`config\` while dropping unrelated settings
- \`patch_config\` cannot create a config when none exists; use \`write_config\` first.
- \`/array/-\` appends to an array; \`/array/0\` inserts before the current first item.
- Model-only changes must preserve existing Brave or SearXNG \`config.webSearch\`.
- Empty, placeholder, or guessed \`instructions\` are rejected; ask for details instead.
- Empty, placeholder, or guessed \`instructions\` or \`description\` values are rejected; ask for details instead.
### Verify

View File

@ -52,6 +52,8 @@ export function getConfigRulesSection(): string {
- \`model\` must be "provider/model-name".
- \`credential\` must be the id returned by \`resolve_llm\` or \`ask_llm\`.
- Fresh agents must include a brief \`description\` explaining what the agent
does. Keep it specific and user-facing; never write placeholder copy.
- Fresh agents must include
\`memory: { "enabled": true, "storage": "n8n" }\`
unless the user explicitly asks to disable memory.
@ -60,6 +62,11 @@ export function getConfigRulesSection(): string {
\`credentialType: "openAiApi"\`.
- Memory worker model fields use \`{ "model": "provider/model-name", "credential": "<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\`.
- Web search lives under \`config.webSearch\`. Only OpenAI and Anthropic models
support native web search; for those providers, use
\`{ "enabled": true, "provider": "native" }\` or omit \`provider\`. Every
@ -70,8 +77,8 @@ export function getConfigRulesSection(): string {
- Preserve existing Brave/SearXNG \`config.webSearch\` on model switches unless
the user explicitly asks to change web-search method.
- \`config.maxIterations\` caps the number of agent loop iterations per run. Do not set or change this unless the user explicitly asks.
- Fresh agents need a real model, credential, and instructions before config
is written.`;
- Fresh agents need a real model, credential, description, and instructions
before config is written.`;
}
export function getSchemaReferenceSection(): string {

View File

@ -246,6 +246,12 @@ export class ExecutionRecorder {
case 'tool-call':
this.recordToolCall(chunk.toolCallId, chunk.toolName, chunk.input);
break;
case 'tool-execution-start':
this.recordToolExecutionStart(chunk.toolCallId, chunk.startTime);
break;
case 'tool-execution-end':
this.recordToolExecutionEnd(chunk.toolCallId, chunk.isError, chunk.endTime);
break;
case 'tool-result':
this.recordToolResult(
chunk.toolCallId,
@ -265,7 +271,7 @@ export class ExecutionRecorder {
};
}
this.model = chunk.model ?? null;
this.totalCost = chunk.totalCost ?? chunk.usage?.cost ?? null;
this.totalCost = chunk.usage?.cost ?? null;
break;
case 'tool-call-suspended':
this.flushTextBuffer();
@ -367,6 +373,46 @@ export class ExecutionRecorder {
});
}
/**
* Real per-tool execution start, bridged from the runtime event bus. The
* `tool-call` chunk only marks when the model emitted the call; this marks
* when the handler actually started. Uses the server-stamped `startTime`
* carried on the chunk so the persisted duration matches the live one
* exactly (the FE reads the same value off the stream).
*/
private recordToolExecutionStart(toolCallId: string, startTime: number): void {
if (!toolCallId) return;
const entry = this.findOpenTimelineToolCall(toolCallId);
if (entry) entry.startTime = startTime;
}
/**
* Real per-tool execution end, bridged from the runtime event bus. Closes
* the timeline entry with the server-stamped finish time so concurrently-
* executed tools keep distinct durations the batched `tool-result` chunks
* all arrive together and would otherwise share a single end timestamp.
*/
private recordToolExecutionEnd(toolCallId: string, isError: boolean, endTime: number): void {
if (!toolCallId) return;
const entry = this.findOpenTimelineToolCall(toolCallId);
if (entry) {
entry.endTime = endTime;
entry.success = !isError;
}
}
/** Most recent not-yet-closed timeline tool-call entry for a tool call id. */
private findOpenTimelineToolCall(
toolCallId: string,
): (TimelineEvent & { type: 'tool-call' }) | undefined {
return [...this.timeline]
.reverse()
.find(
(e): e is TimelineEvent & { type: 'tool-call' } =>
e.type === 'tool-call' && e.toolCallId === toolCallId && e.endTime === 0,
);
}
/**
* Find the still-open flat tool-call entry to attach a result to. Prefers
* an exact match on `toolCallId`; when the stream omits the id (empty
@ -410,13 +456,16 @@ export class ExecutionRecorder {
.find(
(e): e is TimelineEvent & { type: 'tool-call' } =>
e.type === 'tool-call' &&
(toolCallId ? e.toolCallId === toolCallId : e.name === name) &&
e.endTime === 0,
(toolCallId ? e.toolCallId === toolCallId : e.name === name && e.endTime === 0),
);
if (pendingTimeline) {
pendingTimeline.output = recordedOutput;
pendingTimeline.endTime = Date.now();
pendingTimeline.success = !isError;
// `tool-execution-end` (real per-tool finish) normally closed this entry
// already; only fall back to the batched result time if it never fired.
if (pendingTimeline.endTime === 0) {
pendingTimeline.endTime = Date.now();
}
if (pendingTimeline.kind === 'workflow' && isRecord(recordedOutput)) {
const execId = recordedOutput.executionId;

View File

@ -5,6 +5,11 @@ import { AgentExecutionThread } from '../entities/agent-execution-thread.entity'
const SESSION_NUMBER_RETRY_ATTEMPTS = 3;
export interface AgentExecutionThreadMetadata {
parentThreadId?: string;
parentAgentId?: string;
}
export interface AgentExecutionThreadPage {
threads: AgentExecutionThread[];
nextCursor: string | null;
@ -25,6 +30,7 @@ export class AgentExecutionThreadRepository extends Repository<AgentExecutionThr
agentId: string,
agentName: string,
projectId: string,
metadata?: AgentExecutionThreadMetadata,
taskId?: string | null,
taskVersionId?: string | null,
): Promise<{ thread: AgentExecutionThread; created: boolean }> {
@ -35,6 +41,7 @@ export class AgentExecutionThreadRepository extends Repository<AgentExecutionThr
agentId,
agentName,
projectId,
metadata,
taskId,
taskVersionId,
);
@ -51,6 +58,7 @@ export class AgentExecutionThreadRepository extends Repository<AgentExecutionThr
agentId: string,
agentName: string,
projectId: string,
metadata?: AgentExecutionThreadMetadata,
taskId?: string | null,
taskVersionId?: string | null,
): Promise<{ thread: AgentExecutionThread; created: boolean }> {
@ -77,6 +85,8 @@ export class AgentExecutionThreadRepository extends Repository<AgentExecutionThr
taskId: taskId ?? null,
taskVersionId: taskVersionId ?? null,
sessionNumber,
parentThreadId: metadata?.parentThreadId ?? null,
parentAgentId: metadata?.parentAgentId ?? null,
});
const saved = await repository.save(thread);
return { thread: saved, created: true };

View File

@ -0,0 +1,213 @@
import type { CredentialProvider, 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,
} from '../delegate-sub-agent-tool';
import type {
SubAgentForegroundResult,
SubAgentForegroundRunner,
} from '../sub-agent-foreground-runner';
const projectId = 'project-1';
const source: SubAgentSource = {
agentId: 'agent-2',
};
const generateResult: GenerateResult = {
runId: 'child-run-1',
finishReason: 'stop',
usage: {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cost: 0.01,
},
messages: [
{
role: 'assistant',
type: 'llm',
content: [
{ type: 'text', text: 'Preamble' },
{ type: 'text', text: 'Child answer' },
],
},
],
};
const foregroundResult: SubAgentForegroundResult = {
taskPath: '/root/research_api_0',
threadId: 'child-thread-1',
status: 'completed',
result: generateResult,
};
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 () => {
const tool = createN8nDelegateSubAgentTool({
runner,
sourcesById: { 'agent-2': source },
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
policy: { maxChildren: 2, timeoutMs: 1000 },
});
await expect(
tool.handler?.(
{
taskName: 'Research API',
goal: 'Find the API behavior.',
context: 'Focus on auth endpoints.',
expectedOutput: 'A short summary.',
},
{
runId: 'parent-run-1',
toolCallId: 'tool-call-1',
},
),
).resolves.toMatchObject({
status: 'completed',
taskPath: '/root/research_api_0',
runId: 'child-run-1',
threadId: 'child-thread-1',
answer: 'Preamble\nChild answer',
});
expect(runner.runForeground).toHaveBeenCalledWith(
{
goal: 'Find the API behavior.',
context: 'Focus on auth endpoints.',
expectedOutput: 'A short summary.',
source,
executionMode: 'foreground',
policy: { maxChildren: 2, timeoutMs: 1000 },
taskPath: '/root/research_api_0',
},
expect.objectContaining({
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
}),
);
});
it('forwards the parent persistence thread id and resource id to the runner', async () => {
const tool = createN8nDelegateSubAgentTool({
runner,
sourcesById: { 'agent-2': source },
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
});
await tool.handler?.(
{ taskName: 'Research API', goal: 'Find behavior.' },
{
runId: 'parent-run-1',
persistence: { threadId: 'parent-thread-1', resourceId: 'resource-1' },
},
);
expect(runner.runForeground).toHaveBeenCalledWith(
expect.objectContaining({
parentThreadId: 'parent-thread-1',
parentResourceId: 'resource-1',
}),
expect.any(Object),
);
});
it('selects a configured n8n agent source by subAgentId', async () => {
const selectedSource: SubAgentSource = { agentId: 'agent-2' };
const tool = createN8nDelegateSubAgentTool({
runner,
sourcesById: {
'agent-2': selectedSource,
},
availableSubAgents: [{ id: 'agent-2', name: 'Research Agent' }],
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
});
await tool.handler?.(
{ subAgentId: 'agent-2', taskName: 'Research API', goal: 'Find behavior.' },
{ runId: 'parent-run-1' },
);
expect(runner.runForeground).toHaveBeenCalledWith(
expect.objectContaining({
source: selectedSource,
}),
expect.any(Object),
);
});
it('returns a failed tool output when the foreground runner throws', async () => {
runner.runForeground.mockRejectedValue(new Error('child failed'));
const tool = createN8nDelegateSubAgentTool({
runner,
sourcesById: { 'agent-2': source },
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
});
await expect(
tool.handler?.(
{ taskName: 'Research API', goal: 'Find behavior.' },
{ runId: 'parent-run-1' },
),
).resolves.toMatchObject({
status: 'failed',
taskPath: '/root/research_api_0',
answer: '',
error: 'child failed',
});
});
});
describe('formatSubAgentToolOutput', () => {
it('keeps child metadata compact for the parent model', () => {
expect(formatSubAgentToolOutput(foregroundResult)).toEqual({
status: 'completed',
taskPath: '/root/research_api_0',
runId: 'child-run-1',
threadId: 'child-thread-1',
answer: 'Preamble\nChild answer',
usage: {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cost: 0.01,
},
finishReason: 'stop',
});
});
});

View File

@ -0,0 +1,425 @@
import type {
BuiltAgent,
CredentialProvider,
StreamChunk,
StreamResult,
ToolDescriptor,
} from '@n8n/agents';
import type { Logger } from '@n8n/backend-common';
import type {
ResolvedSubAgentSource,
RunnableAgentJsonConfig,
SubAgentSpawnRequest,
} from '@n8n/api-types';
import { mock } from 'jest-mock-extended';
import type { AgentExecutionService } from '../../agent-execution.service';
import { buildFromJson, type ToolExecutor } from '../../json-config/from-json-config';
import { SubAgentForegroundRunner } from '../sub-agent-foreground-runner';
import type {
ResolvedSubAgentRuntimeSource,
SubAgentSourceResolver,
} from '../sub-agent-source-resolver';
jest.mock('../../json-config/from-json-config', () => ({
buildFromJson: jest.fn(),
}));
const projectId = 'project-1';
const parentThreadId = 'parent-thread-1';
const parentAgentId = 'parent-agent-1';
const runnableConfig: RunnableAgentJsonConfig = {
name: 'Helper Agent',
model: 'anthropic/claude-sonnet-4-5',
credential: 'credential-1',
instructions: 'Help with delegated work.',
};
const source: ResolvedSubAgentSource = {
sourceId: 'agent-1',
config: runnableConfig,
};
const toolDescriptor: ToolDescriptor = {
name: 'lookup_customer',
description: 'Look up a customer',
systemInstruction: null,
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: null,
hasSuspend: false,
hasResume: false,
hasToMessage: false,
requireApproval: false,
providerOptions: null,
};
const runtimeSource: ResolvedSubAgentRuntimeSource = {
source,
toolDescriptors: {
tool_1: toolDescriptor,
},
toolCodeByName: {
lookup_customer: 'return input;',
},
skills: {
skill_1: {
name: 'Skill 1',
description: 'Helps with tests',
instructions: 'Skill body',
},
},
};
const spawnRequest: SubAgentSpawnRequest = {
goal: 'Find the relevant API behavior.',
context: 'Focus on auth endpoints.',
expectedOutput: 'A concise summary.',
source: {
agentId: 'agent-1',
},
executionMode: 'foreground',
parentThreadId,
taskPath: '/root/research_api_0',
};
const defaultStreamChunks: StreamChunk[] = [
{ type: 'text-delta', id: 'text-1', delta: 'Child answer' },
{
type: 'finish',
finishReason: 'stop',
model: 'anthropic/claude-sonnet-4-5',
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cost: 0.01 },
},
];
describe('SubAgentForegroundRunner', () => {
let sourceResolver: jest.Mocked<SubAgentSourceResolver>;
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();
sourceResolver = mock<SubAgentSourceResolver>();
sourceResolver.resolveForRuntime.mockResolvedValue(runtimeSource);
agentExecutionService = mock<AgentExecutionService>();
logger = mock<Logger>();
runner = new SubAgentForegroundRunner(sourceResolver, agentExecutionService, logger);
childAgent = mock<BuiltAgent>();
childAgent.stream.mockResolvedValue(makeStreamResult(defaultStreamChunks));
childAgent.close.mockResolvedValue(undefined);
jest.mocked(buildFromJson).mockResolvedValue(childAgent as never);
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 () => {
const result = await runner.runForeground(spawnRequest, {
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
});
expect(result).toMatchObject({
taskPath: '/root/research_api_0',
threadId: expect.any(String),
status: 'completed',
result: expect.objectContaining({
runId: 'child-run-1',
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cost: 0.01 },
}),
});
expect(createToolExecutor).toHaveBeenCalledWith(runtimeSource.toolCodeByName);
expect(childAgent.close).toHaveBeenCalledTimes(1);
expect(buildFromJson).toHaveBeenCalledWith(
runnableConfig,
runtimeSource.toolDescriptors,
expect.objectContaining({
toolExecutor,
credentialProvider,
skills: runtimeSource.skills,
memoryFactory: expect.any(Function),
}),
);
// A delegated run gets an ordinary uuid thread id (no special structure).
// With no parent resource scope, memory isolates to the run's own thread,
// so resourceId === threadId.
expect(childAgent.stream).toHaveBeenCalledWith(
expect.stringContaining('Goal:\nFind the relevant API behavior.'),
expect.objectContaining({
persistence: {
resourceId: result.threadId,
threadId: result.threadId,
},
}),
);
const childPrompt = childAgent.stream.mock.calls[0]?.[0] as string;
expect(childPrompt).toContain('Context:\nFocus on auth endpoints.');
expect(childPrompt).toContain('Expected output:\nA concise summary.');
// Every sub-agent run is a saved n8n agent, so it records under its run
// thread id, owned by the sub-agent's own id.
expect(agentExecutionService.recordMessage).toHaveBeenCalledWith(
expect.objectContaining({
threadId: result.threadId,
agentId: 'agent-1',
source: 'subagent',
}),
);
});
it('omits subAgents from the child config so delegated runs cannot spawn sub-agents', async () => {
sourceResolver.resolveForRuntime.mockResolvedValue({
...runtimeSource,
source: {
...runtimeSource.source,
config: {
...runnableConfig,
subAgents: { agents: [{ agentId: 'agent-nested' }] },
},
},
});
await runner.runForeground(spawnRequest, {
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
});
expect(buildFromJson).toHaveBeenCalledWith(
expect.not.objectContaining({
subAgents: expect.anything(),
}),
runtimeSource.toolDescriptors,
expect.any(Object),
);
});
it('inherits the parent resource id as the child memory scope when provided', async () => {
const result = await runner.runForeground(
{ ...spawnRequest, parentResourceId: 'draft-chat:user-1' },
{
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
},
);
expect(childAgent.stream).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
persistence: {
resourceId: 'draft-chat:user-1',
threadId: result.threadId,
},
}),
);
expect(result.threadId).toEqual(expect.any(String));
});
it('uses the saved n8n agent id as memory owner and records the session under the run thread id', async () => {
sourceResolver.resolveForRuntime.mockResolvedValue({
...runtimeSource,
source: {
sourceId: 'agent-2',
versionId: 'version-1',
config: {
...runnableConfig,
memory: { enabled: true, storage: 'n8n' },
},
},
});
jest.mocked(buildFromJson).mockImplementation(async (_config, _toolDescriptors, options) => {
await options.memoryFactory({ enabled: true, storage: 'n8n' });
return childAgent as never;
});
const result = await runner.runForeground(
{
...spawnRequest,
source: { agentId: 'agent-2', versionId: 'version-1' },
},
{
projectId,
parentAgentId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
},
);
// Memory is owned by the sub-agent's own id, exactly like a normal agent.
expect(createMemoryFactory).toHaveBeenCalledWith('agent-2');
expect(childAgent.stream).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
persistence: {
resourceId: result.threadId,
threadId: result.threadId,
},
}),
);
expect(agentExecutionService.recordMessage).toHaveBeenCalledWith(
expect.objectContaining({
// Same id as the SDK memory thread above, so title sync + deletion line up.
threadId: result.threadId,
agentId: 'agent-2',
agentName: 'Helper Agent',
projectId,
source: 'subagent',
// Parent linkage lives in columns, not in the thread id.
threadMetadata: {
parentThreadId,
parentAgentId,
},
}),
);
});
it('marks the run as failed when the child result contains an error', async () => {
childAgent.stream.mockResolvedValue(
makeStreamResult([
{ type: 'error', error: new Error('failed') },
{ type: 'finish', finishReason: 'error' },
]),
);
await expect(
runner.runForeground(spawnRequest, {
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
}),
).resolves.toMatchObject({
status: 'failed',
});
expect(childAgent.close).toHaveBeenCalledTimes(1);
});
it('passes an abort signal when timeout policy is configured', async () => {
await runner.runForeground(
{
...spawnRequest,
policy: { timeoutMs: 1000 },
},
{
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
},
);
expect(childAgent.stream).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
abortSignal: expect.any(AbortSignal),
}),
);
});
it('aborts the child run when the parent run is cancelled', async () => {
const parentAbort = new AbortController();
childAgent.stream.mockImplementation(
async (_input, options) =>
await new Promise<StreamResult>((resolve) => {
const settle = () =>
resolve(
makeStreamResult([
{ type: 'error', error: new Error('aborted') },
{ type: 'finish', finishReason: 'error' },
]),
);
if (options?.abortSignal?.aborted) settle();
else options?.abortSignal?.addEventListener('abort', settle, { once: true });
}),
);
const run = runner.runForeground(spawnRequest, {
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
abortSignal: parentAbort.signal,
});
parentAbort.abort();
await expect(run).resolves.toMatchObject({ status: 'failed' });
expect(childAgent.stream).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ abortSignal: expect.any(AbortSignal) }),
);
});
it('returns failed status when timeout aborts the child run', async () => {
jest.useFakeTimers();
childAgent.stream.mockImplementation(
async (_input, options) =>
await new Promise<StreamResult>((resolve) => {
options?.abortSignal?.addEventListener('abort', () => {
resolve(
makeStreamResult([
{ type: 'error', error: new Error('aborted') },
{ type: 'finish', finishReason: 'error' },
]),
);
});
}),
);
try {
const run = runner.runForeground(
{
...spawnRequest,
policy: { timeoutMs: 1000 },
},
{
projectId,
credentialProvider,
createToolExecutor,
createMemoryFactory,
},
);
await jest.advanceTimersByTimeAsync(1000);
await expect(run).resolves.toMatchObject({
status: 'failed',
});
} finally {
jest.useRealTimers();
}
});
});
function makeStreamResult(chunks: StreamChunk[]): StreamResult {
return {
runId: 'child-run-1',
stream: new ReadableStream<StreamChunk>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
}),
};
}

View File

@ -0,0 +1,180 @@
import { type AgentJsonConfig } from '@n8n/api-types';
import type { ToolDescriptor } from '@n8n/agents';
import { mock } from 'jest-mock-extended';
import type { AgentHistory } from '../../entities/agent-history.entity';
import type { Agent } from '../../entities/agent.entity';
import type { AgentHistoryRepository } from '../../repositories/agent-history.repository';
import type { AgentRepository } from '../../repositories/agent.repository';
import { SubAgentSourceResolver } from '../sub-agent-source-resolver';
const projectId = 'project-1';
const agentId = 'agent-1';
const versionId = 'version-1';
const runnableConfig: AgentJsonConfig = {
name: 'Helper Agent',
description: 'Helps with delegated work',
model: 'anthropic/claude-sonnet-4-5',
credential: 'credential-1',
instructions: 'Be useful.',
config: {
maxIterations: 5,
},
};
const customToolDescriptor: ToolDescriptor = {
name: 'lookup_customer',
description: 'Look up a customer',
systemInstruction: null,
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: null,
hasSuspend: false,
hasResume: false,
hasToMessage: false,
requireApproval: false,
providerOptions: null,
};
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: agentId,
projectId,
versionId,
schema: runnableConfig,
integrations: [],
tools: {},
skills: {},
...overrides,
} as unknown as Agent;
}
function makeAgentHistory(overrides: Partial<AgentHistory> = {}): AgentHistory {
return {
agentId,
versionId,
schema: runnableConfig,
tools: {},
skills: {},
...overrides,
} as unknown as AgentHistory;
}
describe('SubAgentSourceResolver', () => {
let agentRepository: jest.Mocked<AgentRepository>;
let agentHistoryRepository: jest.Mocked<AgentHistoryRepository>;
let resolver: SubAgentSourceResolver;
beforeEach(() => {
jest.clearAllMocks();
agentRepository = mock<AgentRepository>();
agentHistoryRepository = mock<AgentHistoryRepository>();
resolver = new SubAgentSourceResolver(agentRepository, agentHistoryRepository);
});
it('resolves a saved draft n8n agent in the same project', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
await expect(resolver.resolveForRuntime({ agentId }, { projectId })).resolves.toMatchObject({
source: {
sourceId: agentId,
versionId,
config: {
...runnableConfig,
integrations: [],
},
},
});
});
it('resolves a saved n8n agent version', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(makeAgentHistory());
await expect(
resolver.resolveForRuntime({ agentId, versionId }, { projectId }),
).resolves.toMatchObject({
source: {
sourceId: agentId,
versionId,
config: runnableConfig,
},
});
});
it('resolves runtime assets for saved n8n agent drafts', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({
tools: {
tool_1: {
code: 'return input;',
descriptor: customToolDescriptor,
},
},
skills: {
skill_1: {
name: 'Skill 1',
description: 'Helps with tests',
instructions: 'Skill body',
},
},
}),
);
await expect(resolver.resolveForRuntime({ agentId }, { projectId })).resolves.toMatchObject({
source: {
sourceId: agentId,
},
toolDescriptors: {
tool_1: customToolDescriptor,
},
toolCodeByName: {
lookup_customer: 'return input;',
},
skills: {
skill_1: {
name: 'Skill 1',
description: 'Helps with tests',
instructions: 'Skill body',
},
},
});
});
it('rejects missing or inaccessible n8n agents', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(null);
await expect(resolver.resolveForRuntime({ agentId }, { projectId })).rejects.toThrow(
`Agent "${agentId}" not found`,
);
});
it('rejects n8n agents with no config', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ schema: null }));
await expect(resolver.resolveForRuntime({ agentId }, { projectId })).rejects.toThrow(
`Agent "${agentId}" has no config`,
);
});
it('rejects a pinned version that does not exist', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(null);
await expect(resolver.resolveForRuntime({ agentId, versionId }, { projectId })).rejects.toThrow(
`Version "${versionId}" not found for agent "${agentId}"`,
);
});
it('rejects a resolved config that is not runnable', async () => {
const { credential: _credential, ...invalidConfig } = runnableConfig;
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ schema: invalidConfig }));
await expect(resolver.resolveForRuntime({ agentId }, { projectId })).rejects.toThrow(
'Invalid sub-agent config',
);
});
});

View File

@ -0,0 +1,86 @@
import {
createDelegateSubAgentTool,
generateResultToDelegateSubAgentOutput,
type DelegateSubAgentToolOutput,
} from '@n8n/agents';
import type { SubAgentRunPolicy, SubAgentSource } from '@n8n/api-types';
import type {
SubAgentForegroundRunContext,
SubAgentForegroundResult,
SubAgentForegroundRunner,
} from './sub-agent-foreground-runner';
export interface CreateN8nDelegateSubAgentToolOptions extends SubAgentForegroundRunContext {
runner: SubAgentForegroundRunner;
sourcesById: Record<string, SubAgentSource>;
availableSubAgents?: Array<{ id: string; name: string; description?: string }>;
policy?: SubAgentRunPolicy;
}
export function createN8nDelegateSubAgentTool(options: CreateN8nDelegateSubAgentToolOptions) {
const { runner, sourcesById, availableSubAgents, policy, ...runContext } = options;
return createDelegateSubAgentTool({
...(availableSubAgents !== undefined ? { availableSubAgents } : {}),
...(policy !== undefined ? { policy } : {}),
runSubAgent: async (request) => {
const selectedSource = selectSubAgentSource({
sourcesById,
subAgentId: request.subAgentId,
});
if (!selectedSource) {
return {
status: 'failed',
answer:
'No subagent matched this request. Provide subAgentId when multiple configured subagents are available.',
};
}
const result = await runner.runForeground(
{
goal: request.goal,
source: selectedSource,
executionMode: 'foreground',
...(request.context !== undefined ? { context: request.context } : {}),
...(request.expectedOutput !== undefined
? { expectedOutput: request.expectedOutput }
: {}),
...(policy !== undefined ? { policy } : {}),
...(request.parentThreadId !== undefined
? { parentThreadId: request.parentThreadId }
: {}),
...(request.parentResourceId !== undefined
? { parentResourceId: request.parentResourceId }
: {}),
taskPath: request.taskPath,
},
{
...runContext,
...(request.parentAbortSignal !== undefined
? { abortSignal: request.parentAbortSignal }
: {}),
},
);
return formatSubAgentToolOutput(result);
},
});
}
function selectSubAgentSource(options: {
sourcesById: Record<string, SubAgentSource>;
subAgentId?: string;
}): SubAgentSource | undefined {
const { sourcesById, subAgentId } = options;
if (subAgentId) return sourcesById?.[subAgentId];
const sources = Object.values(sourcesById);
return sources.length === 1 ? sources[0] : undefined;
}
export function formatSubAgentToolOutput(
result: SubAgentForegroundResult,
): DelegateSubAgentToolOutput {
return generateResultToDelegateSubAgentOutput(result.taskPath, result.result, result.threadId);
}

View File

@ -0,0 +1,287 @@
import {
assertSubAgentTaskPath,
renderDelegateSubAgentPrompt,
type AgentExecutionCounter,
type AgentMessage,
type CredentialProvider,
type GenerateResult,
type SubAgentTaskPath,
} from '@n8n/agents';
import { Logger } from '@n8n/backend-common';
import type { ResolvedSubAgentSource, SubAgentSpawnRequest } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { AgentExecutionService } from '../agent-execution.service';
import { ExecutionRecorder } from '../execution-recorder';
import type { MessageRecord } from '../execution-recorder';
import {
buildFromJson,
type MemoryFactory,
type ToolExecutor,
type ToolResolver,
} from '../json-config/from-json-config';
import { SubAgentSourceResolver } from './sub-agent-source-resolver';
export interface SubAgentForegroundRunContext {
projectId: string;
/** Saved n8n agent id of the delegating parent agent, used to link the child session back. */
parentAgentId?: string;
credentialProvider: CredentialProvider;
createToolExecutor(toolCodeByName: Record<string, string>): ToolExecutor;
createMemoryFactory(memoryOwnerAgentId: string): MemoryFactory;
resolveTool?: ToolResolver;
executionCounter?: AgentExecutionCounter;
/** Parent run's abort signal — cancelling the parent cancels this child. */
abortSignal?: AbortSignal;
}
export interface SubAgentForegroundResult {
taskPath: SubAgentTaskPath;
/** The child run's memory/session thread id, so callers can link or continue it. */
threadId: string;
status: 'completed' | 'failed';
result: GenerateResult;
}
@Service()
export class SubAgentForegroundRunner {
constructor(
private readonly sourceResolver: SubAgentSourceResolver,
private readonly agentExecutionService: AgentExecutionService,
private readonly logger: Logger,
) {}
async runForeground(
request: SubAgentSpawnRequest,
context: SubAgentForegroundRunContext,
): Promise<SubAgentForegroundResult> {
// Background execution (dispatch, return a receipt, reconcile the result
// later) is not yet implemented. Tracked in AGENT-186:
// https://linear.app/n8n/issue/AGENT-186
if (request.executionMode !== undefined && request.executionMode !== 'foreground') {
throw new UserError('Foreground sub-agent runner only supports foreground execution mode');
}
// The SDK delegate tool already assigned this delegation's task path and
// enforced fan-out policy before invoking the runner. Just validate the
// forwarded shape — don't recompute it or re-run the gates.
const taskPath = request.taskPath;
assertSubAgentTaskPath(taskPath);
const runtimeSource = await this.sourceResolver.resolveForRuntime(request.source, {
projectId: context.projectId,
});
const toolExecutor = context.createToolExecutor(runtimeSource.toolCodeByName);
// A delegated run is a fresh conversation, so it gets an ordinary thread id
// (a uuid) — exactly like any other agent run, with no special structure.
// The parent linkage is persisted as columns on the session record
// (origin / parentThreadId / parentAgentId), never encoded into the id.
// The same id is shared by the SDK memory thread and the session record so
// title sync, recall, and deletion all resolve to the same thread, and it is
// returned on the result so a caller can re-supply it to continue the thread.
const threadId = uuid();
// Inherit the parent's episodic-memory scope. When the parent has none,
// isolate this run to its own thread rather than widening to the project.
const resourceId = request.parentResourceId ?? threadId;
const { subAgents: _subAgents, ...childConfig } = runtimeSource.source.config;
const agent = await buildFromJson(childConfig, runtimeSource.toolDescriptors, {
toolExecutor,
credentialProvider: context.credentialProvider,
resolveTool: context.resolveTool,
skills: runtimeSource.skills,
memoryFactory: createSubAgentMemoryFactory(runtimeSource.source, context),
});
const timeoutController = request.policy?.timeoutMs ? new AbortController() : undefined;
const timeout = timeoutController
? setTimeout(() => timeoutController.abort(), request.policy?.timeoutMs)
: undefined;
// Abort the child when the parent run is cancelled or the timeout fires.
const abortSignal = combineAbortSignals(context.abortSignal, timeoutController?.signal);
const prompt = renderDelegateSubAgentPrompt(request);
try {
const resultStream = await agent.stream(prompt, {
...(abortSignal !== undefined ? { abortSignal } : {}),
persistence: {
resourceId,
threadId,
},
executionCounter: context.executionCounter,
});
const recorder = new ExecutionRecorder();
let structuredOutput: unknown;
const reader = resultStream.stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
recorder.record(value);
if (value.type === 'finish' && value.structuredOutput !== undefined) {
structuredOutput = value.structuredOutput;
}
}
} finally {
reader.releaseLock();
}
const messageRecord = recorder.getMessageRecord();
await this.recordSubAgentExecution({
runtimeSource: runtimeSource.source,
projectId: context.projectId,
threadId,
parentThreadId: request.parentThreadId,
parentAgentId: context.parentAgentId,
taskPath,
prompt,
record: messageRecord,
});
const result = buildGenerateResultFromRecord(
resultStream.runId,
messageRecord,
structuredOutput,
);
return {
taskPath,
threadId,
status:
result.finishReason === 'error' || result.error !== undefined ? 'failed' : 'completed',
result,
};
} finally {
if (timeout) clearTimeout(timeout);
// Each delegation builds its own child agent, so release it here:
// dispose the runtime's background tasks and disconnect any MCP
// transports instead of leaking them per delegated run.
await agent.close().catch((error) => {
this.logger.warn('Failed to close subagent after run', {
taskPath,
error: error instanceof Error ? error.message : String(error),
});
});
}
}
private async recordSubAgentExecution(params: {
runtimeSource: ResolvedSubAgentSource;
projectId: string;
/** Unified thread id, shared with the SDK memory thread. */
threadId: string;
parentThreadId?: string;
parentAgentId?: string;
taskPath: SubAgentTaskPath;
prompt: string;
record: MessageRecord;
}): Promise<void> {
const {
runtimeSource,
projectId,
threadId,
parentThreadId,
parentAgentId,
taskPath,
prompt,
record,
} = params;
try {
await this.agentExecutionService.recordMessage({
threadId,
agentId: runtimeSource.sourceId,
agentName: runtimeSource.config.name,
projectId,
userMessage: prompt,
record,
source: 'subagent',
threadMetadata: {
...(parentThreadId !== undefined ? { parentThreadId } : {}),
...(parentAgentId !== undefined ? { parentAgentId } : {}),
},
});
} catch (error) {
this.logger.warn('Failed to record subagent execution', {
agentId: runtimeSource.sourceId,
taskPath,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
function buildGenerateResultFromRecord(
runId: string,
record: MessageRecord,
structuredOutput: unknown,
): GenerateResult {
const messages = createAssistantMessages(record.assistantResponse);
const finishReason = toKnownFinishReason(record.finishReason);
const result: GenerateResult = {
runId,
messages,
...(record.model !== null ? { model: record.model } : {}),
...(finishReason !== undefined ? { finishReason } : {}),
...(record.usage !== null
? {
usage: {
...record.usage,
...(record.totalCost !== null ? { cost: record.totalCost } : {}),
},
}
: {}),
...(structuredOutput !== undefined ? { structuredOutput } : {}),
...(record.error !== null ? { error: record.error } : {}),
};
return result;
}
function createAssistantMessages(text: string): AgentMessage[] {
if (!text.trim()) return [];
return [
{
role: 'assistant',
content: [{ type: 'text', text }],
},
];
}
function toKnownFinishReason(
value: string,
): NonNullable<GenerateResult['finishReason']> | undefined {
if (
value === 'stop' ||
value === 'length' ||
value === 'content-filter' ||
value === 'tool-calls' ||
value === 'error' ||
value === 'other' ||
value === 'max-iterations'
) {
return value;
}
return undefined;
}
function createSubAgentMemoryFactory(
source: ResolvedSubAgentSource,
context: SubAgentForegroundRunContext,
): MemoryFactory {
return async (params) => {
return await context.createMemoryFactory(source.sourceId)(params);
};
}
/** Merge up to two abort signals: cancellation of either cancels the child run. */
function combineAbortSignals(
a: AbortSignal | undefined,
b: AbortSignal | undefined,
): AbortSignal | undefined {
const signals = [a, b].filter((signal): signal is AbortSignal => signal !== undefined);
if (signals.length <= 1) return signals[0];
return AbortSignal.any(signals);
}

View File

@ -0,0 +1,123 @@
import {
type AgentSkill,
RunnableAgentJsonConfigSchema,
type AgentJsonConfig,
type ResolvedSubAgentSource,
type SubAgentSource,
} from '@n8n/api-types';
import type { ToolDescriptor } from '@n8n/agents';
import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { AgentHistory } from '../entities/agent-history.entity';
import type { Agent } from '../entities/agent.entity';
import { composeJsonConfig } from '../json-config/agent-config-composition';
import { AgentHistoryRepository } from '../repositories/agent-history.repository';
import { AgentRepository } from '../repositories/agent.repository';
export interface ResolveSubAgentSourceContext {
projectId: string;
}
export interface ResolvedSubAgentRuntimeSource {
source: ResolvedSubAgentSource;
toolDescriptors: Record<string, ToolDescriptor>;
toolCodeByName: Record<string, string>;
skills: Record<string, AgentSkill>;
}
@Service()
export class SubAgentSourceResolver {
constructor(
private readonly agentRepository: AgentRepository,
private readonly agentHistoryRepository: AgentHistoryRepository,
) {}
/**
* Resolve a saved n8n agent (its current draft, or a pinned published
* version) into a runnable config plus its tool/skill assets.
*/
async resolveForRuntime(
source: SubAgentSource,
context: ResolveSubAgentSourceContext,
): Promise<ResolvedSubAgentRuntimeSource> {
const agent = await this.agentRepository.findByIdAndProjectId(
source.agentId,
context.projectId,
);
if (!agent) {
throw new NotFoundError(`Agent "${source.agentId}" not found`);
}
if (source.versionId) {
const version = await this.agentHistoryRepository.findByVersionAndAgentId(
source.versionId,
source.agentId,
);
if (!version) {
throw new NotFoundError(
`Version "${source.versionId}" not found for agent "${source.agentId}"`,
);
}
if (!version.schema) {
throw new UserError(
`Agent "${source.agentId}" version "${source.versionId}" has no config`,
);
}
return {
source: {
sourceId: source.agentId,
versionId: source.versionId,
config: this.toRunnableConfig(version.schema),
},
...getAgentRuntimeAssets(version),
};
}
const config = composeJsonConfig(agent);
if (!config) {
throw new UserError(`Agent "${source.agentId}" has no config`);
}
return {
source: {
sourceId: source.agentId,
versionId: agent.versionId ?? undefined,
config: this.toRunnableConfig(config),
},
...getAgentRuntimeAssets(agent),
};
}
private toRunnableConfig(config: AgentJsonConfig): ResolvedSubAgentSource['config'] {
const result = RunnableAgentJsonConfigSchema.safeParse(config);
if (!result.success) {
throw new UserError(
`Invalid sub-agent config: ${result.error.issues[0]?.message ?? 'Invalid config'}`,
);
}
return result.data;
}
}
function getAgentRuntimeAssets(
agent: Pick<Agent | AgentHistory, 'tools' | 'skills'>,
): Omit<ResolvedSubAgentRuntimeSource, 'source'> {
const toolDescriptors: Record<string, ToolDescriptor> = {};
const toolCodeByName: Record<string, string> = {};
for (const [toolId, toolEntry] of Object.entries(agent.tools ?? {})) {
toolDescriptors[toolId] = toolEntry.descriptor;
toolCodeByName[toolEntry.descriptor.name] = toolEntry.code;
}
return {
toolDescriptors,
toolCodeByName,
skills: agent.skills ?? {},
};
}

View File

@ -66,6 +66,8 @@ function timelineToolCallToPart(event: ToolCallTimelineEvent): AgentPersistedMes
toolName: event.name,
toolCallId: event.toolCallId,
input: event.input,
...(event.startTime > 0 ? { startTime: event.startTime } : {}),
...(event.endTime > 0 ? { endTime: event.endTime } : {}),
};
if (state === undefined) return base;

View File

@ -1347,6 +1347,10 @@
"agentSessions.lastMessage": "Last Message",
"agentSessions.tokenUsage": "Token Usage",
"agentSessions.testChat": "Test chat",
"agentSessions.origin": "Origin",
"agentSessions.origin.agent": "Agent",
"agentSessions.origin.subAgent": "Sub-agent",
"agentSessions.goToParentRun": "Go to parent run",
"agentSessions.actions": "Actions",
"agentSessions.empty": "No agent sessions",
"agentSessions.loadMore": "Load more",
@ -1358,8 +1362,7 @@
"agentSessions.showError.load": "Problem loading sessions",
"agentSessions.status": "Status",
"agentSessions.duration": "Duration",
"agentSessions.origin": "Origin",
"agentSessions.origin.agent": "Agent",
"agentSessions.sessionId": "Session ID",
"agentSessions.origin.task": "Task",
"agentSessions.success": "Success",
"agentSessions.detail.backToSessions": "Back to sessions",
@ -1409,6 +1412,7 @@
"agentSessions.timeline.suspended": "Suspended",
"agentSessions.timeline.idle": "Idle",
"agentSessions.timeline.agent": "Agent",
"agentSessions.timeline.subAgent": "Sub-agent",
"agentSessions.timeline.tool.richInteraction": "Feedback requested from user",
"agentSessions.timeline.tool.richInteractionDisplay": "Card sent to user",
"agentSessions.timeline.waitingForUser": "Waiting for user response",
@ -6107,6 +6111,9 @@
"agents.chat.askCredential.skip": "Skip",
"agents.chat.toolNames.webSearch": "Web search",
"agents.chat.toolNames.searchKnowledge": "Search knowledge",
"agents.chat.delegate.label": "Sub-agent · {name}",
"agents.chat.delegate.labelFallback": "Sub-agent",
"agents.chat.toolStep.waitingForInput": "Waiting for your input",
"agents.chat.askQuestion.otherLabel": "Other",
"agents.chat.askQuestion.otherPlaceholder": "Type another answer",
"agents.chat.askQuestion.answerLabel": "Your answer",
@ -6281,6 +6288,18 @@
"agents.builder.files.size.bytes": "{bytes} B",
"agents.builder.files.size.kilobytes": "{kilobytes} KB",
"agents.builder.files.size.megabytes": "{megabytes} MB",
"agents.builder.subAgents.title": "Sub-agents",
"agents.builder.subAgents.description": "Let this agent delegate focused subtasks to selected agents in this project. Add at least one published agent to enable delegation.",
"agents.builder.subAgents.add": "Add agent",
"agents.builder.subAgents.loadError": "Could not load project agents",
"agents.builder.subAgents.remove": "Remove {name}",
"agents.builder.subAgents.modal.title": "Add sub-agents",
"agents.builder.subAgents.modal.description": "Select published agents from this project to let this agent delegate work to them.",
"agents.builder.subAgents.modal.selectAgent": "Select {name}",
"agents.builder.subAgents.modal.empty.title": "No agents to add",
"agents.builder.subAgents.modal.empty.description": "Published agents from this project show here. Already added agents are hidden.",
"agents.builder.subAgents.modal.cancel": "Cancel",
"agents.builder.subAgents.modal.add": "Add agents",
"agents.builder.memory.recallModel.label": "Memory model",
"agents.builder.memory.recallModel.hint": "Choose the model that creates, reviews, and retrieves memories. Uses the agent model by default.",
"agents.builder.episodicMemoryCredentialModal.title": "Episodic Memory",

View File

@ -2,12 +2,20 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
i18n: { baseText: (key: string) => key },
}));
vi.mock('../composables/useProjectAgentsList', () => ({
useProjectAgentsList: () => ({
list: { value: [] },
ensureLoaded: vi.fn().mockResolvedValue([]),
}),
}));
// First mount of these SFCs eats the Vite transform cost; give them headroom.
vi.setConfig({ testTimeout: 30_000 });
@ -36,11 +44,20 @@ describe('AgentBuilderEditorColumn — childrenDisabled composes streaming and c
executionsDescription: '',
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
stubs: {
N8nCard: { template: '<div><slot /></div>' },
N8nHeading: { template: '<div><slot /></div>' },
N8nButton: { template: '<button><slot /><slot name="icon" /></button>' },
N8nIcon: { template: '<span />', props: ['icon', 'size'] },
N8nIconButton: { template: '<button />' },
N8nOption: { template: '<div />', props: ['value', 'label', 'disabled'] },
N8nRadioButtons: { template: '<div />' },
N8nScrollArea: { template: '<div><slot /></div>' },
N8nSelect: { template: '<div><slot /></div>', props: ['modelValue', 'disabled'] },
N8nSwitch2: { template: '<button />', props: ['modelValue', 'disabled'] },
N8nText: { template: '<span><slot /></span>' },
N8nTooltip: { template: '<div><slot /></div>' },
AgentIdentityHeader: {
name: 'AgentIdentityHeader',
template: '<div data-testid="stub-identity" />',
@ -127,6 +144,7 @@ describe('AgentBuilderEditorColumn — childrenDisabled composes streaming and c
executionsDescription: '',
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
stubs: {
N8nCard: { template: '<div><slot /></div>' },
N8nHeading: { template: '<div><slot /></div>' },

View File

@ -1,7 +1,16 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only Vue mounting */
import { createTestingPinia } from '@pinia/testing';
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { flushPromises, mount } from '@vue/test-utils';
import { ref } from 'vue';
import { AGENT_SUB_AGENTS_MODAL_KEY } from '../constants';
import type { AgentResource } from '../types';
const ensureLoadedMock = vi.fn();
const projectAgentsListRef = ref<AgentResource[] | null>([]);
const openModalWithDataMock = vi.fn();
const showErrorMock = vi.fn();
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
@ -12,10 +21,30 @@ vi.mock('@n8n/i18n', () => ({
'Stores source-backed memories from previous conversations. Requires OpenAI credential.',
'agents.builder.memory.episodicMemory.changeCredential': 'Change credential',
'agents.builder.editorColumn.ariaLabel': 'Agent editor',
'agents.builder.subAgents.title': 'Sub-agents',
'agents.builder.subAgents.description': 'Sub-agents description',
'agents.builder.subAgents.add': 'Add agent',
'agents.builder.subAgents.loadError': 'Could not load project agents',
'agents.builder.subAgents.remove': 'Remove {name}',
})[key] ?? key,
}),
}));
vi.mock('../composables/useProjectAgentsList', () => ({
useProjectAgentsList: () => ({
list: projectAgentsListRef,
ensureLoaded: ensureLoadedMock,
}),
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({ showError: showErrorMock }),
}));
vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({ openModalWithData: openModalWithDataMock }),
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return { ...actual, useRoute: () => ({ params: {} }) };
@ -23,18 +52,31 @@ vi.mock('vue-router', async (importOriginal) => {
vi.mock('@n8n/design-system', () => ({
N8nActionBox: { template: '<div />', props: ['icon', 'description'] },
N8nButton: { template: '<button><slot /><slot name="icon" /></button>' },
N8nCard: { template: '<div><slot /></div>', props: ['variant'] },
N8nHeading: { template: '<h2><slot /></h2>', props: ['size'] },
N8nIcon: { template: '<span />', props: ['icon', 'size'] },
N8nIconButton: { template: '<button><slot /></button>' },
N8nIconButton: {
template: '<button><slot /></button>',
props: ['disabled', 'ariaLabel'],
},
N8nLoading: { template: '<div />', props: ['rows', 'variant'] },
N8nRadioButtons: { template: '<div />', props: ['modelValue', 'options'] },
N8nScrollArea: { template: '<div><slot /></div>', props: ['maxHeight', 'type'] },
N8nSwitch: { template: '<button data-test-id="agent-memory-toggle"></button>' },
N8nSwitch2: { template: '<button />', props: ['modelValue', 'disabled'] },
N8nText: { template: '<span><slot /></span>', props: ['tag', 'bold', 'size', 'color'] },
N8nTooltip: { template: '<div><slot /><slot name="content" /></div>' },
}));
vi.mock('@n8n/design-system/components/N8nSelect', () => ({
default: { template: '<div><slot /></div>', props: ['modelValue', 'disabled', 'size'] },
}));
vi.mock('@n8n/design-system/components/N8nOption', () => ({
default: { template: '<div />', props: ['value', 'label', 'disabled'] },
}));
vi.mock('../components/AgentAdvancedPanel.vue', () => ({
default: { name: 'AgentAdvancedPanel', template: '<div />' },
}));
@ -66,6 +108,13 @@ vi.mock('../views/AgentSessionsListView.vue', () => ({
// First mount of this SFC eats the Vite transform cost; give it headroom.
vi.setConfig({ testTimeout: 30_000 });
const publishedSubAgent: AgentResource = {
id: 'agent-2',
name: 'Helper Agent',
description: 'Helps with tasks',
activeVersionId: 'version-2',
} as AgentResource;
async function mountColumn() {
const { default: AgentBuilderEditorColumn } = await import(
'../components/AgentBuilderEditorColumn.vue'
@ -108,6 +157,12 @@ async function mountColumn() {
}
describe('AgentBuilderEditorColumn', () => {
beforeEach(() => {
vi.clearAllMocks();
projectAgentsListRef.value = [];
ensureLoadedMock.mockResolvedValue([]);
});
it('renders only the episodic memory row in the builder memory card', async () => {
const wrapper = await mountColumn();
@ -118,4 +173,52 @@ describe('AgentBuilderEditorColumn', () => {
expect(wrapper.find('[data-testid="agent-episodic-memory-toggle"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-observational-memory-toggle"]').exists()).toBe(false);
});
it('preloads project agents on mount without surfacing rejection', async () => {
const loadError = new Error('boom');
ensureLoadedMock.mockRejectedValueOnce(loadError);
await mountColumn();
await flushPromises();
expect(ensureLoadedMock).toHaveBeenCalledTimes(1);
expect(showErrorMock).not.toHaveBeenCalled();
});
it('opens the sub-agents modal after project agents load successfully', async () => {
projectAgentsListRef.value = [publishedSubAgent];
const wrapper = await mountColumn();
await flushPromises();
const callsAfterMount = ensureLoadedMock.mock.calls.length;
await wrapper.find('[data-testid="agent-sub-agents-open-add-modal"]').trigger('click');
await flushPromises();
expect(ensureLoadedMock.mock.calls.length).toBe(callsAfterMount + 1);
expect(openModalWithDataMock).toHaveBeenCalledWith(
expect.objectContaining({
name: AGENT_SUB_AGENTS_MODAL_KEY,
data: expect.objectContaining({
agents: [{ id: 'agent-2', name: 'Helper Agent', description: 'Helps with tasks' }],
}),
}),
);
expect(showErrorMock).not.toHaveBeenCalled();
});
it('shows an error toast and does not open the modal when project agents fail to load', async () => {
const wrapper = await mountColumn();
await flushPromises();
const callsAfterMount = ensureLoadedMock.mock.calls.length;
const loadError = new Error('network');
ensureLoadedMock.mockRejectedValueOnce(loadError);
await wrapper.find('[data-testid="agent-sub-agents-open-add-modal"]').trigger('click');
await flushPromises();
expect(ensureLoadedMock.mock.calls.length).toBe(callsAfterMount + 1);
expect(showErrorMock).toHaveBeenCalledWith(loadError, 'Could not load project agents');
expect(openModalWithDataMock).not.toHaveBeenCalled();
});
});

View File

@ -25,7 +25,7 @@ vi.mock('@/features/ai/chatHub/components/ChatTypingIndicator.vue', () => ({
}));
vi.mock('@/features/agents/components/AgentChatToolSteps.vue', () => ({
default: { template: '<div />', props: ['toolCalls'] },
default: { template: '<div />', props: ['toolCalls', 'projectId'] },
}));
vi.mock('@/features/agents/components/interactive/InteractiveCard.vue', () => ({

View File

@ -194,6 +194,53 @@ describe('convertDbMessages — interactive turn synthesis', () => {
expect(tc?.state).toBe('done');
expect(tc?.output).toEqual([{ name: 'Slack' }]);
});
it('renders a resolved-but-failed delegate_subagent call as an error', () => {
const dbMessages: AgentPersistedMessageDto[] = [
{
id: 'm1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'delegate_subagent',
toolCallId: 'tc-d',
input: { taskName: 'research' },
state: 'resolved',
output: { status: 'failed', answer: '', error: 'child failed' },
},
],
},
];
const chat = convertDbMessages(dbMessages);
const tc = chat[0].toolCalls?.[0];
expect(tc?.state).toBe('error');
expect(tc?.output).toEqual({ status: 'failed', answer: '', error: 'child failed' });
});
it('renders a resolved-and-completed delegate_subagent call as done', () => {
const dbMessages: AgentPersistedMessageDto[] = [
{
id: 'm1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'delegate_subagent',
toolCallId: 'tc-d2',
input: {},
state: 'resolved',
output: { status: 'completed', answer: 'all good' },
},
],
},
];
const chat = convertDbMessages(dbMessages);
const tc = chat[0].toolCalls?.[0];
expect(tc?.state).toBe('done');
});
});
describe('isGroupable', () => {

View File

@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest';
import {
DELEGATE_SUB_AGENT_TOOL_NAME,
delegateLabel,
humanizeTaskName,
isDelegateSubAgentTool,
isFailedDelegateOutput,
parseDelegateInput,
parseDelegateOutput,
resolveSubAgentName,
} from '../utils/delegate-tool';
describe('delegate-tool', () => {
describe('isDelegateSubAgentTool', () => {
it('matches the delegate tool name only', () => {
expect(isDelegateSubAgentTool(DELEGATE_SUB_AGENT_TOOL_NAME)).toBe(true);
expect(isDelegateSubAgentTool('web_search')).toBe(false);
expect(isDelegateSubAgentTool(undefined)).toBe(false);
});
});
describe('parseDelegateInput', () => {
it('keeps the fields the chat reads and strips the rest', () => {
expect(
parseDelegateInput({
subAgentId: 'agent-1',
taskName: 'research_api',
goal: 'Find pricing',
context: 'For three providers',
expectedOutput: 'A table',
}),
).toEqual({ subAgentId: 'agent-1', taskName: 'research_api' });
});
it('returns undefined for non-object input', () => {
expect(parseDelegateInput('nope')).toBeUndefined();
expect(parseDelegateInput(undefined)).toBeUndefined();
expect(parseDelegateInput(null)).toBeUndefined();
});
});
describe('parseDelegateOutput', () => {
it('keeps status/answer/error and strips the rest', () => {
expect(
parseDelegateOutput({
status: 'completed',
answer: 'Done',
usage: { totalTokens: 1234 },
}),
).toEqual({ status: 'completed', answer: 'Done' });
});
it('keeps the error on a failed delegation', () => {
expect(parseDelegateOutput({ status: 'failed', answer: '', error: 'child failed' })).toEqual({
status: 'failed',
answer: '',
error: 'child failed',
});
});
it('returns undefined for a non-object (rejected tool call raw error string)', () => {
expect(parseDelegateOutput('Something failed')).toBeUndefined();
});
});
describe('isFailedDelegateOutput', () => {
it('is true only for a delegate tool whose output status is failed', () => {
expect(
isFailedDelegateOutput(DELEGATE_SUB_AGENT_TOOL_NAME, { status: 'failed', answer: '' }),
).toBe(true);
});
it('is false for a completed delegate output', () => {
expect(
isFailedDelegateOutput(DELEGATE_SUB_AGENT_TOOL_NAME, { status: 'completed', answer: 'ok' }),
).toBe(false);
});
it('is false for non-delegate tools even when the output looks failed', () => {
expect(isFailedDelegateOutput('web_search', { status: 'failed' })).toBe(false);
});
it('is false when the output is a raw error string', () => {
expect(isFailedDelegateOutput(DELEGATE_SUB_AGENT_TOOL_NAME, 'boom')).toBe(false);
});
});
describe('humanizeTaskName', () => {
it('humanizes snake/kebab names', () => {
expect(humanizeTaskName('research_api')).toBe('Research api');
expect(humanizeTaskName('compare-pricing')).toBe('Compare pricing');
});
it('returns empty string for empty/undefined', () => {
expect(humanizeTaskName(undefined)).toBe('');
expect(humanizeTaskName(' ')).toBe('');
});
});
describe('resolveSubAgentName', () => {
it('prefers the configured sub-agent name from the id map', () => {
const map = new Map([['agent-1', 'Research Bot']]);
expect(resolveSubAgentName({ subAgentId: 'agent-1', taskName: 'research_api' }, map)).toBe(
'Research Bot',
);
});
it('falls back to the humanized task name when the id is unknown', () => {
expect(
resolveSubAgentName({ subAgentId: 'missing', taskName: 'research_api' }, new Map()),
).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('ignores a blank resolved name and uses the task name', () => {
const map = new Map([['agent-1', ' ']]);
expect(resolveSubAgentName({ subAgentId: 'agent-1', taskName: 'deep_research' }, map)).toBe(
'Deep research',
);
});
it('returns empty string when neither id nor task name resolve', () => {
expect(resolveSubAgentName({}, new Map())).toBe('');
expect(resolveSubAgentName('not-an-object', new Map())).toBe('');
});
});
describe('delegateLabel', () => {
// Stub returns the chosen key (with the interpolated name) so the assertions
// pin down both which key is used and the interpolation.
const i18n = {
baseText: (key: string, opts?: { interpolate?: { name?: string } }) =>
opts?.interpolate?.name ? `${key}:${opts.interpolate.name}` : key,
} as unknown as Parameters<typeof delegateLabel>[0];
it('uses the named label and interpolates the name', () => {
expect(delegateLabel(i18n, 'Research Bot')).toBe('agents.chat.delegate.label:Research Bot');
});
it('falls back to the bare label when the name is empty', () => {
expect(delegateLabel(i18n, '')).toBe('agents.chat.delegate.labelFallback');
});
});
});

View File

@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
computeIdleRanges,
builtinToolLabelKey,
itemFilterKey,
sessionBounds,
kindColorToken,
@ -129,6 +130,20 @@ describe('formatDuration', () => {
});
});
describe('builtinToolLabelKey', () => {
it('uses the chat display key for native and fallback web search tools', () => {
expect(builtinToolLabelKey('web_search')).toBe('agents.chat.toolNames.webSearch');
expect(builtinToolLabelKey('anthropic.web_search_20250305')).toBe(
'agents.chat.toolNames.webSearch',
);
expect(builtinToolLabelKey('openai.web_search')).toBe('agents.chat.toolNames.webSearch');
});
it('does not label unrelated tools as web search', () => {
expect(builtinToolLabelKey('custom_web_search')).toBeNull();
});
});
import { flattenExecutionsToTimelineItems } from '../session-timeline.utils';
import type {
AgentExecution,

View File

@ -230,7 +230,12 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
input: { purpose: 'main' },
},
{ type: 'finish-step' },
{ type: 'tool-execution-start', toolCallId: 'tc-1', toolName: ASK_LLM_TOOL_NAME },
{
type: 'tool-execution-start',
toolCallId: 'tc-1',
toolName: ASK_LLM_TOOL_NAME,
startTime: 1_000,
},
{
type: 'tool-call-suspended',
payload: {
@ -377,6 +382,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
type: 'tool-execution-start',
toolCallId: 'tc-9',
toolName: 'compute',
startTime: 1_000,
},
{
type: 'tool-result',
@ -396,4 +402,119 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
expect(assistant.toolCalls?.[0].state).toBe('done');
expect(assistant.toolCalls?.[0].output).toBe(42);
});
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: 'finish-step' },
{
type: 'tool-execution-start',
toolCallId: 'tc-11',
toolName: 'delegate_subagent',
startTime: 1_000,
},
{
type: 'tool-execution-end',
toolCallId: 'tc-11',
toolName: 'delegate_subagent',
isError: false,
endTime: 1_500,
},
{ type: 'done' },
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook();
await hook.sendMessage('do thing');
await nextTick();
const assistant = hook.messages.value[1];
expect(assistant.toolCalls?.[0].state).toBe('done');
});
it('renders a failed delegate_subagent result as an error step even though the call resolves', async () => {
const events: AgentSseEvent[] = [
{ type: 'start-step' },
{
type: 'tool-call',
toolCallId: 'tc-d1',
toolName: 'delegate_subagent',
input: { taskName: 'research' },
},
{ type: 'finish-step' },
{
type: 'tool-result',
toolCallId: 'tc-d1',
toolName: 'delegate_subagent',
output: { status: 'failed', answer: '', error: 'child failed' },
isError: false,
},
{ type: 'done' },
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook();
await hook.sendMessage('go');
await nextTick();
expect(hook.messages.value[1].toolCalls?.[0].state).toBe('error');
});
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: 'finish-step' },
{
type: 'tool-result',
toolCallId: 'tc-d2',
toolName: 'delegate_subagent',
output: { status: 'completed', answer: 'all good' },
isError: false,
},
{ type: 'done' },
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook();
await hook.sendMessage('go');
await nextTick();
expect(hook.messages.value[1].toolCalls?.[0].state).toBe('done');
});
it('stores the server-stamped startTime/endTime verbatim (no client clock)', async () => {
// The FE must not compute timing itself — it stores the backend-measured
// timestamps off the lifecycle events so the live duration equals the
// persisted/reloaded one exactly.
const events: AgentSseEvent[] = [
{ type: 'start-step' },
{ type: 'tool-call', toolCallId: 'tc-12', toolName: 'delegate_subagent', input: {} },
{ type: 'finish-step' },
{
type: 'tool-execution-start',
toolCallId: 'tc-12',
toolName: 'delegate_subagent',
startTime: 1_000,
},
{
type: 'tool-execution-end',
toolCallId: 'tc-12',
toolName: 'delegate_subagent',
isError: false,
endTime: 1_014,
},
{ type: 'done' },
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook();
await hook.sendMessage('do thing');
await nextTick();
const tc = hook.messages.value[1].toolCalls?.[0];
expect(tc?.startTime).toBe(1_000);
expect(tc?.endTime).toBe(1_014);
});
});

View File

@ -14,6 +14,8 @@ vi.mock('@n8n/stores/useRootStore', () => ({
import {
useProjectAgentsList,
removeProjectAgentFromListCache,
upsertProjectAgentsListCache,
__clearProjectAgentsListCacheForTests,
} from '../composables/useProjectAgentsList';
@ -75,4 +77,39 @@ describe('useProjectAgentsList', () => {
const next = await ensureLoaded();
expect(next).toEqual([{ id: 'a1', name: 'Agent One' }]);
});
it('reactively upserts cached agents after publish or create', async () => {
listAgents.mockResolvedValueOnce([{ id: 'a1', name: 'Agent One', activeVersionId: null }]);
const { list, ensureLoaded } = useProjectAgentsList(ref('p1'));
await ensureLoaded();
upsertProjectAgentsListCache('p1', {
id: 'a1',
name: 'Agent One',
activeVersionId: 'version-1',
} as never);
upsertProjectAgentsListCache('p1', {
id: 'a2',
name: 'Agent Two',
activeVersionId: null,
} as never);
expect(list.value).toEqual([
{ id: 'a2', name: 'Agent Two', activeVersionId: null },
{ id: 'a1', name: 'Agent One', activeVersionId: 'version-1' },
]);
});
it('reactively removes cached agents after delete', async () => {
listAgents.mockResolvedValueOnce([
{ id: 'a1', name: 'Agent One' },
{ id: 'a2', name: 'Agent Two' },
]);
const { list, ensureLoaded } = useProjectAgentsList(ref('p1'));
await ensureLoaded();
removeProjectAgentFromListCache('p1', 'a1');
expect(list.value).toEqual([{ id: 'a2', name: 'Agent Two' }]);
});
});

View File

@ -1,12 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nCard, N8nRadioButtons } from '@n8n/design-system';
import { computed, onMounted } from 'vue';
import {
N8nCard,
N8nIcon,
N8nIconButton,
N8nRadioButtons,
N8nScrollArea,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { AgentFileDto } from '@n8n/api-types';
import { useToast } from '@/app/composables/useToast';
import { useUIStore } from '@/app/stores/ui.store';
import type { AgentBuilderMainTab } from '../composables/useAgentBuilderMainTabs';
import { useProjectAgentsList } from '../composables/useProjectAgentsList';
import type { AgentJsonConfig, AgentResource, AgentSkill } from '../types';
import type { ToolOpenTarget } from './AgentCapabilitiesSection.types';
import { AGENT_SUB_AGENTS_MODAL_KEY } from '../constants';
import AgentSessionsListView from '../views/AgentSessionsListView.vue';
import AgentAdvancedPanel from './AgentAdvancedPanel.vue';
import AgentCapabilitiesSection from './AgentCapabilitiesSection.vue';
@ -59,6 +71,82 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const toast = useToast();
const uiStore = useUIStore();
const { list: projectAgents, ensureLoaded: ensureProjectAgentsLoaded } = useProjectAgentsList(
computed(() => props.projectId),
);
onMounted(() => {
void ensureProjectAgentsLoaded().catch(() => {});
});
const selectedSubAgentRefs = computed(() => props.localConfig?.subAgents?.agents ?? []);
const selectedSubAgentIds = computed(() =>
selectedSubAgentRefs.value.map(({ agentId }) => agentId),
);
const selectedSubAgentIdSet = computed(() => new Set(selectedSubAgentIds.value));
const availableSubAgents = computed(() =>
(projectAgents.value ?? []).filter(
(agent) =>
agent.id !== props.agentId &&
Boolean(agent.activeVersionId) &&
!selectedSubAgentIdSet.value.has(agent.id),
),
);
const selectedSubAgents = computed(() =>
selectedSubAgentIds.value.map((agentId) => {
const agent = projectAgents.value?.find((candidate) => candidate.id === agentId);
return {
id: agentId,
name: agent?.name ?? agentId,
description: agent?.description ?? null,
};
}),
);
async function onOpenAddSubAgentsModal() {
if (childrenDisabled.value) return;
try {
await ensureProjectAgentsLoaded();
} catch (error) {
toast.showError(error, i18n.baseText('agents.builder.subAgents.loadError'));
return;
}
uiStore.openModalWithData({
name: AGENT_SUB_AGENTS_MODAL_KEY,
data: {
agents: availableSubAgents.value.map(({ id, name, description }) => ({
id,
name,
description,
})),
onConfirm: (agentIds: string[]) => {
const newAgentRefs = agentIds
.filter((agentId) => !selectedSubAgentIdSet.value.has(agentId))
.map((agentId) => ({ agentId }));
if (newAgentRefs.length === 0) return;
emit('update:config', {
subAgents: {
agents: [...selectedSubAgentRefs.value, ...newAgentRefs],
},
});
},
},
});
}
function onRemoveSubAgent(agentId: string) {
emit('update:config', {
subAgents: {
agents: selectedSubAgentRefs.value.filter((subAgent) => subAgent.agentId !== agentId),
},
});
}
</script>
<template>
@ -135,13 +223,94 @@ const i18n = useI18n();
</N8nCard>
<N8nCard variant="outlined" :class="$style.card">
<AgentMemoryPanel
:config="localConfig"
:disabled="childrenDisabled"
embedded
data-testid="agent-memory-panel"
@update:config="emit('update:config', $event)"
/>
<div :class="[$style.subAgentsPanel, childrenDisabled && $style.disabled]">
<div :class="$style.subAgentsHeader">
<div :class="$style.subAgentsText">
<N8nText tag="h3" :bold="true">
{{ i18n.baseText('agents.builder.subAgents.title') }}
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.subAgents.description') }}
</N8nText>
</div>
<div :class="$style.subAgentsHeaderActions">
<N8nTooltip
:content="i18n.baseText('agents.builder.subAgents.add')"
placement="top"
>
<N8nIconButton
icon="plus"
variant="ghost"
size="small"
icon-size="medium"
:disabled="childrenDisabled"
:aria-label="i18n.baseText('agents.builder.subAgents.add')"
data-testid="agent-sub-agents-open-add-modal"
@click="onOpenAddSubAgentsModal"
/>
</N8nTooltip>
</div>
</div>
<div v-if="selectedSubAgents.length > 0" :class="$style.subAgentsContent">
<N8nScrollArea
max-height="calc((var(--spacing--2xl) + var(--spacing--sm)) * 5)"
type="auto"
:class="$style.rows"
>
<div :class="$style.rowList">
<N8nCard
v-for="subAgent in selectedSubAgents"
:key="subAgent.id"
:class="$style.row"
data-testid="agent-sub-agent-row"
>
<template #prepend>
<N8nIcon icon="bot" size="medium" :class="$style.itemIcon" />
</template>
<N8nText size="xsmall" color="text-dark" :bold="true" :class="$style.name">
{{ subAgent.name }}
</N8nText>
<N8nText
v-if="subAgent.description"
size="xsmall"
color="text-light"
:class="$style.metadata"
>
{{ subAgent.description }}
</N8nText>
<template #append>
<N8nTooltip
:content="
i18n.baseText('agents.builder.subAgents.remove', {
interpolate: { name: subAgent.name },
})
"
placement="top"
>
<N8nIconButton
icon="trash-2"
variant="ghost"
size="mini"
icon-size="small"
:disabled="childrenDisabled"
:aria-label="
i18n.baseText('agents.builder.subAgents.remove', {
interpolate: { name: subAgent.name },
})
"
data-testid="agent-sub-agent-remove"
@click="onRemoveSubAgent(subAgent.id)"
/>
</N8nTooltip>
</template>
</N8nCard>
</div>
</N8nScrollArea>
</div>
</div>
</N8nCard>
<N8nCard v-if="knowledgeBaseEnabled" variant="outlined" :class="$style.card">
@ -157,6 +326,16 @@ const i18n = useI18n();
/>
</N8nCard>
<N8nCard variant="outlined" :class="$style.card">
<AgentMemoryPanel
:config="localConfig"
:disabled="childrenDisabled"
embedded
data-testid="agent-memory-panel"
@update:config="emit('update:config', $event)"
/>
</N8nCard>
<N8nCard variant="outlined" :class="$style.card">
<AgentAdvancedPanel
:config="localConfig"
@ -265,4 +444,74 @@ const i18n = useI18n();
flex-direction: column;
width: 100%;
}
.subAgentsPanel {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
width: 100%;
}
.subAgentsPanel.disabled > :not(.subAgentsHeader) {
pointer-events: none;
opacity: 0.6;
}
.subAgentsHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
width: 100%;
}
.subAgentsText {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.subAgentsHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
flex-shrink: 0;
}
.subAgentsContent {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
width: 100%;
}
.rowList {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding-right: var(--spacing--xs);
}
.rows {
scrollbar-gutter: stable;
}
.row {
--card--append--width: auto;
flex-shrink: 0;
}
.itemIcon {
flex-shrink: 0;
color: var(--text-color--subtle);
}
.name,
.metadata {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
</style>

View File

@ -10,6 +10,7 @@ import { deleteAgent } from '../composables/useAgentApi';
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useAgentPublish } from '../composables/useAgentPublish';
import { removeProjectAgentFromListCache } from '../composables/useProjectAgentsList';
import type { AgentResource } from '../types';
const props = defineProps<{
@ -85,6 +86,7 @@ async function onAction(action: string) {
});
if (confirmed !== MODAL_CONFIRM) return;
await deleteAgent(rootStore.restApiContext, props.projectId, props.agent.id);
removeProjectAgentFromListCache(props.projectId, props.agent.id);
emit('deleted', props.agent.id);
}
}

View File

@ -323,7 +323,11 @@ onBeforeUnmount(() => {
</summary>
<div :class="$style.thinkingContent">{{ group.thinking }}</div>
</details>
<AgentChatToolSteps v-if="group.toolCalls.length" :tool-calls="group.toolCalls" />
<AgentChatToolSteps
v-if="group.toolCalls.length"
:tool-calls="group.toolCalls"
:project-id="projectId"
/>
<div v-if="group.interactives.some((p) => !p.resolvedAt)" :class="$style.interactives">
<InteractiveCard
v-for="payload in group.interactives.filter((p) => !p.resolvedAt)"
@ -389,6 +393,7 @@ onBeforeUnmount(() => {
<AgentChatToolSteps
v-if="group.message.toolCalls?.length"
:tool-calls="group.message.toolCalls"
:project-id="projectId"
/>
<div

View File

@ -1,56 +1,147 @@
<script setup lang="ts">
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
import { N8nIcon, N8nMarkdownEditor, N8nTooltip } from '@n8n/design-system';
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,
parseDelegateOutput,
resolveSubAgentName,
} from '../utils/delegate-tool';
defineProps<{
const props = defineProps<{
toolCalls: ToolCall[];
projectId?: string;
}>();
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.
const projectIdRef = toRef(() => props.projectId ?? '');
const { subAgentNameById } = useSubAgentNames(projectIdRef, () =>
props.toolCalls.some((tc) => isDelegateSubAgentTool(tc.tool)),
);
// Track which delegate steps are expanded (by tool-call id).
const expandedIds = reactive(new Set<string>());
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 delegateAnswer(tc: ToolCall): string {
if (!isDelegateSubAgentTool(tc.tool)) return '';
return parseDelegateOutput(tc.output)?.answer?.trim() ?? '';
}
// A delegate step is expandable once it has an answer to reveal.
function isExpandable(tc: ToolCall): boolean {
return delegateAnswer(tc).length > 0;
}
function isExpanded(tc: ToolCall): boolean {
return expandedIds.has(tc.toolCallId);
}
function toggle(tc: ToolCall): void {
if (!isExpandable(tc)) 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">
<div :class="$style.toolStepIndicator">
<N8nIcon
v-if="tc.state === 'done'"
icon="circle-check"
size="large"
:class="$style.toolStepDone"
/>
<N8nIcon
v-else-if="tc.state === 'error'"
icon="circle-x"
size="large"
:class="$style.toolStepError"
/>
<N8nTooltip
v-else-if="tc.state === 'suspended'"
placement="top"
content="Waiting for your input"
>
<N8nIcon icon="clock" size="large" :class="$style.toolStepSuspended" />
</N8nTooltip>
<N8nIcon v-else icon="spinner" size="large" :spin="true" :class="$style.toolStepLoading" />
<!-- 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"
/>
<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.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>
</div>
<span :class="[$style.toolStepLabel, { [$style.shimmer]: tc.state === 'running' }]">
{{ getToolDisplayName(tc.tool) }}
</span>
<span
v-if="tc.displaySummary"
:class="$style.toolStepSummary"
data-testid="tool-step-summary"
>
· {{ tc.displaySummary }}
</span>
</li>
</ol>
</template>
@ -62,63 +153,103 @@ function getToolDisplayName(toolName: string): string {
padding: 0;
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.toolStep {
display: flex;
align-items: center;
flex-direction: row;
align-items: stretch;
gap: var(--spacing--2xs);
position: relative;
user-select: none;
}
.toolStepIndicator {
position: relative;
.rail {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 14px;
}
.indicator {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
/* Match the label's line box so the icon centers on the first text line. */
height: calc(var(--font-size--sm) * var(--line-height--sm));
flex-shrink: 0;
color: var(--text-color--subtler);
}
.toolStep:not(:last-child) .toolStepIndicator::after {
content: '';
position: absolute;
top: calc(100% + 1px);
left: 50%;
/**
* The connecting line. `flex: 1` makes it grow to fill the rail's remaining
* height which equals the step's height (rail is stretched) so it always
* reaches the next step's icon, regardless of an expanded answer. The
* min-height provides the spacing between adjacent steps. Hidden on the last
* step so there's no dangling tail.
*/
.railLine {
flex: 1 1 auto;
width: 1px;
height: var(--spacing--2xs);
transform: translateX(-50%);
min-height: var(--spacing--2xs);
margin: 2px 0;
background-color: var(--border-color);
}
.toolStepDone {
.toolStep:last-child .railLine {
display: none;
}
.indicatorDone {
color: var(--text-color--success);
}
.toolStepError {
.indicatorError {
color: var(--text-color--danger);
}
.toolStepLoading {
.indicatorLoading {
color: var(--text-color);
}
.toolStepSuspended {
.indicatorSuspended {
color: var(--text-color--warning);
}
.toolStepLabel {
.stepBody {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.stepRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.stepRowButton {
width: 100%;
padding: 0;
border: none;
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.label {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
color: var(--text-color--subtler);
line-height: var(--line-height--sm);
}
.toolStepSummary {
.summary {
color: var(--text-color--subtler);
font-size: var(--font-size--xs);
line-height: var(--line-height--sm);
@ -128,6 +259,31 @@ function getToolDisplayName(toolName: string): 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;
}
.answer {
margin-bottom: var(--spacing--xs);
border-radius: var(--radius--sm);
background-color: var(--background--subtle);
overflow: hidden;
color: var(--text-color--subtle);
font-size: var(--font-size--2xs);
/* N8nMarkdownEditor sizes its content from --input--font-size (falling back
to inherit when unset). Pin it a step below the step label so the
sub-agent answer reads as secondary, compact detail. */
--input--font-size: var(--font-size--2xs);
}
.shimmer {
background: linear-gradient(
90deg,

View File

@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
N8nActionBox,
N8nButton,
N8nCard,
N8nCheckbox,
N8nHeading,
N8nScrollArea,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import Modal from '@/app/components/Modal.vue';
import { useUIStore } from '@/app/stores/ui.store';
export type AgentSubAgentOption = {
id: string;
name: string;
description?: string | null;
};
export type AgentSubAgentsModalData = {
agents: AgentSubAgentOption[];
onConfirm: (agentIds: string[]) => void;
};
const props = defineProps<{
modalName: string;
data: AgentSubAgentsModalData;
}>();
const i18n = useI18n();
const uiStore = useUIStore();
const selectedAgentIds = ref<string[]>([]);
const selectedAgentIdSet = computed(() => new Set(selectedAgentIds.value));
const canAdd = computed(() => selectedAgentIds.value.length > 0);
function closeModal() {
uiStore.closeModal(props.modalName);
}
function setAgentSelected(agentId: string, selected: boolean) {
if (selected) {
if (!selectedAgentIdSet.value.has(agentId)) {
selectedAgentIds.value = [...selectedAgentIds.value, agentId];
}
return;
}
selectedAgentIds.value = selectedAgentIds.value.filter((id) => id !== agentId);
}
function toggleAgent(agentId: string) {
setAgentSelected(agentId, !selectedAgentIdSet.value.has(agentId));
}
function onCheckboxUpdate(agentId: string, value: string | number | boolean) {
setAgentSelected(agentId, Boolean(value));
}
function onAdd() {
if (!canAdd.value) return;
props.data.onConfirm(selectedAgentIds.value);
closeModal();
}
</script>
<template>
<Modal
:name="props.modalName"
width="640px"
:custom-class="$style.modal"
data-testid="agent-sub-agents-modal"
>
<template #header>
<N8nHeading tag="h2" size="large">
{{ i18n.baseText('agents.builder.subAgents.modal.title') }}
</N8nHeading>
</template>
<template #content>
<div :class="$style.content">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.subAgents.modal.description') }}
</N8nText>
<N8nScrollArea v-if="data.agents.length > 0" max-height="420px" type="auto">
<div :class="$style.rows">
<N8nCard
v-for="agent in data.agents"
:key="agent.id"
:class="[$style.row, selectedAgentIdSet.has(agent.id) ? $style.selectedRow : '']"
data-testid="agent-sub-agents-modal-row"
@click="toggleAgent(agent.id)"
>
<template #prepend>
<N8nCheckbox
:model-value="selectedAgentIdSet.has(agent.id)"
:aria-label="
i18n.baseText('agents.builder.subAgents.modal.selectAgent', {
interpolate: { name: agent.name },
})
"
data-testid="agent-sub-agents-modal-checkbox"
@click.stop
@update:model-value="(value) => onCheckboxUpdate(agent.id, value)"
/>
</template>
<div :class="$style.rowBody">
<N8nText size="small" color="text-dark" :bold="true" :class="$style.name">
{{ agent.name }}
</N8nText>
<N8nText
v-if="agent.description"
size="xsmall"
color="text-light"
:class="$style.description"
>
{{ agent.description }}
</N8nText>
</div>
</N8nCard>
</div>
</N8nScrollArea>
<N8nActionBox
v-else
:icon="{ type: 'icon', value: 'bot' }"
:heading="i18n.baseText('agents.builder.subAgents.modal.empty.title')"
:description="i18n.baseText('agents.builder.subAgents.modal.empty.description')"
data-testid="agent-sub-agents-modal-empty"
/>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton variant="subtle" @click="closeModal">
{{ i18n.baseText('agents.builder.subAgents.modal.cancel') }}
</N8nButton>
<N8nButton
variant="solid"
:disabled="!canAdd"
data-testid="agent-sub-agents-modal-add"
@click="onAdd"
>
{{ i18n.baseText('agents.builder.subAgents.modal.add') }}
</N8nButton>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.modal {
:global(.modal-content) {
overflow: hidden;
}
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
margin: calc(-1 * var(--spacing--lg));
padding: var(--spacing--lg);
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding-right: var(--spacing--xs);
}
.row {
--card--prepend--width: auto;
flex-shrink: 0;
cursor: pointer;
}
.selectedRow {
border-color: var(--color--primary);
}
.rowBody {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
width: 100%;
min-width: 0;
}
.name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow-wrap: anywhere;
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
}
</style>

View File

@ -19,7 +19,8 @@ import RichInteractionCard from './RichInteractionCard.vue';
import WorkflowExecutionLogViewer from './WorkflowExecutionLogViewer.vue';
import ToolIoView from './ToolIoView.vue';
import type { TimelineItem } from '../session-timeline.types';
import { builtinToolLabelKey } from '../session-timeline.utils';
import { builtinToolLabelKey, isSubAgentTimelineItem } from '../session-timeline.utils';
import { delegateLabel } from '../utils/delegate-tool';
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
const i18n = useI18n();
@ -127,9 +128,14 @@ const toolDisplayName = computed((): string => {
return key ? i18n.baseText(key) : formatToolNameForDisplay(props.item.toolName);
});
const isSubAgent = computed((): boolean =>
props.item ? isSubAgentTimelineItem(props.item) : false,
);
const headerTitle = computed((): string => {
const item = props.item;
if (!item) return '';
if (isSubAgent.value) return delegateLabel(i18n, item.subAgentName ?? '');
if (item.kind === 'workflow') return item.workflowName ?? formatToolNameForDisplay(item.toolName);
if (item.kind === 'tool') return toolDisplayName.value;
if (item.kind === 'node') return item.nodeDisplayName ?? formatToolNameForDisplay(item.toolName);
@ -141,6 +147,7 @@ const headerTitle = computed((): string => {
const headerIcon = computed((): IconName => {
const item = props.item;
if (!item) return 'info';
if (isSubAgent.value) return 'bot';
if (item.kind === 'workflow') return 'workflow';
if (item.kind === 'tool') return 'wrench';
if (item.kind === 'node') return 'box';

View File

@ -6,8 +6,13 @@ import { N8nHoverCard } from '@n8n/design-system';
import { convertToDisplayDate } from '@/app/utils/formatters/dateFormatter';
import type { CSSProperties } from 'vue';
import type { IdleRange, TimelineItem } from '../session-timeline.types';
import { builtinToolLabelKey, formatDuration, itemFilterKey } from '../session-timeline.utils';
import { chartBlockStyle } from '../session-timeline.styles';
import {
builtinToolLabelKey,
formatDuration,
isSubAgentTimelineItem,
itemFilterKey,
} from '../session-timeline.utils';
import { chartBlockStyleForItem } from '../session-timeline.styles';
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
import SessionTimelinePill from './SessionTimelinePill.vue';
@ -69,7 +74,7 @@ function cellStyle(seg: Segment): Record<string, string> {
}
function eventStyle(item: TimelineItem): CSSProperties {
const style: CSSProperties = chartBlockStyle(item.kind);
const style: CSSProperties = chartBlockStyleForItem(item);
if (isDimmed(item)) {
style.opacity = '0.15';
style.pointerEvents = 'none';
@ -77,7 +82,12 @@ function eventStyle(item: TimelineItem): CSSProperties {
return style;
}
function popoverPillKind(item: TimelineItem) {
return isSubAgentTimelineItem(item) ? 'subagent' : item.kind;
}
function popoverLabel(item: TimelineItem): string {
if (isSubAgentTimelineItem(item)) return i18n.baseText('agentSessions.timeline.subAgent');
switch (item.kind) {
case 'user':
return i18n.baseText('agentSessions.timeline.user');
@ -97,6 +107,9 @@ function popoverLabel(item: TimelineItem): string {
}
function popoverName(item: TimelineItem): string {
if (isSubAgentTimelineItem(item)) {
return item.subAgentName ?? formatToolNameForDisplay(item.toolName);
}
switch (item.kind) {
case 'user':
case 'agent':
@ -250,7 +263,7 @@ onBeforeUnmount(clearShowPopoverTimer);
</div>
<div v-else-if="activePopover" :class="$style.popoverInner">
<SessionTimelinePill
:kind="activePopover.segment.item.kind"
:kind="popoverPillKind(activePopover.segment.item)"
:label="popoverLabel(activePopover.segment.item)"
show-label
/>

View File

@ -6,7 +6,7 @@ import { pillColors } from '../session-timeline.styles';
const props = withDefaults(
defineProps<{
kind: EventKind | 'idle';
kind: EventKind | 'idle' | 'subagent';
label?: string;
showLabel?: boolean;
}>(),
@ -21,6 +21,7 @@ const icon = computed((): IconName => {
case 'user':
return 'user';
case 'agent':
case 'subagent':
return 'bot';
case 'tool':
return 'wrench';

View File

@ -7,7 +7,8 @@ import { truncate } from '@n8n/utils';
import { convertToDisplayDate } from '@/app/utils/formatters/dateFormatter';
import { VIEWS } from '@/app/constants/navigation';
import type { TimelineItem } from '../session-timeline.types';
import { builtinToolLabelKey } from '../session-timeline.utils';
import { builtinToolLabelKey, isSubAgentTimelineItem } from '../session-timeline.utils';
import { delegateLabel } from '../utils/delegate-tool';
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
import SessionTimelinePill from './SessionTimelinePill.vue';
@ -21,6 +22,11 @@ const emit = defineEmits<{ select: [] }>();
const router = useRouter();
const i18n = useI18n();
// A delegate_subagent call renders as a sub-agent (bot icon + "Sub-agent · name")
// to match the chat, rather than as a plain tool.
const isSubAgent = computed((): boolean => isSubAgentTimelineItem(props.item));
const pillKind = computed(() => (isSubAgent.value ? 'subagent' : props.item.kind));
const time = computed((): string => {
if (!props.item.timestamp) return '';
return convertToDisplayDate(new Date(props.item.timestamp).toISOString()).time;
@ -39,6 +45,7 @@ const infoText = computed((): string => {
case 'agent':
return truncate(it.content ?? '', 500);
case 'tool': {
if (isSubAgent.value) return delegateLabel(i18n, it.subAgentName ?? '');
const key = builtinToolLabelKey(it.toolName, it.toolOutput);
return key ? i18n.baseText(key) : formatToolNameForDisplay(it.toolName);
}
@ -54,6 +61,7 @@ const infoText = computed((): string => {
});
const label = computed((): string => {
if (isSubAgent.value) return i18n.baseText('agentSessions.timeline.subAgent');
switch (props.item.kind) {
case 'user':
return i18n.baseText('agentSessions.timeline.user');
@ -76,7 +84,7 @@ const label = computed((): string => {
<template>
<div :class="[$style.row, selected && $style.selected]" @click="emit('select')">
<N8nTooltip :content="label" placement="top">
<SessionTimelinePill :kind="item.kind" />
<SessionTimelinePill :kind="pillKind" />
</N8nTooltip>
<div :class="$style.info">
<template v-if="item.kind === 'workflow' && workflowHref">

View File

@ -22,6 +22,7 @@ import {
import { CHAT_MESSAGE_STATUS, TOOL_CALL_STATE } from '../constants';
import type { ChatMessageStatus, ToolCallState } from '../constants';
import { summariseToolCall } from '../utils/interactive-summary';
import { isFailedDelegateOutput } from '../utils/delegate-tool';
export { type ChatMessageStatus, type ToolCallState };
// ---------------------------------------------------------------------------
@ -35,6 +36,10 @@ export interface ToolCall {
input?: unknown;
output?: unknown;
state: ToolCallState;
/** Epoch ms when the tool started executing (live: client clock; reload: recorded). */
startTime?: number;
/** Epoch ms when the tool settled. Absent while still running. */
endTime?: number;
/**
* One-line answer label rendered next to the tool name in
* `AgentChatToolSteps`. Set when an interactive tool resolves so the user
@ -286,8 +291,12 @@ export function convertDbMessages(dbMessages: AgentPersistedMessageDto[]): ChatM
let state: ToolCallState;
let output: unknown;
if (part.state === 'resolved') {
state = TOOL_CALL_STATE.DONE;
output = part.output;
// A failed delegation resolves like any other tool, so detect it
// from the output and render it as an error to match the live run.
state = isFailedDelegateOutput(part.toolName, part.output)
? TOOL_CALL_STATE.ERROR
: TOOL_CALL_STATE.DONE;
} else if (part.state === 'rejected') {
state = TOOL_CALL_STATE.ERROR;
output = part.error;
@ -302,6 +311,8 @@ export function convertDbMessages(dbMessages: AgentPersistedMessageDto[]): ChatM
input: part.input,
...(output !== undefined && { output }),
state,
...(part.startTime !== undefined && { startTime: part.startTime }),
...(part.endTime !== undefined && { endTime: part.endTime }),
displaySummary: summariseToolCall(part.toolName, output, part.input),
});
}

View File

@ -24,6 +24,7 @@ import {
} from './agentChatMessages';
import { CHAT_MESSAGE_STATUS, TOOL_CALL_STATE } from '../constants';
import { summariseToolCall } from '../utils/interactive-summary';
import { isFailedDelegateOutput } from '../utils/delegate-tool';
export interface FatalAgentError {
message: string;
@ -271,13 +272,33 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
break;
}
case 'tool-execution-start': {
// Timing is server-measured: store the backend `startTime` verbatim
// (no client clock) so the live duration matches the persisted one.
const found = findToolCallById(event.toolCallId);
if (
found &&
found.tc.state !== TOOL_CALL_STATE.DONE &&
found.tc.state !== TOOL_CALL_STATE.ERROR
) {
found.tc.state = TOOL_CALL_STATE.RUNNING;
if (found) {
found.tc.startTime = event.startTime;
if (found.tc.state !== TOOL_CALL_STATE.DONE && found.tc.state !== TOOL_CALL_STATE.ERROR) {
found.tc.state = TOOL_CALL_STATE.RUNNING;
}
}
break;
}
case 'tool-execution-end': {
// Per-tool completion bridged from the runtime event bus. Flips a
// concurrent tool call to its terminal state the moment it settles,
// rather than waiting for the batched `tool-result` events. The later
// `tool-result` still fills in the output/summary. `endTime` is the
// server-measured settle time (no client clock).
const found = findToolCallById(event.toolCallId);
if (found) {
if (
found.tc.state !== TOOL_CALL_STATE.DONE &&
found.tc.state !== TOOL_CALL_STATE.ERROR &&
found.tc.state !== TOOL_CALL_STATE.SUSPENDED
) {
found.tc.state = event.isError ? TOOL_CALL_STATE.ERROR : TOOL_CALL_STATE.DONE;
}
found.tc.endTime = event.endTime;
}
break;
}
@ -285,7 +306,8 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
const found = findToolCallById(event.toolCallId);
if (found) {
found.tc.output = event.output;
found.tc.state = event.isError ? TOOL_CALL_STATE.ERROR : TOOL_CALL_STATE.DONE;
const failed = event.isError || isFailedDelegateOutput(found.tc.tool, event.output);
found.tc.state = failed ? TOOL_CALL_STATE.ERROR : TOOL_CALL_STATE.DONE;
found.tc.displaySummary = summariseToolCall(found.tc.tool, event.output, found.tc.input);
// If this was an interactive tool call, the result IS the user's
// resume payload — refresh the card so it flips to its resolved

View File

@ -7,6 +7,7 @@ import { publishAgent, revertAgentToPublished, unpublishAgent } from './useAgent
import { useAgentTelemetry } from './useAgentTelemetry';
import { buildAgentConfigFingerprint } from './agentTelemetry.utils';
import { useAgentConfirmationModal } from './useAgentConfirmationModal';
import { upsertProjectAgentsListCache } from './useProjectAgentsList';
import type { AgentResource } from '../types';
/**
@ -28,6 +29,7 @@ export function useAgentPublish() {
publishing.value = true;
try {
const updated = await publishAgent(rootStore.restApiContext, projectId, agentId);
upsertProjectAgentsListCache(projectId, updated);
// Derive the fingerprint from the server's response so `config_version`
// reflects what was actually published regardless of the caller —
// list-card publishes don't have access to the live draft. Triggers
@ -70,6 +72,7 @@ export function useAgentPublish() {
publishing.value = true;
try {
const updated = await unpublishAgent(rootStore.restApiContext, projectId, agentId);
upsertProjectAgentsListCache(projectId, updated);
agentTelemetry.trackUnpublishedAgent({ agentId });
showMessage({ title: locale.baseText('agents.publish.toast.unpublished'), type: 'success' });
return updated;
@ -97,6 +100,7 @@ export function useAgentPublish() {
publishing.value = true;
try {
const updated = await revertAgentToPublished(rootStore.restApiContext, projectId, agentId);
upsertProjectAgentsListCache(projectId, updated);
showMessage({ title: locale.baseText('agents.publish.toast.reverted'), type: 'success' });
return updated;
} catch (error) {

View File

@ -5,6 +5,8 @@ export interface AgentExecutionThread {
id: string;
agentId: string;
agentName: string;
parentThreadId: string | null;
parentAgentId: string | null;
projectId: string;
/** Set when the session was invoked by a scheduled task; null for agent runs. */
taskId: string | null;

View File

@ -4,7 +4,7 @@
* per project, in-flight requests deduped, errors propagated so the next
* `ensureLoaded()` can retry cleanly.
*/
import { ref, watch, type Ref } from 'vue';
import { computed, ref, type Ref } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { listAgents } from './useAgentApi';
import type { AgentResource } from '../types';
@ -27,18 +27,10 @@ function getEntry(projectId: string): Entry {
export function useProjectAgentsList(projectId: Ref<string>) {
const rootStore = useRootStore();
const list = ref<AgentResource[] | null>(null);
function bind(id: string) {
if (!id) {
list.value = null;
return;
}
list.value = getEntry(id).list.value;
}
bind(projectId.value);
watch(projectId, (id) => bind(id));
const list = computed(() => {
const id = projectId.value;
return id ? getEntry(id).list.value : null;
});
async function ensureLoaded(): Promise<AgentResource[]> {
const id = projectId.value;
@ -50,7 +42,6 @@ export function useProjectAgentsList(projectId: Ref<string>) {
.then((result) => {
entry.list.value = result;
entry.inFlight = null;
if (projectId.value === id) list.value = result;
return result;
})
.catch((err: unknown) => {
@ -72,6 +63,30 @@ export function useProjectAgentsList(projectId: Ref<string>) {
return { list, ensureLoaded, refresh };
}
export function upsertProjectAgentsListCache(projectId: string, agent: AgentResource) {
if (!projectId) return;
const entry = getEntry(projectId);
const current = entry.list.value;
if (!current) return;
const existingIndex = current.findIndex((candidate) => candidate.id === agent.id);
if (existingIndex === -1) {
entry.list.value = [agent, ...current];
return;
}
entry.list.value = current.map((candidate) => (candidate.id === agent.id ? agent : candidate));
}
export function removeProjectAgentFromListCache(projectId: string, agentId: string) {
if (!projectId) return;
const entry = getEntry(projectId);
const current = entry.list.value;
if (!current) return;
entry.list.value = current.filter((agent) => agent.id !== agentId);
}
/** Test-only escape hatch — drops the module-level cache between specs. */
export function __clearProjectAgentsListCacheForTests() {
caches.clear();

View File

@ -0,0 +1,28 @@
/**
* Resolves sub-agent ids friendly names for delegate labels. Wraps the
* cached/deduped project agents list and loads it lazily only once the caller
* signals (via `isNeeded`) that the current content actually contains
* delegations. Shared by the chat tool step and the session timeline.
*/
import { computed, watch, type Ref } from 'vue';
import { useProjectAgentsList } from './useProjectAgentsList';
export function useSubAgentNames(projectId: Ref<string>, isNeeded: () => boolean) {
const { list, ensureLoaded } = useProjectAgentsList(projectId);
const subAgentNameById = computed(() => {
const map = new Map<string, string>();
for (const agent of list.value ?? []) map.set(agent.id, agent.name);
return map;
});
watch(
[isNeeded, projectId],
([needed, id]) => {
if (needed && id) void ensureLoaded().catch(() => {});
},
{ immediate: true },
);
return { subAgentNameById };
}

View File

@ -15,6 +15,7 @@ export const AGENT_TOOL_CONFIG_MODAL_KEY = 'agentToolConfigModal';
export const AGENT_SKILL_MODAL_KEY = 'agentSkillModal';
export const AGENT_TASK_MODAL_KEY = 'agentTaskModal';
export const AGENT_ADD_TRIGGER_MODAL_KEY = 'agentAddTriggerModal';
export const AGENT_SUB_AGENTS_MODAL_KEY = 'agentSubAgentsModal';
export const AGENT_EPISODIC_MEMORY_CREDENTIAL_MODAL_KEY = 'agentEpisodicMemoryCredentialModal';
export const AGENT_EPISODIC_MEMORY_CREDENTIAL_TYPE = 'openAiApi';

View File

@ -12,6 +12,7 @@ import {
AGENT_SKILL_MODAL_KEY,
AGENT_TASK_MODAL_KEY,
AGENT_ADD_TRIGGER_MODAL_KEY,
AGENT_SUB_AGENTS_MODAL_KEY,
AGENT_EPISODIC_MEMORY_CREDENTIAL_MODAL_KEY,
AGENT_EPISODIC_MEMORY_CREDENTIAL_TYPE,
AGENT_VIEW,
@ -105,6 +106,17 @@ export const AgentsModule: FrontendModuleDescription = {
},
},
},
{
key: AGENT_SUB_AGENTS_MODAL_KEY,
component: async () => await import('./components/AgentSubAgentsModal.vue'),
initialState: {
open: false,
data: {
agents: [],
onConfirm: () => {},
},
},
},
{
key: AGENT_EPISODIC_MEMORY_CREDENTIAL_MODAL_KEY,
component: async () => await import('../ai/chatHub/components/CredentialSelectorModal.vue'),

View File

@ -1,7 +1,7 @@
import type { CSSProperties } from 'vue';
import type { EventKind } from './session-timeline.types';
import { chartBlockColor } from './session-timeline.utils';
type TimelinePillKind = EventKind | 'idle';
import type { EventKind, TimelineItem } from './session-timeline.types';
import { chartBlockColor, isSubAgentTimelineItem } from './session-timeline.utils';
type TimelinePillKind = EventKind | 'idle' | 'subagent';
export function pillColors(
kind: TimelinePillKind,
@ -11,6 +11,8 @@ export function pillColors(
return { backgroundColor: 'var(--color--blue-200)', color: 'var(--color--blue-950)' };
case 'agent':
return { backgroundColor: 'var(--color--purple-200)', color: 'var(--color--purple-950)' };
case 'subagent':
return { backgroundColor: 'var(--color--mint-200)', color: 'var(--color--mint-950)' };
case 'tool':
return { backgroundColor: 'var(--color--green-200)', color: 'var(--color--green-950)' };
case 'workflow':
@ -25,9 +27,12 @@ export function pillColors(
}
}
export function chartBlockStyle(kind: EventKind): CSSProperties {
/** Chart block colour for a timeline item — sub-agent delegations get a distinct hue. */
export function chartBlockStyleForItem(item: TimelineItem): CSSProperties {
return {
'--session-timeline-chart-block-color': chartBlockColor(kind),
'--session-timeline-chart-block-color': isSubAgentTimelineItem(item)
? 'var(--color--mint-600)'
: chartBlockColor(item.kind),
};
}

View File

@ -25,6 +25,12 @@ export interface TimelineItem {
* the LLM's runtime input items.
*/
nodeParameters?: Record<string, unknown>;
/**
* Resolved display name for a `delegate_subagent` tool call the configured
* sub-agent's name, falling back to the humanized task name. Set by the view
* so the row/chart/detail can render "Sub-agent · <name>".
*/
subAgentName?: string;
resumed?: boolean;
}

View File

@ -1,6 +1,8 @@
import type { BaseTextKey } from '@n8n/i18n';
import type { EventKind, IdleRange, TimelineItem } from './session-timeline.types';
import type { AgentExecution } from './composables/useAgentThreadsApi';
import { formatToolNameForDisplay } from './utils/toolDisplayName';
import { isDelegateSubAgentTool } from './utils/delegate-tool';
import { formatToolNameForDisplay, getToolNameTranslationKey } from './utils/toolDisplayName';
export const IDLE_THRESHOLD_MS = 10 * 60 * 1000;
@ -8,6 +10,11 @@ export function endTimestampOf(item: TimelineItem): number {
return item.endTimestamp ?? item.timestamp;
}
/** A `delegate_subagent` tool call — rendered as a sub-agent (bot icon) rather than a plain tool. */
export function isSubAgentTimelineItem(item: TimelineItem): boolean {
return item.kind === 'tool' && isDelegateSubAgentTool(item.toolName);
}
export function computeIdleRanges(items: TimelineItem[]): IdleRange[] {
const ranges: IdleRange[] = [];
for (let i = 0; i < items.length - 1; i++) {
@ -43,7 +50,13 @@ export function timelineItemSearchText(
parts.push(labelForKey('suspension-waiting'));
}
parts.push(item.content, item.toolName, item.workflowName, item.nodeDisplayName);
parts.push(
item.content,
item.toolName,
item.workflowName,
item.nodeDisplayName,
item.subAgentName,
);
if (item.toolName) parts.push(formatToolNameForDisplay(item.toolName));
const toolKey = builtinToolLabelKey(item.toolName, item.toolOutput);
@ -119,15 +132,6 @@ export function chartBlockColor(kind: EventKind): string {
return CHART_BLOCK_COLOR_MAP[kind];
}
/**
* i18n keys for built-in tools that should render as a friendly label rather
* than their raw machine name. Returns `null` for any tool not in the map so
* callers fall back to the raw `toolName`.
*/
export type BuiltinToolLabelKey =
| 'agentSessions.timeline.tool.richInteraction'
| 'agentSessions.timeline.tool.richInteractionDisplay';
/**
* Resolve the i18n label for a tool entry. Some built-in tools (currently
* `rich_interaction`) have two semantically distinct modes interactive
@ -140,14 +144,14 @@ export type BuiltinToolLabelKey =
export function builtinToolLabelKey(
toolName: string | undefined,
output?: unknown,
): BuiltinToolLabelKey | null {
): BaseTextKey | null {
switch (toolName) {
case 'rich_interaction':
return isDisplayOnlyOutput(output)
? 'agentSessions.timeline.tool.richInteractionDisplay'
: 'agentSessions.timeline.tool.richInteraction';
default:
return null;
return getToolNameTranslationKey(toolName) ?? null;
}
}

View File

@ -0,0 +1,97 @@
import type { useI18n } from '@n8n/i18n';
import { z } from 'zod';
/**
* Name of the SDK tool a parent agent calls to hand a task to a sub-agent.
* Mirrors `DELEGATE_SUB_AGENT_TOOL_NAME` in `@n8n/agents` (not FE-importable),
* so the chat can special-case the tool call and render it as an expandable
* tool step.
*/
export const DELEGATE_SUB_AGENT_TOOL_NAME = 'delegate_subagent';
// 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(),
taskName: z.string().optional(),
});
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(),
answer: z.string().optional(),
error: z.string().optional(),
});
export type DelegateInput = z.infer<typeof delegateInputSchema>;
export type DelegateOutput = z.infer<typeof delegateOutputSchema>;
export function isDelegateSubAgentTool(toolName: string | undefined): boolean {
return toolName === DELEGATE_SUB_AGENT_TOOL_NAME;
}
/** Parse a delegate tool-call input; returns `undefined` when it isn't an object. */
export function parseDelegateInput(input: unknown): DelegateInput | undefined {
const result = delegateInputSchema.safeParse(input);
return result.success ? result.data : undefined;
}
/**
* Parse a delegate tool-call output; returns `undefined` when it isn't an object
* (e.g. a rejected tool call whose output is the raw error string).
*/
export function parseDelegateOutput(output: unknown): DelegateOutput | undefined {
const result = delegateOutputSchema.safeParse(output);
return result.success ? result.data : undefined;
}
/**
* 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
* error state explicitly (both live and on reload).
*/
export function isFailedDelegateOutput(toolName: string | undefined, output: unknown): boolean {
if (!isDelegateSubAgentTool(toolName)) return false;
return parseDelegateOutput(output)?.status === 'failed';
}
/** Humanize a snake/kebab task name, e.g. `research_api` → `Research api`. */
export function humanizeTaskName(taskName: string | undefined): string {
const normalized = taskName?.trim().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
if (!normalized) return '';
return normalized.charAt(0).toLocaleUpperCase() + normalized.slice(1);
}
/**
* Resolve a delegate call's display name from its raw tool input: the configured
* sub-agent's name when its id maps to one, else the humanized task name, else
* `''`. Shared by the chat tool step and the session timeline so both label a
* delegation identically.
*/
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;
if (resolved) return resolved;
return humanizeTaskName(parsed?.taskName);
}
/**
* Format a delegate label: `Sub-agent · <name>` when a name resolved, otherwise
* the bare `Sub-agent` fallback. Takes the i18n instance (rather than resolving
* keys at the call site) so the chat, timeline row, and detail panel stay in
* sync.
*/
export function delegateLabel(
i18n: Pick<ReturnType<typeof useI18n>, 'baseText'>,
name: string,
): string {
return name
? i18n.baseText('agents.chat.delegate.label', { interpolate: { name } })
: i18n.baseText('agents.chat.delegate.labelFallback');
}

View File

@ -45,6 +45,7 @@ import { useAgentBuilderSession } from '../composables/useAgentBuilderSession';
import { useAgentConfigAutosave } from '../composables/useAgentConfigAutosave';
import { useAgentBuilderMainTabs } from '../composables/useAgentBuilderMainTabs';
import { mcpServerToNode } from '../composables/useMcpServerAdapter';
import { removeProjectAgentFromListCache } from '../composables/useProjectAgentsList';
import {
AGENT_BUILDER_VIEW,
AGENT_PREVIEW_VIEW,
@ -652,6 +653,7 @@ async function onHeaderAction(action: string) {
try {
await deleteAgent(rootStore.restApiContext, capturedProjectId, agentId.value);
removeProjectAgentFromListCache(capturedProjectId, agentId.value);
} catch (error) {
showError(error, 'Could not delete agent');
return;

View File

@ -26,7 +26,10 @@ import {
itemFilterKey,
chartBlockColor,
filteredTimelineItemIndexes,
isSubAgentTimelineItem,
} from '@/features/agents/session-timeline.utils';
import { useSubAgentNames } from '@/features/agents/composables/useSubAgentNames';
import { resolveSubAgentName } from '@/features/agents/utils/delegate-tool';
import { shouldIgnoreCanvasShortcut } from '@/features/workflows/canvas/canvas.utils';
import type { FilterOption, TimelineItem } from '@/features/agents/session-timeline.types';
import { useI18n } from '@n8n/i18n';
@ -66,7 +69,24 @@ const selectedFilters = ref<Set<string>>(new Set());
const searchQuery = ref('');
let loadThreadDetailRequestId = 0;
const items = computed<TimelineItem[]>(() => flattenExecutionsToTimelineItems(executions.value));
const baseItems = computed<TimelineItem[]>(() =>
flattenExecutionsToTimelineItems(executions.value),
);
// Resolve sub-agent ids to friendly names, loaded lazily and only when the
// session actually contains delegations (mirrors how the chat resolves the
// delegate step label).
const { subAgentNameById } = useSubAgentNames(projectId, () =>
baseItems.value.some(isSubAgentTimelineItem),
);
const items = computed<TimelineItem[]>(() =>
baseItems.value.map((item) => {
if (!isSubAgentTimelineItem(item)) return item;
const name = resolveSubAgentName(item.toolInput, subAgentNameById.value);
return name ? { ...item, subAgentName: name } : item;
}),
);
const idleRanges = computed(() => computeIdleRanges(items.value));
const bounds = computed(() => sessionBounds(items.value));

View File

@ -7,11 +7,13 @@ import { convertToDisplayDate } from '@/app/utils/formatters/dateFormatter';
import { useAgentSessionsStore } from '@/features/agents/agentSessions.store';
import { AGENT_SESSION_DETAIL_VIEW } from '@/features/agents/constants';
import { useThreadTitle } from '@/features/agents/utils/thread-title';
import type { AgentExecutionThread } from '@/features/agents/composables/useAgentThreadsApi';
import { useI18n } from '@n8n/i18n';
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { N8nActionDropdown, N8nButton, N8nTableBase } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system';
import { ElSkeletonItem } from 'element-plus';
const i18n = useI18n();
@ -66,15 +68,32 @@ function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
function originLabel(taskId: string | null): string {
return taskId
? i18n.baseText('agentSessions.origin.task')
: i18n.baseText('agentSessions.origin.agent');
function originLabel(thread: AgentExecutionThread): string {
if (thread.parentThreadId) return i18n.baseText('agentSessions.origin.subAgent');
if (thread.taskId) return i18n.baseText('agentSessions.origin.task');
return i18n.baseText('agentSessions.origin.agent');
}
const deleteActions = [
{ id: 'delete', label: i18n.baseText('generic.delete'), icon: 'trash-2' as const },
];
function rowActions(thread: AgentExecutionThread): Array<ActionDropdownItem<string>> {
const actions: Array<ActionDropdownItem<string>> = [];
if (thread.parentThreadId && thread.parentAgentId) {
actions.push({
id: 'goToParentRun',
label: i18n.baseText('agentSessions.goToParentRun'),
icon: 'arrow-up-right',
});
}
actions.push({
id: 'delete',
label: i18n.baseText('generic.delete'),
icon: 'trash-2',
divided: actions.length > 0,
});
return actions;
}
function onRowClick(threadId: string) {
void router.push({
@ -83,7 +102,20 @@ function onRowClick(threadId: string) {
});
}
async function onAction(actionId: string, threadId: string) {
async function onAction(actionId: string, thread: AgentExecutionThread) {
if (actionId === 'goToParentRun') {
if (!thread.parentAgentId || !thread.parentThreadId) return;
void router.push({
name: AGENT_SESSION_DETAIL_VIEW,
params: {
projectId: projectId.value,
agentId: thread.parentAgentId,
threadId: thread.parentThreadId,
},
});
return;
}
if (actionId !== 'delete') return;
const confirmed = await message.confirm(
@ -99,7 +131,7 @@ async function onAction(actionId: string, threadId: string) {
if (confirmed !== MODAL_CONFIRM) return;
try {
await sessionsStore.deleteThread(projectId.value, threadId);
await sessionsStore.deleteThread(projectId.value, thread.id);
toast.showMessage({
title: i18n.baseText('agentSessions.showMessage.deleted'),
type: 'success',
@ -128,6 +160,7 @@ async function loadMore() {
<th>{{ i18n.baseText('agentSessions.lastMessage') }}</th>
<th>{{ i18n.baseText('agentSessions.duration') }}</th>
<th>{{ i18n.baseText('agentSessions.tokenUsage') }}</th>
<th>{{ i18n.baseText('agentSessions.sessionId') }}</th>
<th>{{ i18n.baseText('agentSessions.origin') }}</th>
<th style="width: 50px"></th>
</tr>
@ -144,19 +177,20 @@ async function loadMore() {
<td>{{ formatDate(thread.updatedAt) }}</td>
<td>{{ formatDuration(thread.totalDuration) }}</td>
<td>{{ formatTokens(thread.totalPromptTokens + thread.totalCompletionTokens) }}</td>
<td>{{ originLabel(thread.taskId) }}</td>
<td>{{ thread.sessionNumber }}</td>
<td data-test-id="agent-session-origin">{{ originLabel(thread) }}</td>
<td @click.stop>
<N8nActionDropdown
:items="deleteActions"
:items="rowActions(thread)"
activator-icon="ellipsis"
data-test-id="agent-session-actions"
@select="onAction($event, thread.id)"
@select="onAction($event, thread)"
/>
</td>
</tr>
<template v-if="sessionsStore.loading && !sessionsStore.threads.length">
<tr v-for="item in 5" :key="item">
<td v-for="col in 6" :key="col">
<td v-for="col in 7" :key="col">
<ElSkeletonItem />
</td>
</tr>
@ -165,7 +199,7 @@ async function loadMore() {
v-if="!sessionsStore.loading && !sessionsStore.threads.length"
:class="$style.lastRow"
>
<td :colspan="6" style="text-align: center; padding: var(--spacing--lg)">
<td :colspan="7" style="text-align: center; padding: var(--spacing--lg)">
<template v-if="!sessionsStore.threads.length && !sessionsStore.loading">
<span data-test-id="agent-sessions-empty">
{{ i18n.baseText('agentSessions.empty') }}
@ -174,7 +208,7 @@ async function loadMore() {
</td>
</tr>
<tr :class="$style.lastRow" v-if="sessionsStore.nextCursor">
<td colspan="6">
<td colspan="7">
<N8nButton
icon="refresh-cw"
variant="ghost"

View File

@ -14,6 +14,7 @@ import { AGENT_BUILDER_VIEW } from '../constants';
import { useAgentBuilderStatus } from '../composables/useAgentBuilderStatus';
import { useAgentTelemetry } from '../composables/useAgentTelemetry';
import { buildAgentConfigFingerprint } from '../composables/agentTelemetry.utils';
import { upsertProjectAgentsListCache } from '../composables/useProjectAgentsList';
import AgentBuilderProgress from '../components/AgentBuilderProgress.vue';
import AgentBuilderUnconfiguredEmptyState from '../components/AgentBuilderUnconfiguredEmptyState.vue';
@ -198,6 +199,7 @@ async function createBlank() {
projectId.value,
i18n.baseText('agents.new.defaultName'),
);
upsertProjectAgentsListCache(projectId.value, agent);
telemetry.track('User created agent', {
agent_id: agent.id,
source: 'create_blank',
@ -221,6 +223,7 @@ async function submitDescription() {
projectId.value,
i18n.baseText('agents.new.defaultName'),
);
upsertProjectAgentsListCache(projectId.value, agent);
telemetry.track('User created agent', {
agent_id: agent.id,
source: 'description_prompt',