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