diff --git a/packages/@n8n/agents/src/__tests__/integration/deferred-tools.test.ts b/packages/@n8n/agents/src/__tests__/integration/deferred-tools.test.ts new file mode 100644 index 00000000000..59b9c3a90ba --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/deferred-tools.test.ts @@ -0,0 +1,134 @@ +import { expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + chunksOfType, + collectStreamChunks, + collectTextDeltas, + describeIf, + getModel, +} from './helpers'; +import { Agent, Tool } from '../../index'; + +const describe = describeIf('anthropic'); + +describe('deferred tools integration', () => { + it('searches, loads, and uses a deferred tool in generate mode', async () => { + const multiplyTool = new Tool('multiply_numbers') + .description('Multiply two numbers and return their product') + .input( + z.object({ + a: z.number().describe('First number'), + b: z.number().describe('Second number'), + }), + ) + .output(z.object({ product: z.number() })) + .handler(async ({ a, b }) => ({ product: a * b })); + + const agent = new Agent('deferred-generate-test') + .model(getModel('anthropic')) + .instructions( + 'You are testing deferred tool discovery. For arithmetic requests, first call search_tools, then call load_tool with the exact returned tool name, then call the loaded tool. Do not answer from mental math.', + ) + .deferredTool(multiplyTool, { search: { topK: 2 } }); + + const result = await agent.generate( + 'Use deferred tools to multiply 6 and 7. Search for the tool, load it, call it, then answer with the product.', + ); + + const toolNames = result.toolCalls?.map((toolCall) => toolCall.tool) ?? []; + expect(toolNames).toEqual( + expect.arrayContaining(['search_tools', 'load_tool', 'multiply_numbers']), + ); + + const multiplyCall = result.toolCalls?.find((toolCall) => toolCall.tool === 'multiply_numbers'); + expect(multiplyCall?.output).toEqual({ product: 42 }); + }); + + it('searches, loads, and uses a deferred tool in stream mode', async () => { + const countCharactersTool = new Tool('count_characters') + .description('Count the characters in a text string') + .input(z.object({ text: z.string().describe('Text to count') })) + .output(z.object({ length: z.number() })) + .handler(async ({ text }) => ({ length: text.length })); + + const agent = new Agent('deferred-stream-test') + .model(getModel('anthropic')) + .instructions( + 'You are testing deferred tool discovery. For character counting requests, first call search_tools, then call load_tool with the exact returned tool name, then call the loaded tool. Do not count manually.', + ) + .deferredTool(countCharactersTool); + + const { stream } = await agent.stream( + 'Use deferred tools to count the characters in the text "n8n". Search for the tool, load it, call it, then answer with the length.', + ); + + const chunks = await collectStreamChunks(stream); + const toolResults = chunksOfType(chunks, 'tool-result'); + const toolNames = toolResults.map((toolResult) => toolResult.toolName); + + expect(toolNames).toEqual( + expect.arrayContaining(['search_tools', 'load_tool', 'count_characters']), + ); + + const countResult = toolResults.find( + (toolResult) => toolResult.toolName === 'count_characters', + ); + expect(countResult?.output).toEqual({ length: 3 }); + expect(collectTextDeltas(chunks)).toMatch(/3/); + }); + + it('resumes a suspended deferred tool after loading it', async () => { + const deleteTool = new Tool('delete_temp_file') + .description('Delete a temporary file at a requested path after approval') + .input(z.object({ path: z.string().describe('Temporary file path to delete') })) + .output(z.object({ deleted: z.boolean(), path: z.string() })) + .suspend(z.object({ message: z.string(), severity: z.string() })) + .resume(z.object({ approved: z.boolean() })) + .handler(async ({ path }, ctx) => { + if (!ctx.resumeData) { + return await ctx.suspend({ + message: `Delete temporary file "${path}"?`, + severity: 'destructive', + }); + } + + if (!ctx.resumeData.approved) return { deleted: false, path }; + return { deleted: true, path }; + }); + + const agent = new Agent('deferred-suspend-test') + .model(getModel('anthropic')) + .instructions( + 'You are testing deferred tool discovery with approval. When asked to delete a temporary file, first call search_tools, then call load_tool with the exact returned tool name, then call delete_temp_file. Do not skip the tool.', + ) + .deferredTool(deleteTool) + .checkpoint('memory'); + + const firstResult = await agent.generate( + 'Use deferred tools to delete /tmp/deferred-tool-test.txt. Search for the tool, load it, and call it with that exact path.', + ); + + expect(firstResult.pendingSuspend).toHaveLength(1); + expect(firstResult.pendingSuspend![0].toolName).toBe('delete_temp_file'); + + const resumeResult = await agent.resume( + 'generate', + { approved: true }, + { + runId: firstResult.pendingSuspend![0].runId, + toolCallId: firstResult.pendingSuspend![0].toolCallId, + }, + ); + + expect(resumeResult.finishReason).not.toBe('error'); + expect(resumeResult.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tool: 'delete_temp_file', + output: { deleted: true, path: '/tmp/deferred-tool-test.txt' }, + }), + ]), + ); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index 3ed3d1753b9..b9e83397424 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -760,6 +760,205 @@ function makeGenerateWithToolCalls( }; } +describe('AgentRuntime — deferred tool loading', () => { + beforeEach(() => { + generateText.mockReset(); + streamText.mockReset(); + }); + + it('searches and loads deferred tools into the next generate iteration', async () => { + const coreTool = makeMockTool('core_tool', async () => await Promise.resolve({ ok: true })); + const deferredTool = makeMockTool( + 'deferred_capability', + async () => await Promise.resolve({ ok: true }), + ); + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'You are a test assistant.', + tools: [coreTool], + deferredTools: [deferredTool], + }); + + generateText + .mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { + toolCallId: 'tc-search', + toolName: 'search_tools', + args: { query: 'deferred capability' }, + }, + ]), + ) + .mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { + toolCallId: 'tc-load', + toolName: 'load_tool', + args: { toolName: 'deferred_capability' }, + }, + ]), + ) + .mockResolvedValueOnce(makeGenerateSuccess('ready')); + + const result = await runtime.generate('need the deferred capability'); + + expect(generateText).toHaveBeenCalledTimes(3); + + const searchCall = result.toolCalls?.find((toolCall) => toolCall.tool === 'search_tools'); + expect(searchCall?.output).toEqual({ + results: [ + { + name: 'deferred_capability', + description: 'Mock tool deferred_capability', + loaded: false, + }, + ], + }); + + const loadCall = result.toolCalls?.find((toolCall) => toolCall.tool === 'load_tool'); + expect(loadCall?.output).toEqual({ + status: 'loaded', + toolName: 'deferred_capability', + tool: { + name: 'deferred_capability', + description: 'Mock tool deferred_capability', + loaded: true, + }, + message: 'Tool "deferred_capability" is loaded and will be available on the next model turn.', + }); + + const generateTextCalls = generateText.mock.calls as Array< + [{ tools: Record }] + >; + const firstCall = generateTextCalls[0][0]; + const firstTools = Object.keys(firstCall.tools); + expect(firstTools).toEqual(expect.arrayContaining(['core_tool', 'search_tools', 'load_tool'])); + expect(firstTools).not.toContain('deferred_capability'); + + const secondTools = Object.keys(generateTextCalls[1][0].tools); + expect(secondTools).toEqual(expect.arrayContaining(['core_tool', 'search_tools', 'load_tool'])); + expect(secondTools).not.toContain('deferred_capability'); + + const thirdTools = Object.keys(generateTextCalls[2][0].tools); + expect(thirdTools).toEqual( + expect.arrayContaining(['core_tool', 'search_tools', 'load_tool', 'deferred_capability']), + ); + }); + + it('does not leak loaded deferred tools into the next generate run', async () => { + const coreTool = makeMockTool('core_tool', async () => await Promise.resolve({ ok: true })); + const deferredTool = makeMockTool( + 'deferred_capability', + async () => await Promise.resolve({ ok: true }), + ); + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'You are a test assistant.', + tools: [coreTool], + deferredTools: [deferredTool], + }); + + generateText + .mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { + toolCallId: 'tc-load', + toolName: 'load_tool', + args: { toolName: 'deferred_capability' }, + }, + ]), + ) + .mockResolvedValueOnce(makeGenerateSuccess('first done')); + + await runtime.generate('load the deferred capability'); + + generateText.mockClear(); + generateText.mockResolvedValueOnce(makeGenerateSuccess('second done')); + + await runtime.generate('start a fresh run'); + + const generateTextCalls = generateText.mock.calls as Array< + [{ tools: Record }] + >; + const freshRunTools = Object.keys(generateTextCalls[0][0].tools); + expect(freshRunTools).toEqual( + expect.arrayContaining(['core_tool', 'search_tools', 'load_tool']), + ); + expect(freshRunTools).not.toContain('deferred_capability'); + }); + + it('resumes a suspended deferred tool after it has been loaded', async () => { + const deferredTool = makeSuspendingTool('deferred_approval', async (input, ctx) => { + if (!ctx.resumeData) { + return await ctx.suspend({ reason: 'approve deferred action?' }); + } + + const resumeData = ctx.resumeData as { approved: boolean }; + return { + approved: resumeData.approved, + value: (input as { value?: string }).value, + }; + }); + + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'You are a test assistant.', + deferredTools: [deferredTool], + checkpointStorage: 'memory', + }); + + generateText + .mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { + toolCallId: 'tc-load', + toolName: 'load_tool', + args: { toolName: 'deferred_approval' }, + }, + ]), + ) + .mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { + toolCallId: 'tc-deferred', + toolName: 'deferred_approval', + args: { value: 'needs approval' }, + }, + ]), + ); + + const firstResult = await runtime.generate('load and run the deferred approval tool'); + + expect(firstResult.finishReason).toBe('tool-calls'); + expect(firstResult.pendingSuspend).toHaveLength(1); + expect(firstResult.pendingSuspend![0].toolName).toBe('deferred_approval'); + + generateText.mockResolvedValueOnce(makeGenerateSuccess('approved')); + + const resumeResult = await runtime.resume( + 'generate', + { approved: true }, + { + runId: firstResult.pendingSuspend![0].runId, + toolCallId: firstResult.pendingSuspend![0].toolCallId, + }, + ); + + expect(resumeResult.finishReason).toBe('stop'); + expect(resumeResult.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tool: 'deferred_approval', + output: { approved: true, value: 'needs approval' }, + }), + ]), + ); + }); +}); + describe('AgentRuntime — concurrent tool execution', () => { beforeEach(() => { generateText.mockReset(); diff --git a/packages/@n8n/agents/src/runtime/agent-runtime.ts b/packages/@n8n/agents/src/runtime/agent-runtime.ts index b29e1a5b0a3..53c37595830 100644 --- a/packages/@n8n/agents/src/runtime/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/agent-runtime.ts @@ -33,6 +33,7 @@ import type { XaiThinkingConfig, } from '../types'; import { BackgroundTaskTracker } from './background-task-tracker'; +import { DeferredToolManager } from './deferred-tool-manager'; import { AgentEventBus } from './event-bus'; import { toJsonValue } from './json-value'; import { saveMessagesToThread } from './memory-store'; @@ -163,6 +164,10 @@ export interface AgentRuntimeConfig { instructions: string; instructionProviderOptions?: ProviderOptions; tools?: BuiltTool[]; + deferredTools?: BuiltTool[]; + toolSearch?: { + topK?: number; + }; providerTools?: BuiltProviderTool[]; memory?: BuiltMemory; lastMessages?: number; @@ -315,10 +320,15 @@ export class AgentRuntime { private observationTimers = new Map>(); + private deferredToolManager: DeferredToolManager | undefined; + /** Resolved telemetry for the current run (own config or inherited from parent). */ constructor(config: AgentRuntimeConfig) { this.config = config; + if (config.deferredTools && config.deferredTools.length > 0) { + this.deferredToolManager = new DeferredToolManager(config.deferredTools, config.toolSearch); + } this.runState = new RunStateManager(config.checkpointStorage); this.eventBus = config.eventBus ?? new AgentEventBus(); this.currentState = { @@ -447,7 +457,10 @@ export class AgentRuntime { const toolCall = state.pendingToolCalls[options.toolCallId]; if (!toolCall) throw new Error(`No tool call found for toolCallId: ${options.toolCallId}`); - const tool = this.config.tools?.find((t) => t.name === toolCall.toolName); + const list = AgentMessageList.deserialize(state.messageList); + this.hydrateDeferredToolsFromList(list); + + const tool = this.getCurrentTools().find((t) => t.name === toolCall.toolName); if (!tool) throw new Error(`Tool ${toolCall.toolName} not found`); let resumeData: unknown = data; @@ -460,8 +473,6 @@ export class AgentRuntime { } try { - const list = AgentMessageList.deserialize(state.messageList); - // Merge persisted execution options with fresh caller options const { runId: _rid, toolCallId: _tcid, ...callerExecOptions } = options; const persisted = state.executionOptions ?? {}; @@ -913,15 +924,7 @@ export class AgentRuntime { /** Core generate loop using generateText (non-streaming). */ private async runGenerateLoop(ctx: LoopContext): Promise { const { list, options, runId, pendingResume } = ctx; - const { - model, - toolMap, - aiTools, - providerOptions, - hasTools, - outputSpec, - effectiveInstructions, - } = this.buildLoopContext({ ...options, persistence: options?.persistence }); + this.hydrateDeferredToolsFromList(list); let totalUsage: TokenUsage | undefined; let lastFinishReason: FinishReason = 'stop'; @@ -931,8 +934,13 @@ export class AgentRuntime { // Resolve pending tool calls from a resumed run before the first LLM call. const runTelemetry = this.resolveTelemetry(options); - const toolCtx: ToolBatchContext = { - toolMap, + const staticLoopContext = this.buildStaticLoopContext({ + ...options, + persistence: options?.persistence, + }); + const pendingLoopContext = this.buildToolLoopContext(staticLoopContext.aiProviderTools); + const pendingToolCtx: ToolBatchContext = { + toolMap: pendingLoopContext.toolMap, list, runId, telemetry: runTelemetry, @@ -940,7 +948,10 @@ export class AgentRuntime { }; if (pendingResume) { - const batch = await this.iteratePendingToolCallsConcurrent({ ...toolCtx, pendingResume }); + const batch = await this.iteratePendingToolCallsConcurrent({ + ...pendingToolCtx, + pendingResume, + }); for (const r of batch.results) { toolCallSummary.push(r.toolEntry); @@ -981,15 +992,19 @@ export class AgentRuntime { this.eventBus.emit({ type: AgentEvent.TurnStart }); + const { toolMap, aiTools, hasTools, effectiveInstructions } = this.buildToolLoopContext( + staticLoopContext.aiProviderTools, + ); + const result = await generateText({ - model, + model: staticLoopContext.model, messages: list.forLlm(effectiveInstructions, this.config.instructionProviderOptions), abortSignal: this.eventBus.signal, ...(hasTools ? { tools: aiTools } : {}), - ...(providerOptions - ? { providerOptions: providerOptions as Record } + ...(staticLoopContext.providerOptions + ? { providerOptions: staticLoopContext.providerOptions as Record } : {}), - ...(outputSpec ? { output: outputSpec } : {}), + ...(staticLoopContext.outputSpec ? { output: staticLoopContext.outputSpec } : {}), ...this.buildTelemetryOptions(options), }); @@ -1004,7 +1019,7 @@ export class AgentRuntime { list.addResponse(newMessages); if (aiFinishReason !== 'tool-calls') { - if (outputSpec) { + if (staticLoopContext.outputSpec) { structuredOutput = result.output; } this.emitTurnEnd(newMessages, extractSettledToolCalls(newMessages)); @@ -1012,7 +1027,11 @@ export class AgentRuntime { } const batch = await this.iterateToolCallsConcurrent({ - ...toolCtx, + toolMap, + list, + runId, + telemetry: runTelemetry, + executionCounter: options?.executionCounter, toolCalls: result.toolCalls, }); @@ -1141,15 +1160,7 @@ export class AgentRuntime { ctx: LoopContext & { writer: WritableStreamDefaultWriter }, ): Promise { const { list, options, runId, pendingResume, writer } = ctx; - const { - model, - toolMap, - aiTools, - providerOptions, - hasTools, - outputSpec, - effectiveInstructions, - } = this.buildLoopContext({ ...options, persistence: options?.persistence }); + this.hydrateDeferredToolsFromList(list); const writeChunk = async (chunk: StreamChunk): Promise => { await writer.write(chunk); @@ -1177,8 +1188,13 @@ export class AgentRuntime { // Resolve pending tool calls from a resumed run before the first LLM call. const runTelemetry = this.resolveTelemetry(options); - const toolCtx: ToolBatchContext = { - toolMap, + const staticLoopContext = this.buildStaticLoopContext({ + ...options, + persistence: options?.persistence, + }); + const pendingLoopContext = this.buildToolLoopContext(staticLoopContext.aiProviderTools); + const pendingToolCtx: ToolBatchContext = { + toolMap: pendingLoopContext.toolMap, list, runId, telemetry: runTelemetry, @@ -1187,7 +1203,7 @@ export class AgentRuntime { if (pendingResume) { try { const batch = await this.iteratePendingToolCallsConcurrent({ - ...toolCtx, + ...pendingToolCtx, pendingResume, }); @@ -1248,16 +1264,19 @@ export class AgentRuntime { if (await handleAbort()) return; this.eventBus.emit({ type: AgentEvent.TurnStart }); + const { toolMap, aiTools, hasTools, effectiveInstructions } = this.buildToolLoopContext( + staticLoopContext.aiProviderTools, + ); const messages = list.forLlm(effectiveInstructions, this.config.instructionProviderOptions); const result = streamText({ - model, + model: staticLoopContext.model, messages, abortSignal: this.eventBus.signal, ...(hasTools ? { tools: aiTools } : {}), - ...(providerOptions - ? { providerOptions: providerOptions as Record } + ...(staticLoopContext.providerOptions + ? { providerOptions: staticLoopContext.providerOptions as Record } : {}), - ...(outputSpec ? { output: outputSpec } : {}), + ...(staticLoopContext.outputSpec ? { output: staticLoopContext.outputSpec } : {}), ...this.buildTelemetryOptions(options), }); @@ -1301,7 +1320,7 @@ export class AgentRuntime { list.addResponse(newMessages); if (aiFinishReason !== 'tool-calls') { - if (outputSpec) { + if (staticLoopContext.outputSpec) { structuredOutput = await result.output; } this.emitTurnEnd(newMessages, extractSettledToolCalls(newMessages)); @@ -1311,7 +1330,14 @@ export class AgentRuntime { const toolCalls = await result.toolCalls; try { - const batch = await this.iterateToolCallsConcurrent({ ...toolCtx, toolCalls }); + const batch = await this.iterateToolCallsConcurrent({ + toolMap, + list, + runId, + telemetry: runTelemetry, + executionCounter: options?.executionCounter, + toolCalls, + }); if (await handleAbort()) return; @@ -1985,28 +2011,55 @@ export class AgentRuntime { }; } - /** Build common LLM call dependencies shared by both the generate and stream loops. */ - private buildLoopContext( + /** Build run-stable LLM call dependencies shared by all iterations. */ + private buildStaticLoopContext( execOptions?: ExecutionOptions & { persistence?: AgentPersistenceOptions }, ) { - const allUserTools = this.config.tools ?? []; - const aiTools = toAiSdkTools(allUserTools); const aiProviderTools = toAiSdkProviderTools(this.config.providerTools); - const allTools = { ...aiTools, ...aiProviderTools }; const model = createModel(this.config.model); return { model, - toolMap: buildToolMap(allUserTools), - aiTools: allTools, + aiProviderTools, providerOptions: this.buildCallProviderOptions(execOptions?.providerOptions), - hasTools: Object.keys(allTools).length > 0, outputSpec: this.config.structuredOutput ? Output.object({ schema: this.config.structuredOutput }) : undefined, - effectiveInstructions: this.composeEffectiveInstructions(allUserTools), }; } + /** Build the current local tool view; deferred loads can change this between iterations. */ + private buildToolLoopContext(aiProviderTools: ReturnType) { + const allUserTools = this.getCurrentTools(); + const aiTools = toAiSdkTools(allUserTools); + const allTools = { ...aiTools, ...aiProviderTools }; + const aiToolCount = Object.keys(allTools).length; + const toolMap = buildToolMap(allUserTools); + const effectiveInstructions = this.composeEffectiveInstructions(allUserTools); + + return { + toolMap, + aiTools: allTools, + hasTools: aiToolCount > 0, + effectiveInstructions, + }; + } + + private getCurrentTools(): BuiltTool[] { + const baseTools = this.config.tools ?? []; + if (!this.deferredToolManager?.hasTools) return baseTools; + + return [ + ...baseTools, + ...this.deferredToolManager.getControllerTools(), + ...this.deferredToolManager.getLoadedTools(), + ]; + } + + private hydrateDeferredToolsFromList(list: AgentMessageList): void { + if (!this.deferredToolManager?.hasTools) return; + this.deferredToolManager.hydrateLoadedToolsFromMessages(list.serialize().messages); + } + /** * Merge tool-attached `systemInstruction` fragments into the agent's * configured instructions. Fragments are wrapped in a single diff --git a/packages/@n8n/agents/src/runtime/deferred-tool-manager.ts b/packages/@n8n/agents/src/runtime/deferred-tool-manager.ts new file mode 100644 index 00000000000..2f76c41bfac --- /dev/null +++ b/packages/@n8n/agents/src/runtime/deferred-tool-manager.ts @@ -0,0 +1,247 @@ +import { z } from 'zod'; + +import type { AgentDbMessage, ContentToolCall } from '../types/sdk/message'; +import type { BuiltTool } from '../types/sdk/tool'; + +export const SEARCH_TOOLS_TOOL_NAME = 'search_tools'; +export const LOAD_TOOL_TOOL_NAME = 'load_tool'; + +const DEFAULT_TOP_K = 5; + +const searchToolsInputSchema = z.object({ + query: z.string().min(1).describe('Keywords describing the capability or integration you need'), +}); + +const toolSummarySchema = z.object({ + name: z.string(), + description: z.string(), + loaded: z.boolean(), +}); + +const searchToolsOutputSchema = z.object({ + results: z.array(toolSummarySchema), +}); + +const loadToolInputSchema = z.object({ + toolName: z.string().min(1).describe('Exact tool name returned by search_tools'), +}); + +const loadToolOutputSchema = z.object({ + status: z.enum(['loaded', 'already_loaded', 'not_found']), + toolName: z.string(), + tool: toolSummarySchema.optional(), + candidates: z.array(toolSummarySchema).optional(), + message: z.string(), +}); + +type SearchToolsOutput = z.infer; +type ToolSummary = z.infer; +type LoadToolOutput = z.infer; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function tokenize(value: string): Set { + return new Set( + value + .toLowerCase() + .split(/[^a-z0-9_@./-]+/i) + .map((token) => token.trim()) + .filter(Boolean), + ); +} + +function scoreTool(tool: BuiltTool, query: string): number { + const normalizedQuery = query.toLowerCase().trim(); + const queryTokens = tokenize(query); + const name = tool.name.toLowerCase(); + const description = tool.description.toLowerCase(); + const nameTokens = tokenize(name); + const descriptionTokens = tokenize(description); + let score = 0; + + if (name === normalizedQuery) score += 100; + if (name.includes(normalizedQuery)) score += 40; + if (description.includes(normalizedQuery)) score += 15; + + for (const token of queryTokens) { + if (nameTokens.has(token)) score += 20; + if (name.includes(token)) score += 10; + if (descriptionTokens.has(token)) score += 8; + if (description.includes(token)) score += 4; + } + + return score; +} + +export interface DeferredToolManagerOptions { + topK?: number; +} + +export class DeferredToolManager { + private readonly toolsByName = new Map(); + + private readonly loadedToolNames = new Set(); + + private readonly topK: number; + + private readonly searchTool: BuiltTool; + + private readonly loadTool: BuiltTool; + + constructor(tools: BuiltTool[], options: DeferredToolManagerOptions = {}) { + for (const tool of tools) { + if (tool.name === SEARCH_TOOLS_TOOL_NAME || tool.name === LOAD_TOOL_TOOL_NAME) { + throw new Error(`Deferred tool name "${tool.name}" is reserved`); + } + if (this.toolsByName.has(tool.name)) { + throw new Error(`Duplicate deferred tool name "${tool.name}"`); + } + this.toolsByName.set(tool.name, tool); + } + + this.topK = options.topK ?? DEFAULT_TOP_K; + this.searchTool = this.createSearchTool(); + this.loadTool = this.createLoadTool(); + } + + get hasTools(): boolean { + return this.toolsByName.size > 0; + } + + get totalToolCount(): number { + return this.toolsByName.size; + } + + get loadedToolCount(): number { + return this.loadedToolNames.size; + } + + getControllerTools(): BuiltTool[] { + if (!this.hasTools) return []; + return [this.searchTool, this.loadTool]; + } + + getLoadedTools(): BuiltTool[] { + return Array.from(this.loadedToolNames) + .map((name) => this.toolsByName.get(name)) + .filter((tool): tool is BuiltTool => tool !== undefined); + } + + hydrateLoadedToolsFromMessages(messages: AgentDbMessage[]): void { + this.loadedToolNames.clear(); + + for (const message of messages) { + if (!('content' in message) || !Array.isArray(message.content)) continue; + for (const block of message.content) { + if (!this.isSuccessfulLoadToolCall(block)) continue; + const toolName = this.getLoadedToolNameFromOutput(block.output); + if (toolName && this.toolsByName.has(toolName)) { + this.loadedToolNames.add(toolName); + } + } + } + } + + private createSearchTool(): BuiltTool { + return { + name: SEARCH_TOOLS_TOOL_NAME, + description: + 'Search for additional tools that can be loaded when the current toolset is missing a capability.', + inputSchema: searchToolsInputSchema, + outputSchema: searchToolsOutputSchema, + handler: async (input) => { + const { query } = searchToolsInputSchema.parse(input); + return await Promise.resolve(this.search(query)); + }, + }; + } + + private createLoadTool(): BuiltTool { + return { + name: LOAD_TOOL_TOOL_NAME, + description: + 'Load a deferred tool by exact name. The tool becomes available on the next model turn and remains available for this conversation.', + inputSchema: loadToolInputSchema, + outputSchema: loadToolOutputSchema, + handler: async (input) => { + const { toolName } = loadToolInputSchema.parse(input); + return await Promise.resolve(this.load(toolName)); + }, + }; + } + + private search(query: string): SearchToolsOutput { + const scored = Array.from(this.toolsByName.values()) + .map((tool) => ({ + tool, + score: scoreTool(tool, query), + })) + .sort((left, right) => { + if (left.score !== right.score) return right.score - left.score; + return left.tool.name.localeCompare(right.tool.name); + }); + + const positiveMatches = scored.filter(({ score }) => score > 0); + const matches = positiveMatches.length > 0 ? positiveMatches : scored; + + return { + results: matches.slice(0, this.topK).map(({ tool }) => this.summarizeTool(tool)), + }; + } + + private load(toolName: string): LoadToolOutput { + const tool = this.toolsByName.get(toolName); + if (!tool) { + return { + status: 'not_found', + toolName, + candidates: this.search(toolName).results, + message: `Tool "${toolName}" was not found. Use search_tools to find the exact tool name.`, + }; + } + + if (this.loadedToolNames.has(toolName)) { + return { + status: 'already_loaded', + toolName, + tool: this.summarizeTool(tool), + message: `Tool "${toolName}" is already loaded.`, + }; + } + + this.loadedToolNames.add(toolName); + return { + status: 'loaded', + toolName, + tool: this.summarizeTool(tool), + message: `Tool "${toolName}" is loaded and will be available on the next model turn.`, + }; + } + + private summarizeTool(tool: BuiltTool): ToolSummary { + return { + name: tool.name, + description: tool.description, + loaded: this.loadedToolNames.has(tool.name), + }; + } + + private isSuccessfulLoadToolCall( + block: unknown, + ): block is Extract { + return ( + isRecord(block) && + block.type === 'tool-call' && + block.toolName === LOAD_TOOL_TOOL_NAME && + block.state === 'resolved' + ); + } + + private getLoadedToolNameFromOutput(output: unknown): string | undefined { + if (!isRecord(output)) return undefined; + if (output.status !== 'loaded' && output.status !== 'already_loaded') return undefined; + return typeof output.toolName === 'string' ? output.toolName : undefined; + } +} diff --git a/packages/@n8n/agents/src/sdk/agent.ts b/packages/@n8n/agents/src/sdk/agent.ts index 31ec26347c9..907fec65887 100644 --- a/packages/@n8n/agents/src/sdk/agent.ts +++ b/packages/@n8n/agents/src/sdk/agent.ts @@ -7,6 +7,7 @@ import { Memory } from './memory'; import { Telemetry } from './telemetry'; import { Tool, wrapToolForApproval } from './tool'; import { AgentRuntime } from '../runtime/agent-runtime'; +import { LOAD_TOOL_TOOL_NAME, SEARCH_TOOLS_TOOL_NAME } from '../runtime/deferred-tool-manager'; import { AgentEventBus } from '../runtime/event-bus'; import { hasObservationStore } from '../runtime/observation-store'; import { @@ -50,6 +51,12 @@ const DEFAULT_LAST_MESSAGES = 10; type ToolParameter = BuiltTool | { build(): BuiltTool }; +interface DeferredToolOptions { + search?: { + topK?: number; + }; +} + /** * Lightweight read-only view of an agent's configured state. * Returned by `Agent.snapshot` for testing and debugging purposes. @@ -61,7 +68,7 @@ export interface AgentSnapshot { model: { provider: string | null; name: string | null }; /** Instruction text passed to `.instructions()`, or null if not set. */ instructions: string | null; - /** Minimal description of each registered tool. */ + /** Minimal description of each directly registered tool. */ tools: ReadonlyArray<{ name: string; description: string | undefined }>; /** True when `.memory()` has been configured. */ hasMemory: boolean; @@ -100,6 +107,10 @@ export class Agent implements BuiltAgent, AgentBuilder { private tools: BuiltTool[] = []; + private deferredTools: BuiltTool[] = []; + + private deferredToolSearchTopK: number | undefined; + private providerTools: BuiltProviderTool[] = []; private memoryConfig?: MemoryConfig; @@ -191,6 +202,19 @@ export class Agent implements BuiltAgent, AgentBuilder { return this; } + /** Add tools that are searchable through `search_tools` and activated on demand with `load_tool`. */ + deferredTool(t: ToolParameter | ToolParameter[], options?: DeferredToolOptions): this { + const tools = Array.isArray(t) ? t : [t]; + for (const tool of tools) { + const built = 'build' in tool ? tool.build() : tool; + this.deferredTools.push(built); + } + if (options?.search?.topK !== undefined) { + this.deferredToolSearchTopK = options.search.topK; + } + return this; + } + /** Add a provider-defined tool (e.g. Anthropic web search, OpenAI code interpreter). */ providerTool(builtProviderTool: BuiltProviderTool): this { this.providerTools.push(builtProviderTool); @@ -737,6 +761,7 @@ export class Agent implements BuiltAgent, AgentBuilder { } const finalTools = [...this.tools]; + const configuredDeferredTools = [...this.deferredTools]; if (this.workspaceInstance) { const wsTools = this.workspaceInstance.getTools(); @@ -744,15 +769,21 @@ export class Agent implements BuiltAgent, AgentBuilder { } let finalStaticTools = finalTools; + let finalDeferredTools = configuredDeferredTools; if (this.requireToolApprovalValue) { finalStaticTools = finalTools.map((t) => t.suspendSchema ? t : wrapToolForApproval(t, { requireApproval: true }), ); + finalDeferredTools = configuredDeferredTools.map((t) => + t.suspendSchema ? t : wrapToolForApproval(t, { requireApproval: true }), + ); } // Validate checkpoint requirement from static tools and known MCP approval config // before attempting any network connections (allows fast failure). - const staticNeedsCheckpoint = finalStaticTools.some((t) => t.suspendSchema); + const staticNeedsCheckpoint = + finalStaticTools.some((t) => t.suspendSchema) || + finalDeferredTools.some((t) => t.suspendSchema); const mcpNeedsCheckpoint = (this.requireToolApprovalValue && this.mcpClients.length > 0) || this.mcpClients.some((c) => c.declaresApproval()); @@ -776,9 +807,30 @@ export class Agent implements BuiltAgent, AgentBuilder { ); } - // Detect collisions between MCP tools and static tools. + // Detect collisions between direct, deferred, and MCP tools. const staticNames = new Set(finalStaticTools.map((t) => t.name)); - const collisions = mcpTools.filter((t) => staticNames.has(t.name)).map((t) => t.name); + const reservedDeferredToolNames = new Set([SEARCH_TOOLS_TOOL_NAME, LOAD_TOOL_TOOL_NAME]); + const deferredNames = new Set(); + const deferredCollisions: string[] = []; + for (const tool of finalDeferredTools) { + if ( + staticNames.has(tool.name) || + reservedDeferredToolNames.has(tool.name) || + deferredNames.has(tool.name) + ) { + deferredCollisions.push(tool.name); + } + deferredNames.add(tool.name); + } + if (deferredCollisions.length > 0) { + throw new Error( + `Deferred tool name collision — the following tool names resolve to duplicates or reserved tools: ${deferredCollisions.join(', ')}`, + ); + } + + const collisions = mcpTools + .filter((t) => staticNames.has(t.name) || deferredNames.has(t.name)) + .map((t) => t.name); if (collisions.length > 0) { throw new Error( `MCP tool name collision — the following tool names resolve to duplicates: ${collisions.join(', ')}`, @@ -789,7 +841,8 @@ export class Agent implements BuiltAgent, AgentBuilder { // Validate checkpoint again after discovering actual MCP tools // (catches the case where MCP tools have suspendSchema after listing). - const allNeedCheckpoint = allTools.some((t) => t.suspendSchema); + const allNeedCheckpoint = + allTools.some((t) => t.suspendSchema) || finalDeferredTools.some((t) => t.suspendSchema); if (allNeedCheckpoint && !this.checkpointStore) { throw new Error( `Agent "${this.name}" has tools requiring approval or suspend/resume but no checkpoint storage. ` + @@ -813,6 +866,11 @@ export class Agent implements BuiltAgent, AgentBuilder { model: modelConfig, instructions, tools: allTools.length > 0 ? allTools : undefined, + deferredTools: finalDeferredTools.length > 0 ? finalDeferredTools : undefined, + toolSearch: + finalDeferredTools.length > 0 && this.deferredToolSearchTopK !== undefined + ? { topK: this.deferredToolSearchTopK } + : undefined, instructionProviderOptions: this.instructionProviderOpts, providerTools: this.providerTools.length > 0 ? this.providerTools : undefined, memory: this.memoryConfig?.memory, diff --git a/packages/@n8n/agents/src/types/sdk/agent-builder.ts b/packages/@n8n/agents/src/types/sdk/agent-builder.ts index 546241eb145..d62401ef9cf 100644 --- a/packages/@n8n/agents/src/types/sdk/agent-builder.ts +++ b/packages/@n8n/agents/src/types/sdk/agent-builder.ts @@ -17,6 +17,7 @@ export interface AgentBuilder { model(providerOrIdOrConfig: string | ModelConfig, modelName?: string): this; instructions(text: string): this; tool(t: BuiltTool | BuiltTool[]): this; + deferredTool(t: BuiltTool | BuiltTool[], options?: { search?: { topK?: number } }): this; providerTool(t: BuiltProviderTool): this; thinking(provider: string, config?: Record): this; toolCallConcurrency(n: number): this;