mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-03 10:17:00 +02:00
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:
parent
ee3b277ff0
commit
bfff25f05d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 : ''))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
417
packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts
Normal file
417
packages/@n8n/agents/src/runtime/delegate-sub-agent-tool.ts
Normal 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 '';
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
149
packages/@n8n/agents/src/runtime/sub-agent-task-path.ts
Normal file
149
packages/@n8n/agents/src/runtime/sub-agent-task-path.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,6 @@ export type {
|
|||
ResumeOptions,
|
||||
GenerateResult,
|
||||
StreamResult,
|
||||
SubAgentUsage,
|
||||
BuiltAgent,
|
||||
AgentRunState,
|
||||
AgentResumeData,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
52
packages/@n8n/api-types/src/agents/sub-agent.schema.ts
Normal file
52
packages/@n8n/api-types/src/agents/sub-agent.schema.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -560,6 +560,8 @@ describe('GlobalConfig', () => {
|
|||
},
|
||||
agents: {
|
||||
checkpointTtlSeconds: 345600,
|
||||
subAgentMaxChildren: 5,
|
||||
subAgentTimeoutMs: 300000,
|
||||
modules: [],
|
||||
},
|
||||
} satisfies GlobalConfigShape;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ?? [] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 ?? {},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>' },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user