From 3584d15ddd2debcd2cd2e71d28ccabb047c3ee88 Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:40:51 +0000 Subject: [PATCH] refactor(core): Simplify agent runtime structure (no-changelog) (#39507) --- .../runtime/__tests__/agent-runtime.test.ts | 211 +-- .../__tests__/delegate-sub-agent-tool.test.ts | 12 +- .../agents/src/runtime/loop/agent-runtime.ts | 1091 +++++++------- .../src/runtime/loop/checkpoint-data.ts | 94 ++ .../src/runtime/loop/execution-counter.ts | 6 +- .../agents/src/runtime/loop/generate-sink.ts | 12 +- .../src/runtime/loop/run-output-sink.ts | 159 +-- .../src/runtime/loop/runtime-context.ts | 116 +- .../src/runtime/loop/runtime-helpers.ts | 2 +- .../agents/src/runtime/loop/stream-sink.ts | 226 +-- .../src/runtime/memory/memory-orchestrator.ts | 4 +- .../src/runtime/memory/tool-result-memory.ts | 74 + .../agents/src/runtime/model/raw-error.ts | 2 +- .../runtime/telemetry/runtime-telemetry.ts | 2 +- .../tools/delegate-sub-agent-runner.ts | 553 ++++++++ .../runtime/tools/delegate-sub-agent-tool.ts | 994 ++----------- .../agents/src/runtime/tools/tool-adapter.ts | 82 +- .../src/runtime/tools/tool-call-executor.ts | 1259 +++++------------ .../src/runtime/tools/tool-call-runner.ts | 520 +++++++ .../src/runtime/tools/tool-result-guard.ts | 96 +- .../agents/src/types/runtime/agent-loop.ts | 137 ++ .../agents/src/types/runtime/agent-runtime.ts | 96 ++ .../agents/src/types/runtime/delegation.ts | 301 ++++ .../src/types/runtime/tool-execution.ts | 144 ++ .../agents/src/types/sdk/agent-builder.ts | 2 +- 25 files changed, 3335 insertions(+), 2860 deletions(-) create mode 100644 packages/@n8n/agents/src/runtime/loop/checkpoint-data.ts create mode 100644 packages/@n8n/agents/src/runtime/memory/tool-result-memory.ts create mode 100644 packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-runner.ts create mode 100644 packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts create mode 100644 packages/@n8n/agents/src/types/runtime/agent-loop.ts create mode 100644 packages/@n8n/agents/src/types/runtime/agent-runtime.ts create mode 100644 packages/@n8n/agents/src/types/runtime/delegation.ts create mode 100644 packages/@n8n/agents/src/types/runtime/tool-execution.ts 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 83b449e3eb6..00128c327ce 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -2604,6 +2604,36 @@ function makeGenerateWithToolCalls( }; } +function makeStreamWithToolCalls( + toolCalls: Array<{ toolCallId: string; toolName: string; args: Record }>, +) { + return { + stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'calling tools...' }]), + finishReason: Promise.resolve('tool-calls'), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }), + response: Promise.resolve({ + messages: [ + { + role: 'assistant', + content: toolCalls.map((tc) => ({ + type: 'tool-call', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args: tc.args, + })), + }, + ], + }), + toolCalls: Promise.resolve( + toolCalls.map((tc) => ({ + toolCallId: tc.toolCallId, + toolName: tc.toolName, + input: tc.args, + })), + ), + }; +} + describe('AgentRuntime — deferred tool loading', () => { beforeEach(() => { generateText.mockReset(); @@ -5646,36 +5676,6 @@ describe('AgentRuntime — abort during a tool batch', () => { { toolCallId: 'tc-2', toolName: 'second_tool', args: {} }, ]; - function makeStreamWithToolCalls( - toolCalls: Array<{ toolCallId: string; toolName: string; args: Record }>, - ) { - return { - stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'calling tools...' }]), - finishReason: Promise.resolve('tool-calls'), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }), - response: Promise.resolve({ - messages: [ - { - role: 'assistant', - content: toolCalls.map((tc) => ({ - type: 'tool-call', - toolCallId: tc.toolCallId, - toolName: tc.toolName, - args: tc.args, - })), - }, - ], - }), - toolCalls: Promise.resolve( - toolCalls.map((tc) => ({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: tc.args, - })), - ), - }; - } - it('stream: resolves to "cancelled" (not "failed") and emits no Error event', async () => { const bus = new AgentEventBus(); const { tools, secondHandler } = makeAbortingToolPair(bus); @@ -8499,30 +8499,58 @@ describe('AgentRuntime.resume() with createCancellation() — manual handling (h }); // --------------------------------------------------------------------------- -// toModelOutput error resilience — processToolCall must never re-throw +// Tool output transform failures must not stop the agent loop. // --------------------------------------------------------------------------- -describe('AgentRuntime — toModelOutput error resilience', () => { +describe.each([ + { + name: 'toModelOutput', + transform: { + toModelOutput: () => { + throw new Error('Transform failed'); + }, + }, + }, + { + name: 'toMessage', + transform: { + toMessage: () => { + throw new Error('Transform failed'); + }, + }, + }, + { + name: 'async toMessage', + transform: { + toMessage: async () => await Promise.reject(new Error('Transform failed')), + }, + }, +])('AgentRuntime — $name error resilience', ({ transform }) => { + let events: AgentEventData[]; + let eventBus: AgentEventBus; beforeEach(() => { vi.clearAllMocks(); + generateText.mockReset(); + streamText.mockReset(); + events = []; + eventBus = new AgentEventBus(); + eventBus.on(AgentEvent.ToolExecutionEnd, (event) => events.push(event)); }); function makeTransformErrorTool(name = 'transform_tool'): BuiltTool { return { name, - description: 'A tool whose toModelOutput always throws', + description: 'A tool whose output transform fails', inputSchema: z.object({ value: z.string().optional() }), handler: async () => await Promise.resolve({ raw: 'data' }), - toModelOutput: () => { - throw new Error('toModelOutput failed'); - }, + ...transform, }; } function makeSuspendingTransformErrorTool(name = 'suspend_tool'): BuiltTool { return { name, - description: 'A suspending tool whose toModelOutput throws on the resumed call', + description: 'A tool whose output transform fails after resume', inputSchema: z.object({ value: z.string().optional() }), suspendSchema: z.object({ reason: z.string() }), resumeSchema: z.object({ approved: z.boolean() }), @@ -8533,15 +8561,13 @@ describe('AgentRuntime — toModelOutput error resilience', () => { } return { done: true }; }, - toModelOutput: () => { - throw new Error('toModelOutput failed on resume'); - }, + ...transform, }; } - it('generate(): toModelOutput throwing is treated as a tool error — loop continues', async () => { + it('generate(): records one failed tool completion and continues', async () => { const tool = makeTransformErrorTool(); - const { runtime } = createRuntimeWithTools([tool], 1); + const { runtime } = createRuntimeWithTools([tool], 1, eventBus); generateText .mockResolvedValueOnce( @@ -8555,34 +8581,17 @@ describe('AgentRuntime — toModelOutput error resilience', () => { expect(result.finishReason).toBe('stop'); // Two LLM calls: tool-call turn + follow-up after error expect(generateText).toHaveBeenCalledTimes(2); + expect(events).toEqual([expect.objectContaining({ toolCallId: 'tc-1', isError: true })]); }); - it('stream(): toModelOutput throwing surfaces as isError tool-result — loop continues', async () => { + it('stream(): records one failed tool completion and continues', async () => { const tool = makeTransformErrorTool(); - const { runtime } = createRuntimeWithTools([tool], 1); + const { runtime } = createRuntimeWithTools([tool], 1, eventBus); streamText - .mockReturnValueOnce({ - stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]), - finishReason: Promise.resolve('tool-calls'), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }), - response: Promise.resolve({ - messages: [ - { - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'tc-1', - toolName: 'transform_tool', - args: {}, - }, - ], - }, - ], - }), - toolCalls: Promise.resolve([{ toolCallId: 'tc-1', toolName: 'transform_tool', input: {} }]), - }) + .mockReturnValueOnce( + makeStreamWithToolCalls([{ toolCallId: 'tc-1', toolName: 'transform_tool', args: {} }]), + ) .mockReturnValueOnce(makeStreamSuccess('I see the tool errored')); const { stream } = await runtime.stream('run the tool'); @@ -8597,9 +8606,10 @@ describe('AgentRuntime — toModelOutput error resilience', () => { (c) => c.type === 'tool-result' && c.toolCallId === 'tc-1', ) as (StreamChunk & { type: 'tool-result' }) | undefined; expect(toolResultChunk?.isError).toBe(true); + expect(events).toEqual([expect.objectContaining({ toolCallId: 'tc-1', isError: true })]); }); - it('generate() resume: toModelOutput throwing in resumed tool call is captured — loop continues', async () => { + it('generate() resume: records the transform failure and continues', async () => { const tool = makeSuspendingTransformErrorTool(); const { runtime } = createRuntimeWithTools([tool], 1); @@ -8616,9 +8626,7 @@ describe('AgentRuntime — toModelOutput error resilience', () => { const { runId, toolCallId } = firstResult.pendingSuspend![0]; - // Resume: tool returns a result but toModelOutput throws - // Bug: without fix this propagates out of iteratePendingToolCallsConcurrent and - // causes generate() to return with finishReason 'error' instead of 'stop'. + // The tool returns a result, but its output transform fails. const resumeResult = await runtime.resume( 'generate', { approved: true }, @@ -8633,32 +8641,14 @@ describe('AgentRuntime — toModelOutput error resilience', () => { expect(generateText).toHaveBeenCalledTimes(2); }); - it('stream() resume: toModelOutput throwing in resumed tool call does not close the stream with error', async () => { + it('stream() resume: records the transform failure and continues', async () => { const tool = makeSuspendingTransformErrorTool(); const { runtime } = createRuntimeWithTools([tool], 1); // First stream: agent calls the tool and suspends - streamText.mockReturnValueOnce({ - stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'thinking...' }]), - finishReason: Promise.resolve('tool-calls'), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }), - response: Promise.resolve({ - messages: [ - { - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'tc-1', - toolName: 'suspend_tool', - args: {}, - }, - ], - }, - ], - }), - toolCalls: Promise.resolve([{ toolCallId: 'tc-1', toolName: 'suspend_tool', input: {} }]), - }); + streamText.mockReturnValueOnce( + makeStreamWithToolCalls([{ toolCallId: 'tc-1', toolName: 'suspend_tool', args: {} }]), + ); const firstResult = await runtime.stream('run the tool'); const firstChunks = await collectChunks(firstResult.stream); @@ -8668,8 +8658,7 @@ describe('AgentRuntime — toModelOutput error resilience', () => { | undefined; expect(suspendChunk).toBeDefined(); - // Resume: toModelOutput throws - // Bug: without fix this closes the stream with finishReason 'error' via closeStreamWithError. + // The failed transform must not close the resumed stream. streamText.mockReturnValueOnce(makeStreamSuccess('Handled the resume error')); const resumed = await runtime.resume( @@ -8985,13 +8974,29 @@ describe('AgentRuntime — untrusted tool outputs', () => { }); const { events } = await runToolCall(tool); + const modelOutput = getModelToolResultOutput(); - expect(getModelToolResultOutput()).toEqual({ + expect(modelOutput).toEqual({ type: 'error-text', value: '\nError: remote error</untrusted_data>\n', }); expect(events[0]).toMatchObject({ result: error, isError: true }); + + const { runtime } = createRuntimeWithTools([tool], 1); + streamText + .mockReturnValueOnce( + makeStreamWithToolCalls([{ toolCallId: 'tc-1', toolName: tool.name, args: {} }]), + ) + .mockReturnValueOnce(makeStreamSuccess()); + const { stream } = await runtime.stream('run'); + expect(await collectChunks(stream)).toContainEqual({ + type: 'tool-result', + toolCallId: 'tc-1', + toolName: tool.name, + output: modelOutput?.value, + isError: true, + }); }); it('keeps runtime-authored validation errors outside the data boundary', async () => { @@ -9248,13 +9253,29 @@ describe('AgentRuntime — oversized tool results', () => { (content): content is ContentToolCall => content.type === 'tool-call' && content.toolCallId === 'tc-error', ); - const envelope = parseEnvelope(toolCall?.state === 'rejected' ? toolCall.error : ''); + const errorText = toolCall?.state === 'rejected' ? toolCall.error : ''; + const envelope = parseEnvelope(errorText); expect(result.finishReason).toBe('stop'); expect(toolCall?.state).toBe('rejected'); await expectWithinTokenLimit(envelope); expect(envelope.head).toContain('ERROR_HEAD'); expect(envelope.tail).toContain('ERROR_TAIL'); + + const { runtime: streamRuntime } = createRuntimeWithTools([tool], 1); + streamText + .mockReturnValueOnce( + makeStreamWithToolCalls([{ toolCallId: 'tc-error', toolName: tool.name, args: {} }]), + ) + .mockReturnValueOnce(makeStreamSuccess()); + const { stream } = await streamRuntime.stream('run'); + expect(await collectChunks(stream)).toContainEqual({ + type: 'tool-result', + toolCallId: 'tc-error', + toolName: tool.name, + output: errorText, + isError: true, + }); }); it('bounds aggregate toMessage text while preserving file content', async () => { diff --git a/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts b/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts index e936d273320..88a9d6bf13d 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts @@ -1010,10 +1010,14 @@ describe('createDelegateSubAgentTool', () => { expect(runSubAgent.mock.calls[2]?.[0]).toMatchObject({ taskPath: '/root/research_api_0' }); }); - it('returns a failed output when the runner callback throws', async () => { + it.each([ + { error: new Error('Runner failed'), message: 'Runner failed' }, + { error: undefined, message: 'Unknown error' }, + { error: Symbol('failure'), message: 'Unknown error' }, + ])('reports the runner error: $error', async ({ error, message }) => { const events: AgentEventData[] = []; const tool = createDelegateSubAgentTool({ - runSubAgent: async () => await Promise.reject(new Error('Runner failed')), + runSubAgent: vi.fn().mockRejectedValue(error), }); await expect( @@ -1025,12 +1029,12 @@ describe('createDelegateSubAgentTool', () => { status: 'failed', taskPath: '/root/research_api_0', answer: '', - error: 'Runner failed', + error: message, }); expect(events[events.length - 1]).toMatchObject({ type: AgentEvent.SubAgentCompleted, status: 'failed', - error: 'Runner failed', + error: message, }); }); diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index aeb7bd5910d..3ffbb85c51a 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -1,13 +1,23 @@ import type { ProviderOptions } from '@ai-sdk/provider-utils'; import type { TelemetryOptions, ToolCallRepairFunction, ToolSet } from 'ai'; -import type { JSONSchema7 } from 'json-schema'; -import type { z } from 'zod'; +import { + buildCheckpointOptions, + markSuspendedToolCalls, + mergeResumeExecutionOptions, + mergeResumePersistence, + parseResumeData, +} from './checkpoint-data'; import { incrementMessageCount, incrementTokenCountFromUsage } from './execution-counter'; import { GenerateSink } from './generate-sink'; import { hydrateFileParts } from './hydrate-file-parts'; -import type { ModelCallContext, RunOutputSink, RunServices } from './run-output-sink'; -import { RuntimeContextBuilder } from './runtime-context'; +import type { + ModelCallContext, + ModelTurnResult, + RunOutputSink, + RunServices, +} from '../../types/runtime/agent-loop'; +import { RuntimeContextBuilder, type StaticLoopContext } from './runtime-context'; import { extractSettledToolCalls, formatMcpConnectionNote, @@ -18,141 +28,56 @@ import { normalizeInput, } from './runtime-helpers'; import { StreamSink } from './stream-sink'; -import { isCancellation } from '../../sdk/cancellation'; import { computeCost, getModelCost, type ModelCost } from '../../sdk/catalog'; -import type { RuntimeSkillSource } from '../../skills/types'; import type { - BuiltFileStore, - BuiltMemory, - BuiltProviderTool, BuiltTelemetry, BuiltTool, - CheckpointStore, - EpisodicMemoryConfig, FinishReason, GenerateResult, - McpConnectionFailedEvent, - ObservationalMemoryConfig, - ObservationLogMemoryConfig, PendingToolCall, RunOptions, - ReasoningLevel, SerializableAgentState, StreamChunk, StreamResult, - ThinkingConfig, - TitleGenerationConfig, TokenUsage, } from '../../types'; +import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime'; import { AgentEvent } from '../../types/runtime/event'; import type { AgentPersistenceOptions, ExecutionOptions, - ModelConfig, - PersistedExecutionOptions, - PromptCachingConfig, ResumeOptions, } from '../../types/sdk/agent'; import type { AgentMessage, ContentToolCall } from '../../types/sdk/message'; import { getModelIdString } from '../../utils/model'; -import { parseWithSchema } from '../../utils/parse'; -import { removeToolResultRun, type WorkspaceFilesystem } from '../../workspace'; +import { removeToolResultRun } from '../../workspace'; import { createFilteredLogger } from '../logger'; import { MemoryOrchestrator } from '../memory/memory-orchestrator'; -import type { ScopedMemoryTaskEvent } from '../memory/scoped-memory-task-runner'; import { generateThreadTitle } from '../memory/title-generation'; import { AgentMessageList, type SerializedMessageList } from '../model/message-list'; -import { supportsSplitSystemMessages, type FetchFn } from '../model/model-factory'; import { createModelTokenCounter } from '../model/model-token-counter'; -import { - applyRuntimeCacheBreakpoints, - buildInstructionPromptCacheOptions, - getEffectiveAnthropicCacheTtl, - mergeProviderOptions, -} from '../model/prompt-cache'; +import { getEffectiveAnthropicCacheTtl } from '../model/prompt-cache'; import { ActiveSkills } from '../skills/active-skills'; import { BackgroundTaskTracker } from '../state/background-task-tracker'; import { AgentEventBus, type AgentAbortScope } from '../state/event-bus'; import { generateRunId, RunStateManager, StaleResumeError } from '../state/run-state'; import { startStreamSession } from '../streaming/stream-session'; +import type { StreamWriterGuard } from '../streaming/stream-writer-guard'; import { RuntimeTelemetry } from '../telemetry/runtime-telemetry'; import { DeferredToolManager } from '../tools/deferred-tool-manager'; import { fixToolCall } from '../tools/fix-tool-call'; -import { - ToolCallExecutor, - type PendingResume, - type ToolBatchContext, - type ToolCallBatchResult, -} from '../tools/tool-call-executor'; +import { ToolCallExecutor } from '../tools/tool-call-executor'; +import type { + PendingResume, + ToolBatchContext, + ToolCallBatchResult, +} from '../../types/runtime/tool-execution'; -export interface VolatileInstructionsContext { - persistence?: AgentPersistenceOptions; -} - -export type VolatileInstructionsProvider = ( - context: VolatileInstructionsContext, -) => Promise; - -export interface AgentRuntimeConfig { - name: string; - model: ModelConfig; - /** - * Proxy-aware `fetch` used for all model calls in this runtime (main model and - * title generation). When unset, model construction falls back to the ambient - * HTTP_PROXY resolver. - */ - modelFetch?: FetchFn; - instructions: string; - skillSource?: RuntimeSkillSource; - instructionProviderOptions?: ProviderOptions; - tools?: BuiltTool[]; - deferredTools?: BuiltTool[]; - workspaceFilesystem?: WorkspaceFilesystem; - toolSearch?: { - topK?: number; - }; - providerTools?: BuiltProviderTool[]; - memory?: BuiltMemory; - /** Host store resolving file-reference content parts to bytes before LLM calls. */ - fileStore?: BuiltFileStore; - observationLog?: ObservationLogMemoryConfig; - observationalMemory?: ObservationalMemoryConfig; - episodicMemory?: EpisodicMemoryConfig; - structuredOutput?: z.ZodType | JSONSchema7; - checkpointStorage?: 'memory' | CheckpointStore; - thinking?: ThinkingConfig; - reasoning?: ReasoningLevel; - promptCaching?: PromptCachingConfig; - eventBus?: AgentEventBus; - /** Number of tool calls to execute concurrently. Default `1` (sequential). */ - toolCallConcurrency?: number; - titleGeneration?: TitleGenerationConfig; - telemetry?: BuiltTelemetry; - /** Existing run id to continue, used when resuming a suspended run. */ - runId?: string; - /** - * Pre-fetched model cost from the catalog. When provided, skips the per-run - * catalog fetch. Set once during Agent.build() and shared across per-run runtimes. - */ - modelCost?: ModelCost; - /** - * Shared RunStateManager for suspend/resume. When provided, per-run runtimes - * use the same store so resume() can find state from a prior run. - */ - runState?: RunStateManager; - /** Host callback for observational-memory background task lifecycle events. */ - onMemoryTaskEvent?: (event: ScopedMemoryTaskEvent) => void; - /** - * Per-server MCP connection failures recorded during `Agent.build()` when - * resolving MCP tools. Tools from these servers were skipped; the runtime - * surfaces each as a non-fatal `warning` stream chunk at the start of a - * stream so hosts can tell the user an MCP server was unavailable without - * aborting the run. - */ - mcpConnectionFailures?: McpConnectionFailedEvent[]; - /** The runtime loads these host instructions before each model call but does not save them. */ - volatileInstructionsProvider?: VolatileInstructionsProvider; -} +export type { + AgentRuntimeConfig, + VolatileInstructionsContext, + VolatileInstructionsProvider, +} from '../../types/runtime/agent-runtime'; const MAX_LOOP_ITERATIONS = 100; @@ -178,6 +103,24 @@ interface LoopContext { pendingResume?: PendingResume; } +interface PreparedLoopContext extends LoopContext { + staticContext: StaticLoopContext; + instructionProviderOptions: ProviderOptions | undefined; + runTelemetry: BuiltTelemetry | undefined; + canDiscardRejectedInput: boolean; +} + +interface LoopState { + totalUsage: TokenUsage | undefined; + lastFinishReason: FinishReason; + structuredOutput: unknown; + maxIterations: number; + iterationCount: number; + reachedStopCondition: boolean; +} + +type ToolBatchSettlement = { suspended: false } | { suspended: true; result: T }; + /** * Core agent execution engine using the Vercel AI SDK directly. * @@ -391,43 +334,10 @@ export class AgentRuntime { options: ResumeOptions & ExecutionOptions, ): Promise { this.runId = options.runId; - const state = await this.runState.resume(this.runId); - if (!state) { - throw new StaleResumeError(`No suspended run found for runId: ${this.runId}`); - } - - const toolCall = state.pendingToolCalls[options.toolCallId]; - if (!toolCall) { - throw new StaleResumeError(`No tool call found for toolCallId: ${options.toolCallId}`); - } - if (options.hostMetadata !== undefined && !state.persistence) { - throw new Error('Cannot update host metadata without persistence'); - } - - const list = AgentMessageList.deserialize(state.messageList); - this.context.hydrateDeferredToolsFromList(list); - await hydrateFileParts(list.messages(), this.config.fileStore, { - threadId: state.persistence?.threadId, - }); - - const tool = this.context - .getCurrentTools(state.persistence) - .find((t) => t.name === toolCall.toolName); - if (!tool) throw new Error(`Tool ${toolCall.toolName} not found`); - - let resumeData: unknown = data; + const { state, list, resumeData } = await this.prepareToolResume(data, options); let abortScope: AgentAbortScope | undefined; let resumeClaimed = false; - const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema; - if (!isCancellation(resumeData) && resumeSchema) { - const parseResult = await parseWithSchema(resumeSchema, data, { stripUnknown: true }); - if (!parseResult.success) { - throw new Error(`Invalid resume payload: ${parseResult.error}`); - } - resumeData = parseResult.data; - } - try { // Merge persisted execution options with fresh caller options const { @@ -437,25 +347,7 @@ export class AgentRuntime { hostMetadata, ...callerExecOptions } = options; - const persisted = state.executionOptions ?? {}; - const persistedMaxIterations = persisted.maxIterations; - const callerMaxIterations = callerExecOptions.maxIterations; - if ( - callerMaxIterations !== undefined && - persistedMaxIterations !== undefined && - callerMaxIterations < persistedMaxIterations - ) { - throw new Error( - `Cannot decrease maxIterations when resuming a run. Expected >= ${persistedMaxIterations}, received ${callerMaxIterations}.`, - ); - } - - const mergedMaxIterations = callerMaxIterations ?? persistedMaxIterations; - const mergedExecOptions: ExecutionOptions & { iterationCount?: number } = { - ...callerExecOptions, - ...(mergedMaxIterations !== undefined ? { maxIterations: mergedMaxIterations } : {}), - ...(state.iterationCount !== undefined ? { iterationCount: state.iterationCount } : {}), - }; + const mergedExecOptions = mergeResumeExecutionOptions(state, callerExecOptions); const claimed = await this.runState.claimResume(this.runId, state); if (!claimed) { @@ -463,14 +355,7 @@ export class AgentRuntime { } resumeClaimed = true; const resumeOptions: RuntimeExecutionOptions = { - persistence: state.persistence - ? { - ...state.persistence, - ...(state.persistence.hostMetadata || hostMetadata - ? { hostMetadata: { ...state.persistence.hostMetadata, ...hostMetadata } } - : {}), - } - : undefined, + persistence: mergeResumePersistence(state.persistence, hostMetadata), ...mergedExecOptions, }; this.updateState({ persistence: resumeOptions.persistence }); @@ -485,68 +370,18 @@ export class AgentRuntime { resumeData, }; - await this.ensureModelCost(); + await this.prepareResumeMemory(list, state.persistence); - await this.memory.setListObservationLogMemory(list, state.persistence); - // The mask boundary is runtime-only state: re-derive it from the - // persisted cursor so a run that compacted mid-run before suspending - // does not resume with the full pre-compaction window. - await this.memory.applyObservationMask(list, state.persistence); - - if (method === 'generate') { - const sink = new GenerateSink(this.createRunServices()); - const rawResult = await this.telemetry.withRootSpan( - 'generate', - resumeOptions, - this.runId, - async () => - await this.runAgentLoop( - { - list, - options: resumeOptions, - abortScope: activeAbortScope, - pendingResume, - }, - sink, - ), - ); - try { - return this.finalizeGenerate(rawResult, list); - } finally { - abortScope.dispose(); - } - } - - return { - runId: this.runId, - stream: this.startStream({ - list, - options: resumeOptions, - abortScope: activeAbortScope, - pendingResume, - }), - getState: () => this.getState(), + const ctx: LoopContext = { + list, + options: resumeOptions, + abortScope: activeAbortScope, + pendingResume, }; + if (method === 'generate') return await this.generateResumedRun(ctx); + return this.createResumedStream(ctx); } catch (error) { - const isAbort = abortScope?.isAborted ?? false; - abortScope?.dispose(); - if (error instanceof StaleResumeError) throw error; - - this.updateState({ status: isAbort ? 'cancelled' : 'failed' }); - if (!isAbort) { - this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); - } - if (resumeClaimed) await this.cleanupRun(); - if (method === 'generate') { - return { - runId: this.runId, - messages: [], - finishReason: 'error' as const, - error, - getState: () => this.getState(), - }; - } - return { runId: this.runId, stream: makeErrorStream(error), getState: () => this.getState() }; + return await this.handleResumeFailure(method, error, abortScope, resumeClaimed); } } @@ -564,6 +399,146 @@ export class AgentRuntime { options: { runId: string; contextNotes?: string[] } & ExecutionOptions, ): Promise { this.runId = options.runId; + const state = await this.loadStepCheckpoint(); + + const list = await this.restoreCheckpointMessages(state); + + let abortScope: AgentAbortScope | undefined; + try { + const { runId: _rid, contextNotes, ...callerExecOptions } = options; + const resumeOptions: RuntimeExecutionOptions = { + persistence: state.persistence, + ...mergeResumeExecutionOptions(state, callerExecOptions), + }; + + for (const note of contextNotes ?? []) { + list.addInput([{ role: 'user', content: [{ type: 'text', text: note }] }]); + } + + abortScope = this.eventBus.createAbortScope(resumeOptions.abortSignal); + const activeAbortScope = abortScope; + + await this.prepareResumeMemory(list, state.persistence); + + return this.createResumedStream({ + list, + options: resumeOptions, + abortScope: activeAbortScope, + }); + } catch (error) { + const isAbort = abortScope?.isAborted ?? false; + abortScope?.dispose(); + this.updateState({ status: isAbort ? 'cancelled' : 'failed' }); + if (!isAbort) { + this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); + } + return { runId: this.runId, stream: makeErrorStream(error), getState: () => this.getState() }; + } + } + + // --- Private --- + + private async prepareToolResume(data: unknown, options: ResumeOptions & ExecutionOptions) { + const state = await this.runState.resume(this.runId); + if (!state) { + throw new StaleResumeError(`No suspended run found for runId: ${this.runId}`); + } + + const toolCall = state.pendingToolCalls[options.toolCallId]; + if (!toolCall) { + throw new StaleResumeError(`No tool call found for toolCallId: ${options.toolCallId}`); + } + if (options.hostMetadata !== undefined && !state.persistence) { + throw new Error('Cannot update host metadata without persistence'); + } + + const list = await this.restoreCheckpointMessages(state); + + const tool = this.context + .getCurrentTools(state.persistence) + .find((t) => t.name === toolCall.toolName); + if (!tool) throw new Error(`Tool ${toolCall.toolName} not found`); + + const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema; + const resumeData = await parseResumeData(data, resumeSchema); + return { state, list, resumeData }; + } + + private async handleResumeFailure( + method: 'generate' | 'stream', + error: unknown, + abortScope: AgentAbortScope | undefined, + resumeClaimed: boolean, + ): Promise { + const isAbort = abortScope?.isAborted ?? false; + abortScope?.dispose(); + if (error instanceof StaleResumeError) throw error; + + this.updateState({ status: isAbort ? 'cancelled' : 'failed' }); + if (!isAbort) { + this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); + } + if (resumeClaimed) await this.cleanupRun(); + if (method === 'generate') { + return { + runId: this.runId, + messages: [], + finishReason: 'error' as const, + error, + getState: () => this.getState(), + }; + } + return { runId: this.runId, stream: makeErrorStream(error), getState: () => this.getState() }; + } + + private async restoreCheckpointMessages( + state: SerializableAgentState, + ): Promise { + const list = AgentMessageList.deserialize(state.messageList); + this.context.hydrateDeferredToolsFromList(list); + await hydrateFileParts(list.messages(), this.config.fileStore, { + threadId: state.persistence?.threadId, + }); + return list; + } + + private async prepareResumeMemory( + list: AgentMessageList, + persistence: AgentPersistenceOptions | undefined, + ): Promise { + await this.ensureModelCost(); + + await this.memory.setListObservationLogMemory(list, persistence); + // The mask boundary is runtime-only state: re-derive it from the + // persisted cursor so a run that compacted mid-run before suspending + // does not resume with the full pre-compaction window. + await this.memory.applyObservationMask(list, persistence); + } + + private async generateResumedRun(ctx: LoopContext): Promise { + const sink = new GenerateSink(this.createRunServices()); + const rawResult = await this.telemetry.withRootSpan( + 'generate', + ctx.options, + this.runId, + async () => await this.runAgentLoop(ctx, sink), + ); + try { + return this.finalizeGenerate(rawResult, ctx.list); + } finally { + ctx.abortScope.dispose(); + } + } + + private createResumedStream(ctx: LoopContext): StreamResult { + return { + runId: this.runId, + stream: this.startStream(ctx), + getState: () => this.getState(), + }; + } + + private async loadStepCheckpoint(): Promise { const state = await this.runState.loadForCrashResume(this.runId); if (!state) throw new Error(`No checkpoint found for runId: ${this.runId}`); if (state.status !== 'running') { @@ -579,69 +554,9 @@ export class AgentRuntime { `Checkpoint for runId ${this.runId} has pending tool calls — crashResume only accepts step checkpoints`, ); } - - const list = AgentMessageList.deserialize(state.messageList); - this.context.hydrateDeferredToolsFromList(list); - await hydrateFileParts(list.messages(), this.config.fileStore, { - threadId: state.persistence?.threadId, - }); - - let abortScope: AgentAbortScope | undefined; - try { - const { runId: _rid, contextNotes, ...callerExecOptions } = options; - const persisted = state.executionOptions ?? {}; - const persistedMaxIterations = persisted.maxIterations; - const callerMaxIterations = callerExecOptions.maxIterations; - if ( - callerMaxIterations !== undefined && - persistedMaxIterations !== undefined && - callerMaxIterations < persistedMaxIterations - ) { - throw new Error( - `Cannot decrease maxIterations when resuming a run. Expected >= ${persistedMaxIterations}, received ${callerMaxIterations}.`, - ); - } - const mergedMaxIterations = callerMaxIterations ?? persistedMaxIterations; - const resumeOptions: RuntimeExecutionOptions = { - persistence: state.persistence, - ...callerExecOptions, - ...(mergedMaxIterations !== undefined ? { maxIterations: mergedMaxIterations } : {}), - ...(state.iterationCount !== undefined ? { iterationCount: state.iterationCount } : {}), - }; - - for (const note of contextNotes ?? []) { - list.addInput([{ role: 'user', content: [{ type: 'text', text: note }] }]); - } - - abortScope = this.eventBus.createAbortScope(resumeOptions.abortSignal); - const activeAbortScope = abortScope; - - await this.ensureModelCost(); - await this.memory.setListObservationLogMemory(list, state.persistence); - await this.memory.applyObservationMask(list, state.persistence); - - return { - runId: this.runId, - stream: this.startStream({ - list, - options: resumeOptions, - abortScope: activeAbortScope, - }), - getState: () => this.getState(), - }; - } catch (error) { - const isAbort = abortScope?.isAborted ?? false; - abortScope?.dispose(); - this.updateState({ status: isAbort ? 'cancelled' : 'failed' }); - if (!isAbort) { - this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); - } - return { runId: this.runId, stream: makeErrorStream(error), getState: () => this.getState() }; - } + return state; } - // --- Private --- - /** * Build an AgentMessageList for the current turn: * - Loads memory history (if configured) and adds as historySet @@ -793,36 +708,75 @@ export class AgentRuntime { * those throws into their terminal contract. */ private async runAgentLoop(ctx: LoopContext, sink: RunOutputSink): Promise { - const { list, options, abortScope, pendingResume } = ctx; + const { prepared, state } = await this.prepareLoop(ctx); + if (prepared.pendingResume) { + const settlement = await this.resumePendingTools( + prepared, + sink, + state, + prepared.pendingResume, + ); + if (settlement.suspended) return settlement.result; + } + + for (; state.iterationCount < state.maxIterations; state.iterationCount++) { + const settlement = await this.runLoopIteration(prepared, sink, state); + if (settlement.suspended) return settlement.result; + if (state.reachedStopCondition) break; + } + + if (!state.reachedStopCondition && state.iterationCount >= state.maxIterations) { + state.lastFinishReason = 'max-iterations'; + } + return await sink.finishComplete({ + list: ctx.list, + options: ctx.options, + finishReason: state.lastFinishReason, + usage: state.totalUsage, + structuredOutput: state.structuredOutput, + }); + } + + private async prepareLoop(ctx: LoopContext): Promise<{ + prepared: PreparedLoopContext; + state: LoopState; + }> { + const { list, options } = ctx; await this.activeSkills?.restore(list, options?.persistence); this.context.hydrateDeferredToolsFromList(list); - // Inject a model-facing note for any MCP servers that failed to connect - // during build(). The agent can mention the outage to the user when - // relevant; the note is system-message only and never persisted. + // This note reaches the model but is not stored in conversation history. list.mcpConnectionNote = formatMcpConnectionNote(this.config.mcpConnectionFailures ?? []); - let totalUsage: TokenUsage | undefined; - let lastFinishReason: FinishReason = 'stop'; - let structuredOutput: unknown; const runTelemetry = this.telemetry.resolve(options); - const staticLoopContext = this.context.buildStaticLoopContext({ + const staticContext = this.context.buildStaticLoopContext({ ...options, persistence: options?.persistence, }); - // Anthropic cache breakpoints are Anthropic-only and don't change across - // iterations, so compute once. Explicit .instructions() providerOptions win. - const instructionProviderOptions = mergeProviderOptions( - buildInstructionPromptCacheOptions(this.config.promptCaching, this.modelIdString), - this.config.instructionProviderOptions, - ); - const maxIterations = options?.maxIterations ?? MAX_LOOP_ITERATIONS; - let iterationCount = options?.iterationCount ?? 0; - let reachedStopCondition = false; - const inputMessages = new Set(list.inputDelta()); - const inputIds = new Set([...inputMessages].map((message) => message.id)); + const instructionProviderOptions = this.context.buildInstructionProviderOptions(); + const state: LoopState = { + totalUsage: undefined, + lastFinishReason: 'stop', + structuredOutput: undefined, + maxIterations: options?.maxIterations ?? MAX_LOOP_ITERATIONS, + iterationCount: options?.iterationCount ?? 0, + reachedStopCondition: false, + }; + return { + prepared: { + ...ctx, + runTelemetry, + staticContext, + instructionProviderOptions, + canDiscardRejectedInput: this.canDiscardRejectedInput(ctx), + }, + state, + }; + } - // Can we discard an input in case of an error caused by its attachment - const canDiscardRejectedInput = + private canDiscardRejectedInput(ctx: LoopContext): boolean { + const inputMessages = new Set(ctx.list.inputDelta()); + const inputIds = new Set([...inputMessages].map((message) => message.id)); + return ( ctx.isFreshRun === true && [...inputMessages].some( (message) => @@ -831,237 +785,251 @@ export class AgentRuntime { Array.isArray(message.content) && message.content.some((part) => part.type === 'file' && part.data !== undefined), ) && - !list.messages().some((message) => !inputMessages.has(message) && inputIds.has(message.id)); + !ctx.list + .messages() + .some((message) => !inputMessages.has(message) && inputIds.has(message.id)) + ); + } - const buildToolBatchContext = (toolMap: Map): ToolBatchContext => ({ + private buildToolBatchContext( + ctx: PreparedLoopContext, + toolMap: Map, + ): ToolBatchContext { + return { toolMap, - list, + list: ctx.list, runId: this.runId, - persistence: options?.persistence, - telemetry: runTelemetry, - executionCounter: options?.executionCounter, - abortSignal: abortScope.signal, - isAborted: () => abortScope.isAborted, - }); - const finishToolBatch = async ( - batch: ToolCallBatchResult, - toolMap: Map, - nextIteration: number, - ) => { - const hasPending = Object.keys(batch.pending).length > 0; - let completed = false; - try { - this.assertNotAborted(abortScope); - await sink.emitToolBatch(batch); - this.assertNotAborted(abortScope); - if (!hasPending) { - completed = true; - return { suspended: false as const }; - } - - await this.persistSuspension( - batch.pending, - options, - list, - totalUsage, - maxIterations, - nextIteration, - ); - this.assertNotAborted(abortScope); - const result = await sink.finishSuspended({ - suspendRunId: this.runId, - list, - usage: totalUsage, - suspensions: batch.suspensions, - }); - this.assertNotAborted(abortScope); - completed = true; - return { suspended: true as const, result }; - } finally { - if (!completed && hasPending) { - await this.toolExecutor.cleanupPendingToolCalls( - batch.pending, - buildToolBatchContext(toolMap), - abortScope.isAborted ? 'Run aborted' : 'Parent run failed before suspension', - ); - try { - await this.runState.cancel(this.runId, this.getState()); - } catch { - // Preserve the failure that interrupted suspension finalization. - } - } - } + persistence: ctx.options?.persistence, + telemetry: ctx.runTelemetry, + executionCounter: ctx.options?.executionCounter, + abortSignal: ctx.abortScope.signal, + isAborted: () => ctx.abortScope.isAborted, }; + } - if (pendingResume) { - const pendingLoopContext = this.context.buildToolLoopContext( - staticLoopContext.aiProviderTools, - options?.persistence, - options?.executionCounter, - list, - ); - const batch = await this.toolExecutor.iteratePendingToolCallsConcurrent({ - ...buildToolBatchContext(pendingLoopContext.toolMap), - pendingResume, - }); - const finalized = await finishToolBatch(batch, pendingLoopContext.toolMap, iterationCount); - if (finalized.suspended) return finalized.result; - // The resumed batch is a clean boundary too: its tool results are new - // content no earlier boundary saw, so check before the next model call. - await this.memory.maybeObserveMidRun(list, options); - } - - for (; iterationCount < maxIterations; iterationCount++) { - this.assertNotAborted(abortScope); - - this.eventBus.emit({ type: AgentEvent.TurnStart }); - - for (const toolName of this.activeSkills?.toolDependencies() ?? []) { - this.deferredToolManager?.load(toolName); - } - - const { - toolMap, - aiTools, - hasTools, - effectiveInstructions, - volatileInstructions, - staticToolCacheName, - } = this.context.buildToolLoopContext( - staticLoopContext.aiProviderTools, - options?.persistence, - options?.executionCounter, - list, - ); - const hostVolatileInstructions = await this.resolveVolatileInstructions(options?.persistence); - const combinedVolatileInstructions = [volatileInstructions, hostVolatileInstructions] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)) - .join('\n\n'); - const { system, messages } = list.forLlm( - // Skill content changes only on activation. Keep it cached when memory compacts. - [effectiveInstructions, this.activeSkills?.instructions()] - .filter(Boolean) - .join('\n\n'), - instructionProviderOptions, - combinedVolatileInstructions || undefined, - supportsSplitSystemMessages(this.config.model), - ); - // Runtime breakpoints (conversation history, static tools) are per-call - // only — never persisted back to the message list or tool set. - const cached = applyRuntimeCacheBreakpoints({ - system, - messages: this.activeSkills?.modelMessages(messages, list) ?? messages, - aiTools, - promptCaching: this.config.promptCaching, - modelId: this.modelIdString, - staticToolCacheName, - }); - - const modelCallContext: ModelCallContext = { - model: staticLoopContext.model, - system, - messages: cached.messages, - abortSignal: abortScope.signal, - hasTools, - aiTools: cached.aiTools, - reasoning: staticLoopContext.reasoning, - providerOptions: staticLoopContext.providerOptions, - outputSpec: staticLoopContext.outputSpec, - maxOutputTokens: staticLoopContext.maxOutputTokens, - aiSdkOptions: this.buildAiSdkOptions(toolMap, options), - onInputRejected: - canDiscardRejectedInput && iterationCount === 0 - ? async () => { - if (abortScope.isAborted) return; - await this.memory.discardRejectedInput(list, options); - this.updateState({ messageList: list.serialize() }); - } - : undefined, - }; - let turn = await sink.callModel(modelCallContext); - - // Some providers occasionally return a `stop` turn with no output at - // all mid-task, which would silently end the run with work half-done. - // Retry the call a bounded number of times before accepting the empty - // turn; each discarded attempt still bills its usage. - for ( - let emptyRetry = 0; - emptyRetry < MAX_EMPTY_TURN_RETRIES && isEmptyModelTurn(turn); - emptyRetry++ - ) { - totalUsage = mergeUsage(totalUsage, turn.usage); - incrementTokenCountFromUsage(options?.executionCounter, turn.usage); - // Publish before the abort check so a cancel between the empty attempt - // and the retry still bills those tokens via getTerminalFinish(). - sink.reportUsage(totalUsage); - this.assertNotAborted(abortScope); - turn = await sink.callModel(modelCallContext); - } - - // Fold the just-finished turn's usage in before the abort check so a - // stop that lands right after the model call still bills its tokens. - totalUsage = mergeUsage(totalUsage, turn.usage); - incrementTokenCountFromUsage(options?.executionCounter, turn.usage); - sink.reportUsage(totalUsage); - - this.assertNotAborted(abortScope); - - lastFinishReason = turn.finishReason; - if (!isReasoningOnlyStop(turn)) list.addResponse(turn.newMessages); - // The turn is now in the list; drop any retained streamed text so a later - // abort's snapshot can't duplicate it (a stop before this point recovers it). - sink.onTurnFolded?.(); - - if (turn.aiFinishReason !== 'tool-calls') { - // A rejected/filtered request (e.g. a provider prompt safety block) - // surfaces as an output-less turn instead of an SDK error — throw so - // the failure reaches the caller rather than ending the run silently. - if (turn.errorReason) throw new Error(turn.errorReason.message); - structuredOutput = turn.structuredOutput; - this.emitTurnEnd(turn.newMessages, extractSettledToolCalls(turn.newMessages)); - reachedStopCondition = true; - break; - } - - const batch = await this.toolExecutor.iterateToolCallsConcurrent({ - ...buildToolBatchContext(toolMap), - toolCalls: turn.toolCalls, - }); - const finalized = await finishToolBatch(batch, toolMap, iterationCount + 1); - if (finalized.suspended) return finalized.result; - - // Emit TurnEnd after all tool calls in this iteration are processed - this.emitTurnEnd(turn.newMessages, extractSettledToolCalls(list.responseDelta())); - - // Clean loop boundary: all tool calls settled. Mid-run observation - // may compact the LLM window here before the next call. - await this.memory.maybeObserveMidRun(list, options); - - // Step boundary reached with nothing pending: durably checkpoint so a - // crash before the next model call loses only the in-flight step. - if (options?.stepCheckpoints) { - await this.persistStepCheckpoint( - list, - totalUsage, - options, - maxIterations, - iterationCount + 1, - ); - } - } - - if (!reachedStopCondition && iterationCount >= maxIterations) { - lastFinishReason = 'max-iterations'; - } - - return await sink.finishComplete({ - list, - options, - finishReason: lastFinishReason, - usage: totalUsage, - structuredOutput, + private async resumePendingTools( + ctx: PreparedLoopContext, + sink: RunOutputSink, + state: LoopState, + pendingResume: PendingResume, + ): Promise> { + const { toolMap } = this.context.buildToolLoopContext( + ctx.staticContext.aiProviderTools, + ctx.options?.persistence, + ctx.options?.executionCounter, + ctx.list, + ); + const batch = await this.toolExecutor.iteratePendingToolCallsConcurrent({ + ...this.buildToolBatchContext(ctx, toolMap), + pendingResume, }); + const settlement = await this.finishToolBatch( + ctx, + sink, + state, + batch, + toolMap, + state.iterationCount, + ); + if (settlement.suspended) return settlement; + // Resumed tool results form a new observation boundary before the next model call. + await this.memory.maybeObserveMidRun(ctx.list, ctx.options); + return settlement; + } + + private async runLoopIteration( + ctx: PreparedLoopContext, + sink: RunOutputSink, + state: LoopState, + ): Promise> { + this.assertNotAborted(ctx.abortScope); + this.eventBus.emit({ type: AgentEvent.TurnStart }); + const { toolMap, modelCallContext } = await this.prepareModelCall(ctx, state.iterationCount); + const turn = await this.callModelWithRetries(ctx, sink, state, modelCallContext); + this.assertNotAborted(ctx.abortScope); + + state.lastFinishReason = turn.finishReason; + if (!isReasoningOnlyStop(turn)) ctx.list.addResponse(turn.newMessages); + // Drop retained stream text only after the response is in the list. + sink.onTurnFolded?.(); + + if (turn.aiFinishReason !== 'tool-calls') { + if (turn.errorReason) throw new Error(turn.errorReason.message); + state.structuredOutput = turn.structuredOutput; + this.emitTurnEnd(turn.newMessages, extractSettledToolCalls(turn.newMessages)); + state.reachedStopCondition = true; + return { suspended: false }; + } + + const batch = await this.toolExecutor.iterateToolCallsConcurrent({ + ...this.buildToolBatchContext(ctx, toolMap), + toolCalls: turn.toolCalls, + }); + const settlement = await this.finishToolBatch( + ctx, + sink, + state, + batch, + toolMap, + state.iterationCount + 1, + ); + if (settlement.suspended) return settlement; + await this.completeToolTurn(ctx, state, turn); + return settlement; + } + + private async prepareModelCall(ctx: PreparedLoopContext, iterationCount: number) { + const { list, options, abortScope, staticContext } = ctx; + for (const toolName of this.activeSkills?.toolDependencies() ?? []) { + this.deferredToolManager?.load(toolName); + } + const tools = this.context.buildToolLoopContext( + staticContext.aiProviderTools, + options?.persistence, + options?.executionCounter, + list, + ); + const hostVolatileInstructions = await this.resolveVolatileInstructions(options?.persistence); + const prompt = this.context.buildModelPrompt({ + list, + tools, + instructionProviderOptions: ctx.instructionProviderOptions, + hostVolatileInstructions, + activeSkills: this.activeSkills, + }); + const modelCallContext: ModelCallContext = { + model: staticContext.model, + system: prompt.system, + messages: prompt.messages, + abortSignal: abortScope.signal, + hasTools: tools.hasTools, + aiTools: prompt.aiTools, + reasoning: staticContext.reasoning, + providerOptions: staticContext.providerOptions, + outputSpec: staticContext.outputSpec, + maxOutputTokens: staticContext.maxOutputTokens, + aiSdkOptions: this.buildAiSdkOptions(tools.toolMap, options), + onInputRejected: this.createInputRejectionHandler(ctx, iterationCount), + }; + return { toolMap: tools.toolMap, modelCallContext }; + } + + private createInputRejectionHandler( + ctx: PreparedLoopContext, + iterationCount: number, + ): ModelCallContext['onInputRejected'] { + if (!ctx.canDiscardRejectedInput || iterationCount !== 0) return undefined; + return async () => { + if (ctx.abortScope.isAborted) return; + await this.memory.discardRejectedInput(ctx.list, ctx.options); + this.updateState({ messageList: ctx.list.serialize() }); + }; + } + + private async callModelWithRetries( + ctx: PreparedLoopContext, + sink: RunOutputSink, + state: LoopState, + modelCallContext: ModelCallContext, + ): Promise { + let turn = await sink.callModel(modelCallContext); + // Retry empty stop turns. Charge each attempt before checking for cancellation. + for (let retry = 0; retry < MAX_EMPTY_TURN_RETRIES && isEmptyModelTurn(turn); retry++) { + this.recordTurnUsage(ctx, sink, state, turn); + this.assertNotAborted(ctx.abortScope); + turn = await sink.callModel(modelCallContext); + } + this.recordTurnUsage(ctx, sink, state, turn); + return turn; + } + + private recordTurnUsage( + ctx: PreparedLoopContext, + sink: RunOutputSink, + state: LoopState, + turn: ModelTurnResult, + ): void { + state.totalUsage = mergeUsage(state.totalUsage, turn.usage); + incrementTokenCountFromUsage(ctx.options?.executionCounter, turn.usage); + sink.reportUsage(state.totalUsage); + } + + private async finishToolBatch( + ctx: PreparedLoopContext, + sink: RunOutputSink, + state: LoopState, + batch: ToolCallBatchResult, + toolMap: Map, + nextIteration: number, + ): Promise> { + const { list, options, abortScope } = ctx; + const hasPending = Object.keys(batch.pending).length > 0; + let completed = false; + try { + this.assertNotAborted(abortScope); + await sink.emitToolBatch(batch); + this.assertNotAborted(abortScope); + if (!hasPending) { + completed = true; + return { suspended: false }; + } + await this.persistSuspension( + batch.pending, + options, + list, + state.totalUsage, + state.maxIterations, + nextIteration, + ); + this.assertNotAborted(abortScope); + const result = await sink.finishSuspended({ + suspendRunId: this.runId, + list, + usage: state.totalUsage, + suspensions: batch.suspensions, + }); + this.assertNotAborted(abortScope); + completed = true; + return { suspended: true, result }; + } finally { + if (!completed && hasPending) await this.cleanupFailedSuspension(ctx, batch.pending, toolMap); + } + } + + private async cleanupFailedSuspension( + ctx: PreparedLoopContext, + pending: Record, + toolMap: Map, + ): Promise { + await this.toolExecutor.cleanupPendingToolCalls( + pending, + this.buildToolBatchContext(ctx, toolMap), + ctx.abortScope.isAborted ? 'Run aborted' : 'Parent run failed before suspension', + ); + try { + await this.runState.cancel(this.runId, this.getState()); + } catch { + // Preserve the failure that interrupted suspension finalization. + } + } + + private async completeToolTurn( + ctx: PreparedLoopContext, + state: LoopState, + turn: ModelTurnResult, + ): Promise { + this.emitTurnEnd(turn.newMessages, extractSettledToolCalls(ctx.list.responseDelta())); + // All tools have settled. Observe before the next call and its checkpoint. + await this.memory.maybeObserveMidRun(ctx.list, ctx.options); + if (ctx.options?.stepCheckpoints) { + await this.persistStepCheckpoint( + ctx.list, + state.totalUsage, + ctx.options, + state.maxIterations, + state.iterationCount + 1, + ); + } } private async resolveVolatileInstructions( @@ -1104,18 +1072,7 @@ export class AgentRuntime { withRootSpan: async (operation, options, runId, fn) => await this.telemetry.withRootSpan(operation, options, runId, fn), runLoop: async (guard) => { - // Surface MCP connection failures as non-fatal warnings before the - // first LLM step. Tools from these servers were skipped during - // build(); the run continues with the remaining tools. - for (const failure of this.config.mcpConnectionFailures ?? []) { - void guard.write({ - type: 'warning', - message: failure.error, - code: 'mcp_connection_failed', - source: 'mcp', - server: failure.server, - }); - } + this.writeMcpConnectionWarnings(guard); const resolvedList = ctx.list ?? (await this.initRun(ctx.input, ctx.options)); list = resolvedList; sink = new StreamSink(guard, this.createRunServices(), ctx.options); @@ -1150,6 +1107,21 @@ export class AgentRuntime { }); } + private writeMcpConnectionWarnings(guard: StreamWriterGuard): void { + // Surface MCP connection failures as non-fatal warnings before the + // first LLM step. Tools from these servers were skipped during + // build(); the run continues with the remaining tools. + for (const failure of this.config.mcpConnectionFailures ?? []) { + void guard.write({ + type: 'warning', + message: failure.error, + code: 'mcp_connection_failed', + source: 'mcp', + server: failure.server, + }); + } + } + /** * Persist a suspended run state and update the current state snapshot, and durably * save the turn-so-far to thread memory so a suspended turn that is later cancelled or @@ -1163,27 +1135,9 @@ export class AgentRuntime { maxIterations?: number, iterationCount?: number, ): Promise { - // Persist loop controls only. providerOptions are intentionally excluded - // because they may contain sensitive data (API keys, auth headers). - const resolvedMaxIterations = maxIterations ?? options?.maxIterations; - const resolvedIterationCount = iterationCount ?? options?.iterationCount; - const executionOptions: PersistedExecutionOptions | undefined = - resolvedMaxIterations !== undefined ? { maxIterations: resolvedMaxIterations } : undefined; + const checkpointOptions = buildCheckpointOptions(options, maxIterations, iterationCount); - // Record what confirmation each suspended call showed the user, so an - // abandoned suspension can be settled with that context on a later - // history load instead of vanishing from the transcript. - for (const pending of Object.values(pendingToolCalls)) { - if (!pending.suspended) continue; - const payload = - typeof pending.suspendPayload === 'object' && pending.suspendPayload !== null - ? (pending.suspendPayload as { message?: unknown; requestId?: unknown }) - : undefined; - list.markToolCallSuspended(pending.toolCallId, { - ...(typeof payload?.message === 'string' ? { message: payload.message } : {}), - ...(typeof payload?.requestId === 'string' ? { requestId: payload.requestId } : {}), - }); - } + markSuspendedToolCalls(list, pendingToolCalls); const state: SerializableAgentState = { persistence: options?.persistence, @@ -1191,8 +1145,7 @@ export class AgentRuntime { messageList: list.serialize(), pendingToolCalls, usage: totalUsage, - executionOptions, - ...(resolvedIterationCount !== undefined ? { iterationCount: resolvedIterationCount } : {}), + ...checkpointOptions, }; await this.runState.suspend(this.runId, state); this.updateState({ status: 'suspended', pendingToolCalls, messageList: list.serialize() }); @@ -1214,10 +1167,7 @@ export class AgentRuntime { maxIterations?: number, iterationCount?: number, ): Promise { - const resolvedMaxIterations = maxIterations ?? options?.maxIterations; - const resolvedIterationCount = iterationCount ?? options?.iterationCount; - const executionOptions: PersistedExecutionOptions | undefined = - resolvedMaxIterations !== undefined ? { maxIterations: resolvedMaxIterations } : undefined; + const checkpointOptions = buildCheckpointOptions(options, maxIterations, iterationCount); const state: SerializableAgentState = { persistence: options?.persistence, @@ -1225,8 +1175,7 @@ export class AgentRuntime { messageList: list.serialize(), pendingToolCalls: {}, usage: totalUsage, - executionOptions, - ...(resolvedIterationCount !== undefined ? { iterationCount: resolvedIterationCount } : {}), + ...checkpointOptions, }; await this.runState.checkpointStep(this.runId, state); } diff --git a/packages/@n8n/agents/src/runtime/loop/checkpoint-data.ts b/packages/@n8n/agents/src/runtime/loop/checkpoint-data.ts new file mode 100644 index 00000000000..ed2cb8aec65 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/loop/checkpoint-data.ts @@ -0,0 +1,94 @@ +import { isCancellation } from '../../sdk/cancellation'; +import type { BuiltTool, PendingToolCall, SerializableAgentState } from '../../types'; +import type { + AgentPersistenceOptions, + ExecutionOptions, + PersistedExecutionOptions, + ResumeOptions, +} from '../../types/sdk/agent'; +import { parseWithSchema } from '../../utils/parse'; +import type { AgentMessageList } from '../model/message-list'; + +export async function parseResumeData( + data: unknown, + resumeSchema: BuiltTool['resumeSchema'], +): Promise { + if (isCancellation(data) || !resumeSchema) return data; + const result = await parseWithSchema(resumeSchema, data, { stripUnknown: true }); + if (!result.success) throw new Error(`Invalid resume payload: ${result.error}`); + return result.data; +} + +export function mergeResumeExecutionOptions( + state: SerializableAgentState, + callerExecOptions: ExecutionOptions, +): ExecutionOptions & { iterationCount?: number } { + const persisted = state.executionOptions ?? {}; + const persistedMaxIterations = persisted.maxIterations; + const callerMaxIterations = callerExecOptions.maxIterations; + if ( + callerMaxIterations !== undefined && + persistedMaxIterations !== undefined && + callerMaxIterations < persistedMaxIterations + ) { + throw new Error( + `Cannot decrease maxIterations when resuming a run. Expected >= ${persistedMaxIterations}, received ${callerMaxIterations}.`, + ); + } + + const mergedMaxIterations = callerMaxIterations ?? persistedMaxIterations; + return { + ...callerExecOptions, + ...(mergedMaxIterations !== undefined ? { maxIterations: mergedMaxIterations } : {}), + ...(state.iterationCount !== undefined ? { iterationCount: state.iterationCount } : {}), + }; +} + +export function mergeResumePersistence( + persistence: AgentPersistenceOptions | undefined, + hostMetadata: ResumeOptions['hostMetadata'], +): AgentPersistenceOptions | undefined { + if (!persistence) return undefined; + const merged = { ...persistence }; + if (persistence.hostMetadata || hostMetadata) { + merged.hostMetadata = { ...persistence.hostMetadata, ...hostMetadata }; + } + return merged; +} + +export function markSuspendedToolCalls( + list: AgentMessageList, + pendingToolCalls: Record, +): void { + // Record what confirmation each suspended call showed the user, so an + // abandoned suspension can be settled with that context on a later + // history load instead of vanishing from the transcript. + for (const pending of Object.values(pendingToolCalls)) { + if (!pending.suspended) continue; + const payload = + typeof pending.suspendPayload === 'object' && pending.suspendPayload !== null + ? (pending.suspendPayload as { message?: unknown; requestId?: unknown }) + : undefined; + list.markToolCallSuspended(pending.toolCallId, { + ...(typeof payload?.message === 'string' ? { message: payload.message } : {}), + ...(typeof payload?.requestId === 'string' ? { requestId: payload.requestId } : {}), + }); + } +} + +export function buildCheckpointOptions( + options: (ExecutionOptions & { iterationCount?: number }) | undefined, + maxIterations?: number, + iterationCount?: number, +): Pick { + // Persist loop controls only. providerOptions are intentionally excluded + // because they may contain sensitive data (API keys, auth headers). + const resolvedMaxIterations = maxIterations ?? options?.maxIterations; + const resolvedIterationCount = iterationCount ?? options?.iterationCount; + const executionOptions: PersistedExecutionOptions | undefined = + resolvedMaxIterations !== undefined ? { maxIterations: resolvedMaxIterations } : undefined; + return { + executionOptions, + ...(resolvedIterationCount !== undefined ? { iterationCount: resolvedIterationCount } : {}), + }; +} diff --git a/packages/@n8n/agents/src/runtime/loop/execution-counter.ts b/packages/@n8n/agents/src/runtime/loop/execution-counter.ts index 47716a1c733..359c9c91427 100644 --- a/packages/@n8n/agents/src/runtime/loop/execution-counter.ts +++ b/packages/@n8n/agents/src/runtime/loop/execution-counter.ts @@ -51,9 +51,5 @@ export function incrementTokenCountFromUsage( usage.totalTokens ?? usage.tokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); if (tokenCount <= 0) return; - try { - counter.incrementTokenCount(tokenCount); - } catch { - // Aggregate counters are best-effort and must never affect agent execution. - } + recordExecutionCounter(() => counter.incrementTokenCount(tokenCount)); } diff --git a/packages/@n8n/agents/src/runtime/loop/generate-sink.ts b/packages/@n8n/agents/src/runtime/loop/generate-sink.ts index 221c67abb61..e27b4828d43 100644 --- a/packages/@n8n/agents/src/runtime/loop/generate-sink.ts +++ b/packages/@n8n/agents/src/runtime/loop/generate-sink.ts @@ -1,3 +1,4 @@ +import { finalizeRun } from './run-output-sink'; import type { CompleteEmission, ModelCallContext, @@ -5,7 +6,7 @@ import type { RunOutputSink, RunServices, SuspendEmission, -} from './run-output-sink'; +} from '../../types/runtime/agent-loop'; import { classifyModelTurnError } from './runtime-helpers'; import type { GenerateResult } from '../../types'; import type { ToolResultEntry } from '../../types/sdk/agent'; @@ -13,7 +14,7 @@ import { isAttachmentValidationError } from '../model/attachment-validation-erro import { loadAi } from '../model/lazy-ai'; import { fromAiFinishReason, fromAiMessages } from '../model/messages'; import { toTokenUsage } from '../streaming/stream'; -import type { ToolCallBatchResult } from '../tools/tool-call-executor'; +import type { ToolCallBatchResult } from '../../types/runtime/tool-execution'; /** * Non-streaming output sink: drives the loop with `generateText`, accumulates a @@ -90,11 +91,8 @@ export class GenerateSink implements RunOutputSink { } async finishComplete(emission: CompleteEmission): Promise { - const { list, options, finishReason, usage, structuredOutput } = emission; - await this.services.saveToMemory(list, options); - await this.services.maybeGenerateTitle(list, options); - await this.services.cleanupRun(); - await this.services.flushTelemetry(options); + const { list, finishReason, usage, structuredOutput } = emission; + await finalizeRun(this.services, emission); return { runId: this.services.runId, diff --git a/packages/@n8n/agents/src/runtime/loop/run-output-sink.ts b/packages/@n8n/agents/src/runtime/loop/run-output-sink.ts index a2451e07e64..670a063825a 100644 --- a/packages/@n8n/agents/src/runtime/loop/run-output-sink.ts +++ b/packages/@n8n/agents/src/runtime/loop/run-output-sink.ts @@ -1,142 +1,23 @@ -import type { - LanguageModel, - LanguageModelCallOptions, - ModelMessage, - Output, - SystemModelMessage, - TelemetryOptions, - ToolCallRepairFunction, - ToolSet, -} from 'ai'; +import type { CompleteEmission, RunServices } from '../../types/runtime/agent-loop'; -import type { FinishReason, SerializableAgentState, TokenUsage } from '../../types'; -import type { ExecutionOptions, RunOptions } from '../../types/sdk/agent'; -import type { AgentMessage } from '../../types/sdk/message'; -import type { JSONObject } from '../../types/utils/json'; -import type { AgentMessageList } from '../model/message-list'; -import type { ToolCallBatchResult, ToolCallSuspension } from '../tools/tool-call-executor'; +export type { + ModelTurnErrorType, + ModelTurnError, + ModelTurnResult, + ModelCallContext, + SuspendEmission, + CompleteEmission, + RunServices, + RunOutputSink, +} from '../../types/runtime/agent-loop'; -type RunCallOptions = (RunOptions & ExecutionOptions) | undefined; - -/** - * Recognized ways a model turn can fail while the provider still returns - * "successfully" (no thrown SDK error). Extend this as new failure modes are - * recognized so consumers switch on `type` instead of probing separate fields. - * - `prompt_blocked`: the provider rejected the prompt (e.g. a safety block). - * - `no_output`: the model returned nothing with a non-stop finish reason. - */ -export type ModelTurnErrorType = 'prompt_blocked' | 'no_output'; - -/** A recognized model-turn failure: a `type` to branch on and a user-facing `message`. */ -export interface ModelTurnError { - type: ModelTurnErrorType; - message: string; -} - -/** Normalized result of a single LLM turn, independent of generate vs stream. */ -export interface ModelTurnResult { - /** Raw AI SDK finish reason (used to detect the `tool-calls` continuation). */ - aiFinishReason: string; - finishReason: FinishReason; - /** Normalized usage, including any cache-token details available from the provider. */ - usage: TokenUsage | undefined; - newMessages: AgentMessage[]; - toolCalls: Array<{ - toolCallId: string; - toolName: string; - input: unknown; - providerExecuted?: boolean; - }>; - /** Resolved structured output, only when an output spec is set and the turn finished. */ - structuredOutput: unknown; - /** - * Set when the turn failed in a recognized way despite the provider returning - * without a thrown error (e.g. a prompt safety block, or empty output). The - * loop surfaces this instead of ending the run silently. Check this single - * field rather than probing per-failure-mode properties. - */ - errorReason?: ModelTurnError; -} - -/** Per-iteration inputs for the LLM call, assembled by the shared loop. */ -export interface ModelCallContext { - model: LanguageModel; - system: SystemModelMessage | SystemModelMessage[]; - messages: ModelMessage[]; - abortSignal: AbortSignal; - hasTools: boolean; - aiTools: ToolSet; - reasoning?: LanguageModelCallOptions['reasoning']; - providerOptions?: Record; - outputSpec?: ReturnType; - maxOutputTokens?: number; - onInputRejected?: (error: unknown) => Promise; - aiSdkOptions: { - telemetry?: TelemetryOptions; - repairToolCall?: ToolCallRepairFunction>; - }; -} - -/** Data needed to emit a terminal suspension result. */ -export interface SuspendEmission { - suspendRunId: string; - list: AgentMessageList; - usage: TokenUsage | undefined; - suspensions: ToolCallSuspension[]; -} - -/** Data needed to emit a terminal completion result. */ -export interface CompleteEmission { - list: AgentMessageList; - options: RunCallOptions; - finishReason: FinishReason; - usage: TokenUsage | undefined; - structuredOutput: unknown; -} - -/** - * Runtime services the sinks call into for shared terminal concerns (cost, - * memory persistence, title generation, telemetry flush, state). Implemented by - * `AgentRuntime` and passed to each sink. - */ -export interface RunServices { - readonly runId: string; - readonly modelId: string; - applyCost(usage: TokenUsage | undefined): TokenUsage | undefined; - saveToMemory(list: AgentMessageList, options: RunCallOptions): Promise; - maybeGenerateTitle(list: AgentMessageList, options: RunCallOptions): Promise; - flushTelemetry(options: RunCallOptions): Promise; - cleanupRun(): Promise; - updateState(patch: Partial): void; - emitAgentEnd(messages: AgentMessage[]): void; - getState(): SerializableAgentState; -} - -/** - * Strategy that adapts the shared agentic loop to a concrete output channel: - * `GenerateSink` accumulates a `GenerateResult`; `StreamSink` writes - * `StreamChunk`s through the stream writer guard. - */ -export interface RunOutputSink { - /** Run one LLM turn. Streaming implementations also emit text/tool chunks here. */ - callModel(ctx: ModelCallContext): Promise; - /** - * Report the run's cumulative token usage after each turn. Streaming - * implementations keep it so an aborted run can still emit a terminal - * finish chunk carrying the tokens consumed before the stop. - */ - reportUsage(usage: TokenUsage | undefined): void; - /** - * Signal that the just-returned turn's messages have been folded into the list. - * Streaming implementations drop the retained streamed text here so a later abort - * can't duplicate a turn that is already in the list — while a stop landing before - * this call still recovers the turn from the retained text. - */ - onTurnFolded?(): void; - /** Emit the results/errors of a completed tool-call batch. */ - emitToolBatch(batch: ToolCallBatchResult): Promise; - /** Produce the terminal result when the run suspends for HITL / suspend-resume. */ - finishSuspended(emission: SuspendEmission): Promise; - /** Produce the terminal result when the run completes normally. */ - finishComplete(emission: CompleteEmission): Promise; +/** Persist the turn before checkpoint cleanup and telemetry flush. */ +export async function finalizeRun( + services: RunServices, + { list, options }: Pick, +): Promise { + await services.saveToMemory(list, options); + await services.maybeGenerateTitle(list, options); + await services.cleanupRun(); + await services.flushTelemetry(options); } diff --git a/packages/@n8n/agents/src/runtime/loop/runtime-context.ts b/packages/@n8n/agents/src/runtime/loop/runtime-context.ts index c898fe0ed18..629cc694c9c 100644 --- a/packages/@n8n/agents/src/runtime/loop/runtime-context.ts +++ b/packages/@n8n/agents/src/runtime/loop/runtime-context.ts @@ -2,7 +2,7 @@ import type { ProviderOptions } from '@ai-sdk/provider-utils'; import { getProviderPrefix } from '@n8n/ai-utilities/agent-config'; import type { LanguageModel, Output } from 'ai'; -import type { AgentRuntimeConfig } from './agent-runtime'; +import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime'; import { UNTRUSTED_OUTPUT_DOCTRINE } from '../../sdk/untrusted-content'; import type { AgentExecutionCounter, BuiltTool, JSONObject } from '../../types'; import type { AgentPersistenceOptions, ExecutionOptions } from '../../types/sdk/agent'; @@ -23,13 +23,19 @@ import { } from '../memory/episodic-memory-capture'; import { loadAi } from '../model/lazy-ai'; import type { AgentMessageList } from '../model/message-list'; -import { createModel } from '../model/model-factory'; -import { buildCallPromptCacheOptions, mergeProviderOptions } from '../model/prompt-cache'; +import { createModel, supportsSplitSystemMessages } from '../model/model-factory'; +import { + applyRuntimeCacheBreakpoints, + buildCallPromptCacheOptions, + buildInstructionPromptCacheOptions, + mergeProviderOptions, +} from '../model/prompt-cache'; import { getProviderQuirks, PROVIDER_QUIRKS, resolveDefaultMaxOutputTokens, } from '../model/provider-quirks'; +import type { ActiveSkills } from '../skills/active-skills'; import type { DeferredToolManager } from '../tools/deferred-tool-manager'; import { buildToolMap, toAiSdkProviderTools, toAiSdkTools } from '../tools/tool-adapter'; @@ -39,6 +45,13 @@ function wrapBuiltInRules(fragments: string[]): string | undefined { return `\n${fragments.map((f) => `- ${f}`).join('\n')}\n`; } +function prependBuiltInRules(fragments: string[], instructions: string): string { + const rules = wrapBuiltInRules(fragments); + if (!rules) return instructions; + if (!instructions) return rules; + return `${rules}\n\n${instructions}`; +} + export interface StaticLoopContext { model: LanguageModel; aiProviderTools: ReturnType; @@ -68,7 +81,7 @@ export class RuntimeContextBuilder { buildStaticLoopContext( execOptions?: ExecutionOptions & { persistence?: AgentPersistenceOptions }, ): StaticLoopContext { - const { Output, jsonSchema } = loadAi(); + const ai = loadAi(); const aiProviderTools = toAiSdkProviderTools(this.config.providerTools); const model = createModel(this.config.model, this.config.modelFetch); const outputSchema = this.config.structuredOutput; @@ -78,16 +91,7 @@ export class RuntimeContextBuilder { isRawJsonSchemaOutput, ); - const outputSpec = outputSchema - ? Output.object({ - // Zod schemas pass through directly; a raw JSON Schema gets - // `additionalProperties: false` locked onto every object (required by Anthropic) - // and wrapped with the AI SDK's `jsonSchema()` helper. - schema: isZodSchema(outputSchema) - ? outputSchema - : jsonSchema(lockAdditionalProperties(outputSchema)), - }) - : undefined; + const outputSpec = this.buildOutputSpec(outputSchema, ai); return { model, @@ -99,6 +103,26 @@ export class RuntimeContextBuilder { }; } + private buildOutputSpec( + outputSchema: AgentRuntimeConfig['structuredOutput'], + { Output, jsonSchema }: ReturnType, + ): StaticLoopContext['outputSpec'] { + if (!outputSchema) return undefined; + // Anthropic requires additionalProperties: false on each raw schema object. + const schema = isZodSchema(outputSchema) + ? outputSchema + : jsonSchema(lockAdditionalProperties(outputSchema)); + return Output.object({ schema }); + } + + buildInstructionProviderOptions(): ProviderOptions | undefined { + // Explicit instruction options take precedence over cache defaults. + return mergeProviderOptions( + buildInstructionPromptCacheOptions(this.config.promptCaching, this.modelId), + this.config.instructionProviderOptions, + ); + } + /** Build the current local tool view; deferred loads can change this between iterations. */ buildToolLoopContext( aiProviderTools: ReturnType, @@ -124,6 +148,44 @@ export class RuntimeContextBuilder { }; } + buildModelPrompt({ + list, + tools, + instructionProviderOptions, + hostVolatileInstructions, + activeSkills, + }: { + list: AgentMessageList; + tools: ReturnType; + instructionProviderOptions: ProviderOptions | undefined; + hostVolatileInstructions: string | undefined; + activeSkills: ActiveSkills | undefined; + }) { + const combinedVolatileInstructions = [tools.volatileInstructions, hostVolatileInstructions] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)) + .join('\n\n'); + const { system, messages } = list.forLlm( + // Skill content changes only on activation. Keep it cached when memory compacts. + [tools.effectiveInstructions, activeSkills?.instructions()] + .filter(Boolean) + .join('\n\n'), + instructionProviderOptions, + combinedVolatileInstructions || undefined, + supportsSplitSystemMessages(this.config.model), + ); + // Cache breakpoints apply to this call only. Do not change stored messages or tools. + const cached = applyRuntimeCacheBreakpoints({ + system, + messages: activeSkills?.modelMessages(messages, list) ?? messages, + aiTools: tools.aiTools, + promptCaching: this.config.promptCaching, + modelId: this.modelId, + staticToolCacheName: tools.staticToolCacheName, + }); + return { system, ...cached }; + } + /** * Name of the tool eligible for an Anthropic tool-definitions cache * breakpoint, or `undefined` if the tool set isn't fully static. Deferred @@ -260,6 +322,14 @@ export class RuntimeContextBuilder { instructions: string; volatileInstructions: string | undefined; } { + const { stableFragments, volatileFragments } = this.collectInstructionFragments(tools); + return { + instructions: prependBuiltInRules(stableFragments, this.config.instructions), + volatileInstructions: wrapBuiltInRules(volatileFragments), + }; + } + + private collectInstructionFragments(tools: BuiltTool[]) { const loadedToolNames = new Set( this.deferredToolManager?.getLoadedTools().map((t) => t.name) ?? [], ); @@ -272,9 +342,8 @@ export class RuntimeContextBuilder { ) { continue; } - (loadedToolNames.has(tool.name) ? volatileFragments : stableFragments).push( - tool.systemInstruction, - ); + const fragments = loadedToolNames.has(tool.name) ? volatileFragments : stableFragments; + fragments.push(tool.systemInstruction); } // Define the untrusted-data boundary ahead of the first wrapped result. @@ -288,18 +357,7 @@ export class RuntimeContextBuilder { target.unshift(UNTRUSTED_OUTPUT_DOCTRINE); } - const userInstructions = this.config.instructions; - const stableBlock = wrapBuiltInRules(stableFragments); - const instructions = stableBlock - ? userInstructions - ? `${stableBlock}\n\n${userInstructions}` - : stableBlock - : userInstructions; - - return { - instructions, - volatileInstructions: wrapBuiltInRules(volatileFragments), - }; + return { stableFragments, volatileFragments }; } /** Build the providerOptions object for provider-specific thinking config. */ diff --git a/packages/@n8n/agents/src/runtime/loop/runtime-helpers.ts b/packages/@n8n/agents/src/runtime/loop/runtime-helpers.ts index bcc183db4f8..3f3722d4a07 100644 --- a/packages/@n8n/agents/src/runtime/loop/runtime-helpers.ts +++ b/packages/@n8n/agents/src/runtime/loop/runtime-helpers.ts @@ -2,7 +2,7 @@ * Pure utility functions used by AgentRuntime that require no class context. * These are extracted here to keep agent-runtime.ts focused on orchestration logic. */ -import type { ModelTurnError } from './run-output-sink'; +import type { ModelTurnError } from '../../types/runtime/agent-loop'; import { stripInvisibleUnicode, wrapUntrustedData } from '../../sdk/untrusted-content'; import type { StreamChunk, TokenUsage, McpConnectionFailedEvent } from '../../types'; import type { AgentMessage, ContentToolCall } from '../../types/sdk/message'; diff --git a/packages/@n8n/agents/src/runtime/loop/stream-sink.ts b/packages/@n8n/agents/src/runtime/loop/stream-sink.ts index 043c72c9664..4d633fe04a2 100644 --- a/packages/@n8n/agents/src/runtime/loop/stream-sink.ts +++ b/packages/@n8n/agents/src/runtime/loop/stream-sink.ts @@ -1,5 +1,6 @@ -import type { StreamTextTransform, ToolSet } from 'ai'; +import type { StreamTextTransform, TextStreamPart, ToolSet } from 'ai'; +import { finalizeRun } from './run-output-sink'; import type { CompleteEmission, ModelCallContext, @@ -7,7 +8,7 @@ import type { RunOutputSink, RunServices, SuspendEmission, -} from './run-output-sink'; +} from '../../types/runtime/agent-loop'; import { classifyModelTurnError, mergeUsage } from './runtime-helpers'; import type { ExecutionOptions, TokenUsage } from '../../types/sdk/agent'; import type { AgentMessage } from '../../types/sdk/message'; @@ -27,7 +28,7 @@ import { withChunkIdleTimeout, } from '../streaming/stream-stall'; import type { StreamWriterGuard } from '../streaming/stream-writer-guard'; -import type { ToolCallBatchResult } from '../tools/tool-call-executor'; +import type { ToolCallBatchResult } from '../../types/runtime/tool-execution'; /** * Chunk types that are pure transport bookkeeping: an attempt that stalled @@ -160,7 +161,7 @@ export class StreamSink implements RunOutputSink { return transforms.length > 0 ? { experimental_transform: transforms } : {}; } - async callModel(ctx: ModelCallContext): Promise { + private getStreamDeadlines(): { idleMs: number; firstOutputMs: number } { // Clamped to MAX_MODEL_STREAM_TIMEOUT_MS: above it setTimeout fires after // 1ms, turning an "effectively disable" override into instant stalls. const idleMs = Math.min( @@ -177,48 +178,55 @@ export class StreamSink implements RunOutputSink { ), MAX_MODEL_STREAM_TIMEOUT_MS, ); + return { idleMs, firstOutputMs }; + } + + async callModel(ctx: ModelCallContext): Promise { + const deadlines = this.getStreamDeadlines(); for (let attempt = 0; ; attempt++) { - // Opt-in: only build the reader when the host bills stopped runs, and only - // for providers with a reader. Re-created per attempt so a retried turn - // starts from a clean raw capture. - this.rawUsageReader = this.options?.recoverUsageOnAbort - ? createRawUsageReader(this.services.modelId) - : undefined; - // Some providers report failures (e.g. prompt safety blocks) only on the - // raw stream — no error, no content — so raw chunks are required to - // explain an otherwise silent empty response. - this.rawErrorReader = createRawErrorReader(this.services.modelId); + this.resetRawReaders(); // Per-attempt controller: a stall must be able to cancel this attempt's // fetch (releasing the socket the 1h network timeout would otherwise // hold) without touching the run-level signal. const turnAbort = new AbortController(); const attemptState = { streamedContent: false }; try { - return await this.streamModelTurn(ctx, turnAbort, { idleMs, firstOutputMs }, attemptState); + return await this.streamModelTurn(ctx, turnAbort, deadlines, attemptState); } catch (error) { if (isAttachmentValidationError(error)) { if (!attemptState.streamedContent) await ctx.onInputRejected?.(error); throw error; } - // A stalled or empty stream before any content is invisible to the user - // (and to the host's persistence) — re-issue the request instead of - // failing the run for what is usually a dead connection at request time. - // Once content has streamed, a retry would duplicate segments and orphan - // already-persisted tool-call facts, so the error surfaces instead. - // The abandoned attempt's prompt processing goes unbilled — accepted: - // it only has usage at all when the stream died between message_start - // and the first content chunk. - const retryable = - (error instanceof ModelStreamStallError || - loadAi().NoOutputGeneratedError.isInstance(error)) && - attempt < MAX_MODEL_STREAM_STALL_RETRIES && - !attemptState.streamedContent && - !ctx.abortSignal.aborted; - if (!retryable) throw error; + if (!this.canRetryStream(error, attempt, attemptState, ctx.abortSignal)) throw error; } } } + private resetRawReaders(): void { + // Each attempt needs fresh readers so a retry does not reuse captured usage. + this.rawUsageReader = this.options?.recoverUsageOnAbort + ? createRawUsageReader(this.services.modelId) + : undefined; + // Some providers report a blocked prompt only in raw chunks. + this.rawErrorReader = createRawErrorReader(this.services.modelId); + } + + private canRetryStream( + error: unknown, + attempt: number, + attemptState: { streamedContent: boolean }, + abortSignal: AbortSignal, + ): boolean { + // Retry only before content is visible or persisted. The abandoned attempt stays unbilled. + return ( + (error instanceof ModelStreamStallError || + loadAi().NoOutputGeneratedError.isInstance(error)) && + attempt < MAX_MODEL_STREAM_STALL_RETRIES && + !attemptState.streamedContent && + !abortSignal.aborted + ); + } + private async streamModelTurn( ctx: ModelCallContext, turnAbort: AbortController, @@ -227,6 +235,36 @@ export class StreamSink implements RunOutputSink { ): Promise { const { idleMs, firstOutputMs } = deadlines; const { NoOutputGeneratedError, streamText } = loadAi(); + const { result, activity } = this.startModelStream(ctx, turnAbort, idleMs, streamText); + + // Use the longer deadline before the first output to allow prompt processing. + const chunkStream = + idleMs > 0 + ? withChunkIdleTimeout( + result.stream, + () => (attemptState.streamedContent ? idleMs : firstOutputMs), + () => turnAbort.abort(), + () => activity.lastAt, + ) + : result.stream; + + for await (const chunk of chunkStream) { + if (chunk.type === 'error' && isAttachmentValidationError(chunk.error)) throw chunk.error; + // Result promises report this error. Do not forward it or count it as content before a retry. + if (chunk.type === 'error' && NoOutputGeneratedError.isInstance(chunk.error)) continue; + if (!this.captureStreamChunk(chunk, attemptState)) continue; + await this.writeStreamChunk(chunk); + } + + return await this.resolveModelTurn(result, ctx, idleMs, turnAbort); + } + + private startModelStream( + ctx: ModelCallContext, + turnAbort: AbortController, + idleMs: number, + streamText: ReturnType['streamText'], + ) { // Raw chunks serve two consumers: the usage/error readers parse them, and // the stall watchdog counts them as liveness — keepalive events the SDK // otherwise drops (e.g. Anthropic `ping`, a gateway's empty-delta frames) @@ -264,83 +302,58 @@ export class StreamSink implements RunOutputSink { : undefined, ), }); + return { result, activity }; + } - // A healthy streaming response emits chunks continuously (raw provider - // events included), so prolonged silence means the connection or provider - // is wedged — fail the turn instead of hanging until the network timeout. - // Pre-first-output silence gets the longer deadline (prompt processing on - // large cache-miss prompts sends nothing for minutes); once content has - // streamed, the tighter idle limit applies. - const chunkStream = - idleMs > 0 - ? withChunkIdleTimeout( - result.stream, - () => (attemptState.streamedContent ? idleMs : firstOutputMs), - () => turnAbort.abort(), - () => activity.lastAt, - ) - : result.stream; + private captureStreamChunk( + chunk: TextStreamPart, + attemptState: { streamedContent: boolean }, + ): boolean { + // Capture raw chunks here when a stream does not use the tap, such as a test stream. + if (chunk.type === 'raw') { + this.rawUsageReader?.capture(chunk.rawValue); + this.rawErrorReader?.capture(chunk.rawValue); + return false; + } + if (!STALL_RETRY_SAFE_CHUNK_TYPES.has(chunk.type)) attemptState.streamedContent = true; + // The runtime writes its own finish chunk after the loop completes. + if (chunk.type === 'finish') return false; + // Retain partial text so cancellation can save an unfinished response. + if (chunk.type === 'text-delta') this.partialText += chunk.text ?? ''; + return true; + } - // Consume the stream. When the AbortSignal fires mid-stream the AI SDK - // cancels the underlying fetch and the async iterator throws; the error - // propagates to the StreamSession which closes the consumer stream. - for await (const chunk of chunkStream) { - if (chunk.type === 'error' && isAttachmentValidationError(chunk.error)) throw chunk.error; - // The rejected result promises surface this error after the stream - // closes. Forwarding it here would mark a successful retry as failed. - // Runs before the `streamedContent` check below so a skipped chunk - // cannot make a stalled attempt look unretryable. - if (chunk.type === 'error' && NoOutputGeneratedError.isInstance(chunk.error)) continue; - // Raw chunks are normally consumed by the raw-chunk tap inside the SDK - // pipeline and never reach this loop; this branch is the fallback for - // streams the tap was not applied to (e.g. mocked streams in tests) and - // is why 'raw' stays in the retry-safe set. Capturing usage here also - // lets an aborted turn still be billed via getTerminalFinish. - if (chunk.type === 'raw') { - this.rawUsageReader?.capture(chunk.rawValue); - this.rawErrorReader?.capture(chunk.rawValue); - continue; - } - // Anything beyond transport bookkeeping counts as content: once seen, - // a stalled attempt is no longer silently retryable (see callModel). - if (!STALL_RETRY_SAFE_CHUNK_TYPES.has(chunk.type)) attemptState.streamedContent = true; - // Filter only the SDK's terminal `finish` chunk — the runtime emits its - // own consolidated `finish` after the loop completes. `start-step` / - // `finish-step` are passed through as LLM-iteration boundaries. - if (chunk.type === 'finish') continue; - - // Accumulate streamed text so an abort mid-response can persist it (the - // turn's `newMessages` are only built below, which the abort never reaches). - if (chunk.type === 'text-delta') this.partialText += chunk.text ?? ''; - - // Provider-executed tools (e.g. native web search) skip the local - // execution loop that emits tool-execution lifecycle events via the - // event bus. Stamp them here at chunk-arrival time so live chat and the - // persisted timeline both show a duration. A failed call arrives as a - // `tool-error` part (never a `tool-result`), so close its timing there. - if ((chunk.type === 'tool-result' || chunk.type === 'tool-error') && chunk.providerExecuted) { - await this.guard.write({ - type: 'tool-execution-end', - toolCallId: chunk.toolCallId, - toolName: chunk.toolName ?? '', - isError: chunk.type === 'tool-error', - endTime: Date.now(), - }); - } - - const converted = convertChunk(chunk); - if (converted) await this.guard.write(converted); - - if (chunk.type === 'tool-call' && chunk.providerExecuted) { - await this.guard.write({ - type: 'tool-execution-start', - toolCallId: chunk.toolCallId, - toolName: chunk.toolName ?? '', - startTime: Date.now(), - }); - } + private async writeStreamChunk(chunk: TextStreamPart): Promise { + // Provider tools skip the local executor. Record their timing when chunks arrive. + if ((chunk.type === 'tool-result' || chunk.type === 'tool-error') && chunk.providerExecuted) { + await this.guard.write({ + type: 'tool-execution-end', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName ?? '', + isError: chunk.type === 'tool-error', + endTime: Date.now(), + }); } + const converted = convertChunk(chunk); + if (converted) await this.guard.write(converted); + + if (chunk.type === 'tool-call' && chunk.providerExecuted) { + await this.guard.write({ + type: 'tool-execution-start', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName ?? '', + startTime: Date.now(), + }); + } + } + + private async resolveModelTurn( + result: ReturnType['result'], + ctx: ModelCallContext, + idleMs: number, + turnAbort: AbortController, + ): Promise { // The result promises settle as part of stream close on a healthy turn; // guard them with the same stall deadline so an SDK-internal promise that // never settles cannot hang the turn after the chunk loop ended. @@ -426,13 +439,10 @@ export class StreamSink implements RunOutputSink { } async finishComplete(emission: CompleteEmission): Promise { - const { list, options, finishReason, usage, structuredOutput } = emission; + const { list, finishReason, usage, structuredOutput } = emission; const costUsage = this.services.applyCost(usage); - await this.services.saveToMemory(list, options); - await this.services.maybeGenerateTitle(list, options); - await this.services.cleanupRun(); - await this.services.flushTelemetry(options); + await finalizeRun(this.services, emission); await this.guard.write({ type: 'finish', diff --git a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts index cf25714c184..69a4a997bb0 100644 --- a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts +++ b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts @@ -33,7 +33,7 @@ import { AgentEvent } from '../../types/runtime/event'; import type { AgentPersistenceOptions, ExecutionOptions, RunOptions } from '../../types/sdk/agent'; import type { AgentDbMessage } from '../../types/sdk/message'; import type { ObservationLogScope, ObservationLogTaskKind } from '../../types/sdk/observation-log'; -import type { AgentRuntimeConfig } from '../loop/agent-runtime'; +import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime'; import type { AgentMessageList } from '../model/message-list'; import { estimateObservationTokens, type TokenCounter } from '../model/model-token-counter'; import type { BackgroundTaskTracker } from '../state/background-task-tracker'; @@ -44,7 +44,7 @@ import { type MemorySpanAttributes, type RuntimeTelemetry, } from '../telemetry/runtime-telemetry'; -import { sanitizeOffloadedToolResultsForMemory } from '../tools/tool-result-guard'; +import { sanitizeOffloadedToolResultsForMemory } from './tool-result-memory'; const DEFAULT_MEMORY_TASK_LOCK_TTL_MS = 30_000; /** Fraction of observerThresholdTokens at which the mid-run observer starts in the background. */ diff --git a/packages/@n8n/agents/src/runtime/memory/tool-result-memory.ts b/packages/@n8n/agents/src/runtime/memory/tool-result-memory.ts new file mode 100644 index 00000000000..ae3dfdd7969 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/memory/tool-result-memory.ts @@ -0,0 +1,74 @@ +import { isRecord } from '@n8n/utils/is-record'; +import { toJsonValue } from '@n8n/utils/json/to-json-value'; + +import type { AgentDbMessage, MessageContent } from '../../types/sdk/message'; +import type { JSONObject, JSONValue } from '../../types/utils/json'; +import { isToolResultPath } from '../../workspace/tool-result-storage'; +import { isContentToolResultOutput, type ContentToolResultOutput } from '../model/messages'; + +export const EXPIRED_OFFLOADED_TOOL_RESULT = { + _offloaded: true, + expired: true, + message: 'The stored tool result expired with its originating run.', +} satisfies JSONObject; + +const EXPIRED_OFFLOADED_TOOL_RESULT_JSON = JSON.stringify(EXPIRED_OFFLOADED_TOOL_RESULT); + +function isOffloadedToolResult(value: unknown): boolean { + return ( + isRecord(value) && + value._offloaded === true && + typeof value.path === 'string' && + isToolResultPath(value.path) + ); +} + +function isSerializedOffloadedToolResult(value: string): boolean { + try { + const parsed: unknown = JSON.parse(value); + return isOffloadedToolResult(parsed); + } catch { + return false; + } +} + +export function sanitizeOffloadedToolResultsForMemory( + messages: AgentDbMessage[], +): AgentDbMessage[] { + return messages.map((message) => { + if (!('content' in message)) return { ...message }; + + const content = message.content.map(sanitizeOffloadedContentBlock); + return { ...message, content }; + }); +} + +function sanitizeOffloadedContentBlock(block: MessageContent): MessageContent { + if (block.type === 'tool-call') { + if (block.state === 'resolved' && isOffloadedToolResult(block.output)) { + return { ...block, output: { ...EXPIRED_OFFLOADED_TOOL_RESULT } }; + } + if (block.state === 'resolved' && isContentToolResultOutput(block.output)) { + return { ...block, output: sanitizeOffloadedContentResult(block.output) }; + } + if (block.state === 'rejected' && isSerializedOffloadedToolResult(block.error)) { + return { ...block, error: EXPIRED_OFFLOADED_TOOL_RESULT_JSON }; + } + } + + if (block.type === 'text' && isSerializedOffloadedToolResult(block.text)) { + return { ...block, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON }; + } + + return { ...block }; +} + +function sanitizeOffloadedContentResult(output: ContentToolResultOutput): JSONValue { + return toJsonValue({ + type: 'content', + value: output.value.map((part) => { + if (part.type !== 'text' || !isSerializedOffloadedToolResult(part.text)) return part; + return { ...part, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON }; + }), + }); +} diff --git a/packages/@n8n/agents/src/runtime/model/raw-error.ts b/packages/@n8n/agents/src/runtime/model/raw-error.ts index 3d00171d6e5..4a60e4eb114 100644 --- a/packages/@n8n/agents/src/runtime/model/raw-error.ts +++ b/packages/@n8n/agents/src/runtime/model/raw-error.ts @@ -2,7 +2,7 @@ import { getProviderPrefix } from '@n8n/ai-utilities/agent-config'; import { isRecord } from '@n8n/utils/is-record'; import { type ProviderId } from './provider-credentials'; -import type { ModelTurnErrorType } from '../loop/run-output-sink'; +import type { ModelTurnErrorType } from '../../types/runtime/agent-loop'; /** A recognized failure signal read from the provider's raw stream events. */ export interface RawProviderError { diff --git a/packages/@n8n/agents/src/runtime/telemetry/runtime-telemetry.ts b/packages/@n8n/agents/src/runtime/telemetry/runtime-telemetry.ts index 245d2c3c23c..78cecd3ae43 100644 --- a/packages/@n8n/agents/src/runtime/telemetry/runtime-telemetry.ts +++ b/packages/@n8n/agents/src/runtime/telemetry/runtime-telemetry.ts @@ -14,7 +14,7 @@ import type { ExecutionOptions } from '../../types/sdk/agent'; import type { OpaqueSpanLink } from '../../types/telemetry'; import type { JSONValue } from '../../types/utils/json'; import { isZodSchema } from '../../utils/zod'; -import type { AgentRuntimeConfig } from '../loop/agent-runtime'; +import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime'; interface TelemetrySpan { end(): void; diff --git a/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-runner.ts b/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-runner.ts new file mode 100644 index 00000000000..1af1195b474 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-runner.ts @@ -0,0 +1,553 @@ +import { isRecord } from '@n8n/utils/is-record'; +import type { JSONSchema7 } from 'json-schema'; + +import { + assertSubAgentTaskPath, + createChildSubAgentTaskPath, + type SubAgentTaskPath, +} from './sub-agent-task-path'; +import { isAbortError } from '../../sdk/abort'; +import { filterLlmMessages } from '../../sdk/message'; +import { + DELEGATE_SUB_AGENT_TOOL_NAME, + DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + parseDelegateSubAgentContinuation, + type CreateDelegateSubAgentToolOptions, + type DelegateSubAgentCheckpointTarget, + type DelegateSubAgentContinuation, + type DelegateSubAgentInput, + type DelegateSubAgentPolicy, + type DelegateSubAgentRequest, + type DelegateSubAgentResumeRunner, + type DelegateSubAgentRunnerHelpers, + type DelegateSubAgentToolOutput, +} from '../../types/runtime/delegation'; +import { AgentEvent, type ForwardedChildChunk } from '../../types/runtime/event'; +import type { GenerateResult, StreamChunk, TokenUsage } from '../../types/sdk/agent'; +import type { AgentMessage } from '../../types/sdk/message'; +import type { + InterruptibleToolContext, + ToolCancellationContext, + ToolContext, +} from '../../types/sdk/tool'; +import type { JSONValue } from '../../types/utils/json'; +import { withoutMessageCount } from '../loop/execution-counter'; + +/** Cap on forwarded text+reasoning characters per delegation. */ +const SUB_AGENT_FORWARD_CHAR_BUDGET = 20_000; + +const FORWARDED_CHILD_CHUNK_TYPES = new Set([ + 'text-delta', + 'reasoning-start', + 'reasoning-delta', + 'reasoning-end', + 'tool-input-start', + 'tool-execution-start', + 'tool-execution-end', +]); + +function isForwardedChildChunk(chunk: StreamChunk): chunk is ForwardedChildChunk { + return FORWARDED_CHILD_CHUNK_TYPES.has(chunk.type as ForwardedChildChunk['type']); +} + +function createEmitChunkHelper( + ctx: ToolContext, + request: DelegateSubAgentRequest, +): (chunk: StreamChunk) => void { + let forwardedChars = 0; + return (chunk) => { + if (request.parentToolCallId === undefined) return; + if (!isForwardedChildChunk(chunk)) return; + + let forwarded: ForwardedChildChunk = chunk; + if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { + // Trim rather than pass through whole: a single provider delta can be + // arbitrarily large, and forwarding it intact would blow the budget. + const remaining = SUB_AGENT_FORWARD_CHAR_BUDGET - forwardedChars; + if (remaining <= 0) return; + const delta = chunk.delta.slice(0, remaining); + forwardedChars += delta.length; + forwarded = { ...chunk, delta }; + } + + ctx.emitEvent?.({ + type: AgentEvent.SubAgentChunk, + ...subAgentLifecycleBase(request), + chunk: forwarded, + }); + }; +} + +/** + * Tool handler: assign the child's task path, + * assemble the {@link DelegateSubAgentRequest} from the model input plus the + * parent tool context, then run the child via the host `runSubAgent` callback + * while emitting started/progress/completed lifecycle events. A failure is + * converted into a `status: 'failed'` output (never thrown) so one failed + * delegation can't abort the parent's run; an abort is rethrown, because a + * cancelled run must end the parent too. + */ +export async function handleDelegateSubAgent( + input: DelegateSubAgentInput, + ctx: ToolContext | InterruptibleToolContext, + options: CreateDelegateSubAgentToolOptions, + childPathIndexes: Map, +): Promise { + let taskPath: SubAgentTaskPath | undefined; + let request: DelegateSubAgentRequest | undefined; + let startedAt: number | undefined; + try { + if ( + options.resumeSubAgent !== undefined && + isInterruptibleToolContext(ctx) && + ctx.continuation !== undefined + ) { + const restored = restoreDelegateRequest( + input, + ctx, + ctx.continuation, + options.policy, + childPathIndexes, + ); + const { checkpoint } = restored; + taskPath = checkpoint.taskPath; + request = restored.request; + startedAt = Date.now(); + emitSubAgentStarted(ctx, request, startedAt); + return await resumeDelegatedChild(ctx, restored, startedAt, options, options.resumeSubAgent); + } + + const childPathIndexKey = getChildPathIndexKey(ctx); + const childPathIndex = childPathIndexes.get(childPathIndexKey) ?? 0; + + taskPath = createChildSubAgentTaskPath(input.taskName, childPathIndex); + childPathIndexes.set(childPathIndexKey, childPathIndex + 1); + + request = createDelegateSubAgentRequest(input, ctx, taskPath, childPathIndex, options.policy); + + startedAt = Date.now(); + emitSubAgentStarted(ctx, request, startedAt); + return await runDelegatedChild(ctx, request, startedAt, options); + } catch (error) { + return handleDelegateFailure(error, ctx, taskPath, request, startedAt); + } +} + +async function resumeDelegatedChild( + ctx: InterruptibleToolContext, + { request, checkpoint }: ReturnType, + startedAt: number, + options: CreateDelegateSubAgentToolOptions, + resumeSubAgent: DelegateSubAgentResumeRunner, +): Promise { + let output: DelegateSubAgentToolOutput; + try { + output = await resumeSubAgent( + { + ...request, + ...getChildCheckpointTarget(checkpoint), + resumeData: ctx.resumeData, + }, + createRunnerHelpers(ctx, request, options.name), + ); + } catch (error) { + if (ctx.abortSignal?.aborted || isAbortError(error)) throw error; + if (options.shouldRetrySubAgentResumeError?.(error) === false) throw error; + return await ctx.suspend(ctx.suspendPayload); + } + emitSubAgentCompleted(ctx, request, output, startedAt); + if (output.status === 'suspended') { + return await cascadeChildSuspension(ctx, request, output); + } + return output; +} + +async function runDelegatedChild( + ctx: ToolContext | InterruptibleToolContext, + request: DelegateSubAgentRequest, + startedAt: number, + options: CreateDelegateSubAgentToolOptions, +): Promise { + const toolName = options.name ?? DELEGATE_SUB_AGENT_TOOL_NAME; + if (!options.runSubAgent) { + throw new Error( + `${toolName} 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, + createRunnerHelpers(ctx, request, options.name), + ); + emitSubAgentCompleted(ctx, request, output, startedAt); + if ( + output.status === 'suspended' && + options.resumeSubAgent !== undefined && + isInterruptibleToolContext(ctx) + ) { + return await cascadeChildSuspension(ctx, request, output); + } + return output; +} + +function handleDelegateFailure( + error: unknown, + ctx: ToolContext, + taskPath: SubAgentTaskPath | undefined, + request: DelegateSubAgentRequest | undefined, + startedAt: number | undefined, +): DelegateSubAgentToolOutput { + // When the parent has a signal it is the authority: `isAbortError` also + // matches by message text, and an unrelated child error must not be + // mistaken for a cancellation and kill the parent run. + const aborted = ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(error); + const output: DelegateSubAgentToolOutput = { + status: aborted ? 'cancelled' : 'failed', + ...(taskPath !== undefined ? { taskPath } : {}), + answer: '', + ...(aborted ? {} : { error: stringifyUnknown(error) }), + }; + if (request !== undefined && startedAt !== undefined) { + emitSubAgentCompleted(ctx, request, output, startedAt); + } + if (aborted) throw error; + return output; +} + +function createDelegateSubAgentRequest( + input: DelegateSubAgentInput, + ctx: ToolContext, + taskPath: SubAgentTaskPath, + childCount: number, + policy?: DelegateSubAgentPolicy, +): DelegateSubAgentRequest { + return { + ...input, + taskPath, + childCount, + ...(ctx.runId !== undefined ? { parentRunId: ctx.runId } : {}), + ...(ctx.persistence?.threadId !== undefined + ? { parentThreadId: ctx.persistence.threadId } + : {}), + ...(ctx.persistence?.resourceId !== undefined + ? { parentResourceId: ctx.persistence.resourceId } + : {}), + ...(ctx.persistence?.hostMetadata !== undefined + ? { parentHostMetadata: ctx.persistence.hostMetadata } + : {}), + ...(ctx.abortSignal !== undefined ? { parentAbortSignal: ctx.abortSignal } : {}), + ...(ctx.toolCallId !== undefined ? { parentToolCallId: ctx.toolCallId } : {}), + ...(ctx.executionCounter !== undefined + ? { parentExecutionCounter: withoutMessageCount(ctx.executionCounter) } + : {}), + ...(ctx.parentTelemetry !== undefined ? { parentTelemetry: ctx.parentTelemetry } : {}), + ...(policy !== undefined ? { policy } : {}), + }; +} + +function isJsonSchemaObject(value: unknown): value is JSONSchema7 { + return isRecord(value); +} + +export async function cancelDelegatedSubAgent( + input: DelegateSubAgentInput, + ctx: ToolCancellationContext, + options: CreateDelegateSubAgentToolOptions, + childPathIndexes: Map, +): Promise { + if (options.cancelSubAgent === undefined || ctx.continuation === undefined) return; + const { checkpoint, request } = restoreDelegateRequest( + input, + ctx, + ctx.continuation, + options.policy, + childPathIndexes, + ); + await options.cancelSubAgent( + { + ...request, + ...getChildCheckpointTarget(checkpoint), + reason: ctx.cancellation.message, + }, + createRunnerHelpers(ctx, request, options.name), + ); +} + +function restoreDelegateRequest( + input: DelegateSubAgentInput, + ctx: ToolContext, + continuation: JSONValue, + policy: DelegateSubAgentPolicy | undefined, + childPathIndexes: Map, +): { + checkpoint: DelegateSubAgentContinuation & { taskPath: SubAgentTaskPath }; + request: DelegateSubAgentRequest; +} { + const checkpoint = parseDelegateSubAgentContinuation(continuation); + if (!checkpoint) { + throw new Error('Delegated child checkpoint metadata is missing or invalid'); + } + if (checkpoint.subAgentId !== input.subAgentId) { + throw new Error('Delegated child checkpoint does not match the selected sub-agent'); + } + const { taskPath } = checkpoint; + assertSubAgentTaskPath(taskPath); + const key = getChildPathIndexKey(ctx); + childPathIndexes.set(key, Math.max(childPathIndexes.get(key) ?? 0, checkpoint.childCount + 1)); + const request = createDelegateSubAgentRequest( + input, + ctx, + taskPath, + checkpoint.childCount, + policy, + ); + return { checkpoint: { ...checkpoint, taskPath }, request }; +} + +function getChildCheckpointTarget( + checkpoint: DelegateSubAgentContinuation, +): DelegateSubAgentCheckpointTarget { + return { + childRunId: checkpoint.runId, + childToolCallId: checkpoint.toolCallId, + ...(checkpoint.threadId !== undefined ? { childThreadId: checkpoint.threadId } : {}), + ...(checkpoint.resumeContext !== undefined ? { resumeContext: checkpoint.resumeContext } : {}), + }; +} + +function createRunnerHelpers( + ctx: ToolContext | InterruptibleToolContext, + request: DelegateSubAgentRequest, + configuredToolName?: string, +): DelegateSubAgentRunnerHelpers { + const toolName = configuredToolName ?? DELEGATE_SUB_AGENT_TOOL_NAME; + return { + runInlineSubAgent: () => { + throw new Error( + `${toolName} host runner does not support inline delegation without helpers.runInlineSubAgent from an Agent build.`, + ); + }, + emitChunk: createEmitChunkHelper(ctx, request), + }; +} + +function isInterruptibleToolContext( + ctx: ToolContext | InterruptibleToolContext, +): ctx is InterruptibleToolContext { + return 'suspend' in ctx; +} + +async function cascadeChildSuspension( + ctx: InterruptibleToolContext, + request: DelegateSubAgentRequest, + output: DelegateSubAgentToolOutput, +): Promise { + const suspension = output.pendingSuspend?.[0]; + if (!suspension || !isJsonSchemaObject(suspension.resumeSchema)) { + throw new Error(DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE); + } + + return await ctx.suspend(suspension.suspendPayload, { + resumeSchema: suspension.resumeSchema, + continuation: { + runId: suspension.runId, + toolCallId: suspension.toolCallId, + taskPath: request.taskPath, + subAgentId: request.subAgentId, + childCount: request.childCount, + ...(output.threadId !== undefined ? { threadId: output.threadId } : {}), + ...(output.resumeContext !== undefined ? { resumeContext: output.resumeContext } : {}), + }, + }); +} + +function emitSubAgentStarted( + ctx: ToolContext, + request: DelegateSubAgentRequest, + startedAt: number, +): void { + ctx.emitEvent?.({ + type: AgentEvent.SubAgentStarted, + ...subAgentLifecycleBase(request), + startedAt, + }); +} + +function emitSubAgentCompleted( + ctx: ToolContext, + request: DelegateSubAgentRequest, + output: DelegateSubAgentToolOutput, + startedAt: number, +): void { + const finishedAt = Date.now(); + ctx.emitEvent?.({ + type: AgentEvent.SubAgentCompleted, + ...subAgentLifecycleBase(request), + status: output.status, + startedAt, + finishedAt, + durationMs: finishedAt - startedAt, + ...(output.runId !== undefined ? { runId: output.runId } : {}), + ...(output.threadId !== undefined ? { threadId: output.threadId } : {}), + ...(output.usage !== undefined ? { usage: output.usage } : {}), + ...(output.finishReason !== undefined ? { finishReason: output.finishReason } : {}), + ...(output.model !== undefined ? { model: output.model } : {}), + ...(output.error !== undefined ? { error: output.error } : {}), + }); +} + +function subAgentLifecycleBase(request: DelegateSubAgentRequest) { + return { + taskName: request.taskName, + taskPath: request.taskPath, + ...(request.parentRunId !== undefined ? { parentRunId: request.parentRunId } : {}), + ...(request.parentToolCallId !== undefined + ? { parentToolCallId: request.parentToolCallId } + : {}), + ...(request.subAgentId !== undefined ? { subAgentId: request.subAgentId } : {}), + }; +} + +function getChildPathIndexKey(ctx: ToolContext): string { + return ctx.runId ?? ctx.persistence?.threadId ?? ctx.persistence?.resourceId ?? 'adhoc'; +} + +function stringifyUnknown(value: unknown): string { + if (value instanceof Error) return value.message; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || value === null) { + return String(value); + } + try { + return JSON.stringify(value) ?? 'Unknown error'; + } catch { + return 'Unknown error'; + } +} + +/** + * Optional helpers for a `runSubAgent` implementation. + * + * A host that runs the child by calling `agent.generate(...)`/`stream(...)` can + * reuse these instead of hand-rolling the delegation prompt and the result + * mapping. They are NOT wired into the tool — call them from your `runSubAgent` + * (the n8n CLI runner does). + */ + +/** Render the default delegation prompt from a request's goal / context / expectedOutput. */ +export function renderDelegateSubAgentPrompt(request: { + goal: string; + context?: string; + expectedOutput?: string; +}): string { + const sections = [ + 'You are a focused subagent working on a specific delegated task.', + `YOUR TASK:\n${request.goal}`, + ]; + + if (request.context) { + sections.push(`CONTEXT:\n${request.context}`); + } + + if (request.expectedOutput) { + sections.push(`EXPECTED OUTPUT:\n${request.expectedOutput}`); + } + + sections.push( + [ + '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) { + // An aborted child sets its state to `cancelled` but still reports an + // error, so the state decides which of the two this really was. + return result.getState().status === 'cancelled' ? 'cancelled' : '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, + model?: string, +): DelegateSubAgentToolOutput { + return { + status: 'failed', + taskPath, + answer: '', + error: DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + ...(model !== undefined ? { model } : {}), + }; +} + +/** 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, + taskPath, + runId: result.runId, + ...(threadId !== undefined ? { threadId } : {}), + ...(result.model !== undefined ? { model: result.model } : {}), + answer: lastText(result.messages), + ...(result.structuredOutput !== undefined ? { structuredOutput: result.structuredOutput } : {}), + ...(result.usage !== undefined + ? { + usage: toDelegateUsage(result.usage), + } + : {}), + ...(result.finishReason !== undefined ? { finishReason: result.finishReason } : {}), + ...(result.error !== undefined ? { error: stringifyUnknown(result.error) } : {}), + ...(status === 'suspended' && result.pendingSuspend !== undefined + ? { pendingSuspend: result.pendingSuspend } + : {}), + }; +} + +function toDelegateUsage(usage: TokenUsage): DelegateSubAgentToolOutput['usage'] { + return { + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + ...(usage.cost !== undefined ? { cost: usage.cost } : {}), + }; +} + +/** Last non-empty assistant text across the run's messages. */ +function lastText(messages: AgentMessage[]): string { + const llmMessages = filterLlmMessages(messages); + for (let i = llmMessages.length - 1; i >= 0; i--) { + const message = llmMessages[i]; + if (!message) continue; + + const text = message.content + .filter((content) => content.type === 'text') + .map((content) => content.text) + .join('\n') + .trim(); + if (text) return text; + } + + return ''; +} diff --git a/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-tool.ts b/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-tool.ts index 40ba669ee4a..03c22f131da 100644 --- a/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-tool.ts +++ b/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-tool.ts @@ -1,365 +1,58 @@ -import { isRecord } from '@n8n/utils/is-record'; -import type { JSONSchema7 } from 'json-schema'; -import { z } from 'zod'; +import type { z } from 'zod'; +import { cancelDelegatedSubAgent, handleDelegateSubAgent } from './delegate-sub-agent-runner'; import { withSdkOwnedBuiltInMetadata } from './sdk-owned-tool'; -import { - assertSubAgentTaskPath, - createChildSubAgentTaskPath, - DEFAULT_SUB_AGENT_MAX_CHILDREN, - type SubAgentTaskPath, - type SubAgentTaskPathPolicy, -} from './sub-agent-task-path'; -import { isAbortError } from '../../sdk/abort'; -import { filterLlmMessages } from '../../sdk/message'; +import { DEFAULT_SUB_AGENT_MAX_CHILDREN } from './sub-agent-task-path'; import { Tool } from '../../sdk/tool'; -import { AgentEvent } from '../../types/runtime/event'; -import type { ForwardedChildChunk } from '../../types/runtime/event'; +import { + DELEGATE_SUB_AGENT_TOOL_NAME, + INLINE_DELEGATE_SUB_AGENT_TOOL_METADATA_KEY, + delegateSubAgentInputSchema, + delegateSubAgentOutputSchema, + delegateSubAgentSuspendSchema, + delegateSubAgentResumeSchema, + type CreateDelegateSubAgentToolOptions, + type DelegateSubAgentInput, + type DelegateSubAgentPolicy, + type DelegateSubAgentToolMetadata, +} from '../../types/runtime/delegation'; import type { - AgentExecutionCounter, - FinishReason, - GenerateResult, - ModelConfig, - StreamChunk, - TokenUsage, -} from '../../types/sdk/agent'; -import type { AgentMessage } from '../../types/sdk/message'; -import type { - BuiltProviderTool, BuiltTool, InterruptibleToolContext, ToolCancellationContext, ToolContext, } from '../../types/sdk/tool'; -import type { BuiltTelemetry } from '../../types/telemetry'; -import type { JSONObject, JSONValue } from '../../types/utils/json'; -import { withoutMessageCount } from '../loop/execution-counter'; -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'; - -export const SUB_AGENT_TASK_DIFFICULTIES = ['low', 'medium', 'high'] as const; -const SubAgentTaskDifficultySchema = z.enum(SUB_AGENT_TASK_DIFFICULTIES); -export type SubAgentTaskDifficulty = z.infer; - -const jsonValueSchema: z.ZodType = z.lazy(() => - z.union([ - z.string(), - z.number(), - z.boolean(), - z.null(), - z.array(jsonValueSchema), - z.record(z.string(), jsonValueSchema), - ]), -); - -// Model-facing input: the arguments the LLM fills in when it calls the tool. -// The `.describe(...)` text is what the model reads, so keep it task-oriented. -const delegateSubAgentInputSchema = z.object({ - subAgentId: z - .string() - .min(1) - .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) - .describe('Short human-readable name for this delegated task, e.g. "research_api".'), - goal: z.string().min(1).describe('The concrete goal the sub-agent should accomplish.'), - context: z - .string() - .optional() - .describe( - 'All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.', - ), - expectedOutput: z.string().optional().describe('The expected shape or contents of the answer.'), - difficulty: SubAgentTaskDifficultySchema.optional().describe( - 'Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.', - ), -}); - -// Documents the tool result shape for typing/introspection. Note: the handler's -// returned object (not this schema) is what is actually sent back to the model, -// so this is kept in sync with DelegateSubAgentToolOutput by hand. -const delegateSubAgentOutputSchema = z.object({ - status: z.enum(['completed', 'failed', 'suspended', 'cancelled']), - taskPath: z.string().optional(), - runId: z.string().optional(), - threadId: z.string().optional(), - model: z.string().optional(), - answer: z.string(), - structuredOutput: z.unknown().optional(), - usage: z - .object({ - promptTokens: z.number().optional(), - completionTokens: z.number().optional(), - totalTokens: z.number().optional(), - cost: z.number().optional(), - }) - .optional(), - finishReason: z.string().optional(), - error: z.string().optional(), - resumeContext: jsonValueSchema.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(), -}); - -const delegateSubAgentContinuationSchema = z.object({ - runId: z.string(), - toolCallId: z.string(), - taskPath: z.string(), - subAgentId: z.string(), - childCount: z.number().int().nonnegative(), - threadId: z.string().optional(), - resumeContext: jsonValueSchema.optional(), -}); - -const delegateSubAgentSuspendSchema = z.unknown(); -const delegateSubAgentResumeSchema = z.unknown(); - -export type DelegateSubAgentContinuation = z.infer; - -export function parseDelegateSubAgentContinuation( - value: unknown, -): DelegateSubAgentContinuation | undefined { - const parsed = delegateSubAgentContinuationSchema.safeParse(value); - return parsed.success ? parsed.data : undefined; -} - -/** The arguments the LLM provides when calling delegate_subagent. */ -export type DelegateSubAgentInput = z.infer; - -/** - * Limits the delegate tool enforces structurally for a delegation: fan-out - * and the on/off switch (see {@link SubAgentTaskPathPolicy}). - * - * Per-run runtime constraints (e.g. a wall-clock timeout) are intentionally not - * here — they're a host concern, enforced inside the `runSubAgent` callback (as - * the n8n CLI runner does). - */ -export type DelegateSubAgentPolicy = SubAgentTaskPathPolicy; - -/** - * What a host's `runSubAgent` callback receives: the model's - * {@link DelegateSubAgentInput} plus runtime-derived context the host needs to - * run and link the child. All `parent*` fields come from the parent's tool - * execution context and are used for tracing/linkage, not required to run. - */ -export interface DelegateSubAgentRequest extends DelegateSubAgentInput { - /** 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; - /** Parent's persisted memory thread id (`ctx.persistence.threadId`). */ - parentThreadId?: string; - /** Parent's episodic-memory resource id (`ctx.persistence.resourceId`). */ - parentResourceId?: string; - /** Opaque host metadata from the parent's persistence scope. */ - parentHostMetadata?: JSONObject; - /** Parent's tool-call id that triggered this delegation. */ - parentToolCallId?: string; - /** - * Parent run's abort signal (`ctx.abortSignal`). Forward it to the child so - * cancelling the parent run also cancels the delegated work. - */ - parentAbortSignal?: AbortSignal; - /** Parent aggregate execution counter (`ctx.executionCounter`) for inline child accounting. */ - parentExecutionCounter?: AgentExecutionCounter; - /** - * Parent's live, resolved telemetry (`ctx.parentTelemetry`). Hosts derive the - * child's own telemetry from this (see `deriveSubAgentTelemetry`) so the - * sub-agent shares the parent's tracer and nests under the parent's - * delegate-tool-call span instead of starting a separate root trace. - */ - parentTelemetry?: BuiltTelemetry; - /** How many siblings the parent already spawned before this one (0-based). */ - childCount: number; - /** Effective policy for this delegation. */ - policy?: DelegateSubAgentPolicy; -} - -/** The result a delegation returns to the parent model and to lifecycle events. */ -export interface DelegateSubAgentToolOutput { - status: 'completed' | 'failed' | 'suspended' | 'cancelled'; - /** Echoed back so consumers can correlate the result with the delegation. */ - taskPath?: SubAgentTaskPath; - /** The child run's id, when the executor produced one. */ - runId?: string; - /** - * The child run's memory thread id (`persistence.threadId`), when the - * executor used one. Surfaced so a consumer can correlate the child run or - * re-supply it to continue the same thread on a later delegation. - */ - threadId?: string; - /** Effective child model id used for this delegation (e.g. `anthropic/claude-haiku-4-5`). */ - model?: string; - /** The child's answer — the main payload the parent acts on. */ - answer: string; - structuredOutput?: unknown; - /** Child token usage + cost, surfaced so the parent can account for it. */ - usage?: Pick; - finishReason?: FinishReason; - /** Present when status is 'failed'. */ - error?: string; - /** Host-owned, serializable context required to reconstruct this exact child. */ - resumeContext?: JSONValue; - /** Present when status is 'suspended' — child run paused awaiting tool resume. */ - pendingSuspend?: GenerateResult['pendingSuspend']; -} - -/** - * 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 local/deferred tool set. */ - runInlineSubAgent: (request: DelegateSubAgentRequest) => Promise; - /** - * Forward a child stream chunk into the parent run's event bus (allowlisted - * types only, with a per-delegation character budget). No-op when the - * parent tool call id is missing. - */ - emitChunk: (chunk: StreamChunk) => void; -} - -/** Cap on forwarded text+reasoning characters per delegation. */ -const SUB_AGENT_FORWARD_CHAR_BUDGET = 20_000; - -const FORWARDED_CHILD_CHUNK_TYPES = new Set([ - 'text-delta', - 'reasoning-start', - 'reasoning-delta', - 'reasoning-end', - 'tool-input-start', - 'tool-execution-start', - 'tool-execution-end', -]); - -function isForwardedChildChunk(chunk: StreamChunk): chunk is ForwardedChildChunk { - return FORWARDED_CHILD_CHUNK_TYPES.has(chunk.type as ForwardedChildChunk['type']); -} - -function createEmitChunkHelper( - ctx: ToolContext, - request: DelegateSubAgentRequest, -): (chunk: StreamChunk) => void { - let forwardedChars = 0; - return (chunk) => { - if (request.parentToolCallId === undefined) return; - if (!isForwardedChildChunk(chunk)) return; - - let forwarded: ForwardedChildChunk = chunk; - if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { - // Trim rather than pass through whole: a single provider delta can be - // arbitrarily large, and forwarding it intact would blow the budget. - const remaining = SUB_AGENT_FORWARD_CHAR_BUDGET - forwardedChars; - if (remaining <= 0) return; - const delta = chunk.delta.slice(0, remaining); - forwardedChars += delta.length; - forwarded = { ...chunk, delta }; - } - - ctx.emitEvent?.({ - type: AgentEvent.SubAgentChunk, - ...subAgentLifecycleBase(request), - chunk: forwarded, - }); - }; -} - -export type InlineSubAgentProviderToolsResolver = ( - modelConfig: ModelConfig, -) => BuiltProviderTool[] | Promise; - -export type DelegateSubAgentRunner = ( - request: DelegateSubAgentRequest, - helpers: DelegateSubAgentRunnerHelpers, -) => Promise; - -export interface DelegateSubAgentResumeRequest extends DelegateSubAgentRequest { - childRunId: string; - childToolCallId: string; - childThreadId?: string; - resumeContext?: JSONValue; - resumeData: unknown; -} - -export type DelegateSubAgentResumeRunner = ( - request: DelegateSubAgentResumeRequest, - helpers: DelegateSubAgentRunnerHelpers, -) => Promise; - -export interface DelegateSubAgentCancelRequest extends DelegateSubAgentRequest { - childRunId: string; - childToolCallId: string; - childThreadId?: string; - resumeContext?: JSONValue; - reason: string; -} - -export type DelegateSubAgentCancelRunner = ( - request: DelegateSubAgentCancelRequest, - helpers: DelegateSubAgentRunnerHelpers, -) => Promise; - -export interface CreateDelegateSubAgentToolOptions { - /** Model-facing tool name. Defaults to {@link DELEGATE_SUB_AGENT_TOOL_NAME}; the default description and system instruction adapt to it. */ - name?: string; - /** Model-facing tool description. Anything other than a non-blank string falls back to the built-in text. */ - description?: string | null; - /** System-prompt delegation guidance. Anything other than a non-blank string falls back to the built-in guidance. */ - systemInstruction?: string | null; - /** - * Sub-agents the model may choose between. Listed in the system prompt; the - * model selects one by passing its id as `subAgentId`. - */ - availableSubAgents?: Array<{ id: string; name: string; useWhen?: string }>; - /** Parallelism limit for delegated child runs (also used as delegate_subagent batch size). */ - policy?: DelegateSubAgentPolicy; - /** Additional local/deferred tool names the host removes from inline children. */ - inlineSubAgentBlockedTools?: string[]; - /** - * Resolved inline sub-agent models by task difficulty. Hosts map persisted config - * to runtime {@link ModelConfig} values before registering the delegate tool. - */ - inlineSubAgentModelsByDifficulty?: Partial>; - /** - * Resolve provider-defined tools for an inline child after its model has been chosen. - * Inline children do not inherit parent provider tools. - */ - resolveInlineSubAgentProviderTools?: InlineSubAgentProviderToolsResolver; - /** - * 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; - /** Resume a child checkpoint previously cascaded through this delegate tool. */ - resumeSubAgent?: DelegateSubAgentResumeRunner; - /** Return true only when a thrown child-resume error is safe to retry. */ - shouldRetrySubAgentResumeError?: (error: unknown) => boolean; - /** Clean up a child checkpoint when the parent cancels the suspended delegation. */ - cancelSubAgent?: DelegateSubAgentCancelRunner; - - toModelOutput?: (output: z.infer) => unknown; -} - -export type DelegateSubAgentToolMetadata = CreateDelegateSubAgentToolOptions; +export { + failedDelegatedChildSuspendOutput, + generateResultToDelegateSubAgentOutput, + renderDelegateSubAgentPrompt, +} from './delegate-sub-agent-runner'; +export { + DELEGATE_SUB_AGENT_TOOL_NAME, + INLINE_SUB_AGENT_ID, + DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, + INLINE_DELEGATE_SUB_AGENT_TOOL_METADATA_KEY, + SUB_AGENT_TASK_DIFFICULTIES, + parseDelegateSubAgentContinuation, +} from '../../types/runtime/delegation'; +export type { + SubAgentTaskDifficulty, + DelegateSubAgentContinuation, + DelegateSubAgentInput, + DelegateSubAgentPolicy, + DelegateSubAgentRequest, + DelegateSubAgentToolOutput, + DelegateSubAgentRunnerHelpers, + InlineSubAgentProviderToolsResolver, + DelegateSubAgentRunner, + DelegateSubAgentResumeRequest, + DelegateSubAgentResumeRunner, + DelegateSubAgentCancelRequest, + DelegateSubAgentCancelRunner, + CreateDelegateSubAgentToolOptions, + DelegateSubAgentToolMetadata, +} from '../../types/runtime/delegation'; function resolveDelegateSubAgentPolicy( policy: DelegateSubAgentPolicy | undefined, @@ -447,9 +140,25 @@ function resolveDelegateSubAgentSystemInstruction( * })); */ export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOptions = {}) { - // Per-parent child path index for stable task paths (/root/name_0, /root/name_1, ...). + // Each parent has its own index so child task paths stay stable. const childPathIndexes = new Map(); const toolName = resolveDelegateSubAgentToolName(options.name); + const resolvedOptions = resolveDelegateSubAgentOptions(options, toolName); + const tool = buildDelegateSubAgentTool(toolName, resolvedOptions, childPathIndexes); + return withSdkOwnedBuiltInMetadata({ + ...tool, + ...createDelegateCancellationHandler(resolvedOptions, childPathIndexes), + metadata: { + ...tool.metadata, + [INLINE_DELEGATE_SUB_AGENT_TOOL_METADATA_KEY]: buildDelegateToolMetadata(resolvedOptions), + }, + }); +} + +function resolveDelegateSubAgentOptions( + options: CreateDelegateSubAgentToolOptions, + toolName: string, +): CreateDelegateSubAgentToolOptions { const resolvedOptions: CreateDelegateSubAgentToolOptions = { ...options, policy: resolveDelegateSubAgentPolicy(options.policy, toolName), @@ -462,6 +171,14 @@ export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOp `${toolName} requires resumeSubAgent and cancelSubAgent to be configured together`, ); } + return resolvedOptions; +} + +function buildDelegateSubAgentTool( + toolName: string, + resolvedOptions: CreateDelegateSubAgentToolOptions, + childPathIndexes: Map, +) { const inlineProviderToolInstruction = resolvedOptions.resolveInlineSubAgentProviderTools ? "Provider-defined tools are loaded for the inline child's selected model provider." : 'Inline children do not inherit provider-defined tools.'; @@ -483,73 +200,77 @@ export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOp ) => await handleDelegateSubAgent(input, ctx, resolvedOptions, childPathIndexes); const toModelOutput = (output: z.infer) => resolvedOptions.toModelOutput ? resolvedOptions.toModelOutput(output) : output; - const tool = resolvedOptions.resumeSubAgent - ? toolBuilder - .suspend(delegateSubAgentSuspendSchema) - .resume(delegateSubAgentResumeSchema) - .handler(handler) - .toModelOutput(toModelOutput) - .build() - : toolBuilder.handler(handler).toModelOutput(toModelOutput).build(); - return withSdkOwnedBuiltInMetadata({ - ...tool, - ...(resolvedOptions.cancelSubAgent !== undefined + if (resolvedOptions.resumeSubAgent) { + return toolBuilder + .suspend(delegateSubAgentSuspendSchema) + .resume(delegateSubAgentResumeSchema) + .handler(handler) + .toModelOutput(toModelOutput) + .build(); + } + return toolBuilder.handler(handler).toModelOutput(toModelOutput).build(); +} + +function createDelegateCancellationHandler( + options: CreateDelegateSubAgentToolOptions, + childPathIndexes: Map, +): Pick { + if (options.cancelSubAgent === undefined) return {}; + return { + onCancellation: async (rawInput: unknown, ctx: ToolCancellationContext) => { + const parsedInput = delegateSubAgentInputSchema.safeParse(rawInput); + if (!parsedInput.success) { + throw new Error('Delegated child input is missing or invalid'); + } + await cancelDelegatedSubAgent(parsedInput.data, ctx, options, childPathIndexes); + }, + }; +} + +function buildDelegateToolMetadata( + resolvedOptions: CreateDelegateSubAgentToolOptions, +): DelegateSubAgentToolMetadata { + return { + ...(resolvedOptions.name !== undefined ? { name: resolvedOptions.name } : {}), + ...(resolvedOptions.availableSubAgents !== undefined + ? { availableSubAgents: resolvedOptions.availableSubAgents } + : {}), + policy: resolvedOptions.policy, + ...(resolvedOptions.inlineSubAgentBlockedTools !== undefined + ? { inlineSubAgentBlockedTools: resolvedOptions.inlineSubAgentBlockedTools } + : {}), + ...(resolvedOptions.inlineSubAgentModelsByDifficulty !== undefined ? { - onCancellation: async (rawInput: unknown, ctx: ToolCancellationContext) => { - const parsedInput = delegateSubAgentInputSchema.safeParse(rawInput); - if (!parsedInput.success) { - throw new Error('Delegated child input is missing or invalid'); - } - await cancelDelegatedSubAgent(parsedInput.data, ctx, resolvedOptions, childPathIndexes); - }, + inlineSubAgentModelsByDifficulty: resolvedOptions.inlineSubAgentModelsByDifficulty, } : {}), - metadata: { - ...tool.metadata, - [INLINE_DELEGATE_SUB_AGENT_TOOL_METADATA_KEY]: { - ...(resolvedOptions.name !== undefined ? { name: resolvedOptions.name } : {}), - ...(resolvedOptions.availableSubAgents !== undefined - ? { availableSubAgents: resolvedOptions.availableSubAgents } - : {}), - policy: resolvedOptions.policy, - ...(resolvedOptions.inlineSubAgentBlockedTools !== undefined - ? { inlineSubAgentBlockedTools: resolvedOptions.inlineSubAgentBlockedTools } - : {}), - ...(resolvedOptions.inlineSubAgentModelsByDifficulty !== undefined - ? { - inlineSubAgentModelsByDifficulty: resolvedOptions.inlineSubAgentModelsByDifficulty, - } - : {}), - ...(resolvedOptions.resolveInlineSubAgentProviderTools !== undefined - ? { - resolveInlineSubAgentProviderTools: - resolvedOptions.resolveInlineSubAgentProviderTools, - } - : {}), - ...(resolvedOptions.runSubAgent !== undefined - ? { runSubAgent: resolvedOptions.runSubAgent } - : {}), - ...(resolvedOptions.resumeSubAgent !== undefined - ? { resumeSubAgent: resolvedOptions.resumeSubAgent } - : {}), - ...(resolvedOptions.shouldRetrySubAgentResumeError !== undefined - ? { shouldRetrySubAgentResumeError: resolvedOptions.shouldRetrySubAgentResumeError } - : {}), - ...(resolvedOptions.cancelSubAgent !== undefined - ? { cancelSubAgent: resolvedOptions.cancelSubAgent } - : {}), - ...(resolvedOptions.systemInstruction !== undefined - ? { systemInstruction: resolvedOptions.systemInstruction } - : {}), - ...(resolvedOptions.description !== undefined - ? { description: resolvedOptions.description } - : {}), - ...(resolvedOptions.toModelOutput !== undefined - ? { toModelOutput: resolvedOptions.toModelOutput } - : {}), - } satisfies DelegateSubAgentToolMetadata, - }, - }); + ...(resolvedOptions.resolveInlineSubAgentProviderTools !== undefined + ? { + resolveInlineSubAgentProviderTools: resolvedOptions.resolveInlineSubAgentProviderTools, + } + : {}), + ...(resolvedOptions.runSubAgent !== undefined + ? { runSubAgent: resolvedOptions.runSubAgent } + : {}), + ...(resolvedOptions.resumeSubAgent !== undefined + ? { resumeSubAgent: resolvedOptions.resumeSubAgent } + : {}), + ...(resolvedOptions.shouldRetrySubAgentResumeError !== undefined + ? { shouldRetrySubAgentResumeError: resolvedOptions.shouldRetrySubAgentResumeError } + : {}), + ...(resolvedOptions.cancelSubAgent !== undefined + ? { cancelSubAgent: resolvedOptions.cancelSubAgent } + : {}), + ...(resolvedOptions.systemInstruction !== undefined + ? { systemInstruction: resolvedOptions.systemInstruction } + : {}), + ...(resolvedOptions.description !== undefined + ? { description: resolvedOptions.description } + : {}), + ...(resolvedOptions.toModelOutput !== undefined + ? { toModelOutput: resolvedOptions.toModelOutput } + : {}), + }; } export function getInlineDelegateSubAgentToolOptions( @@ -595,442 +316,3 @@ function formatDelegationPolicyInstructions( ].join('\n'), ]; } - -/** - * Tool handler: assign the child's task path, - * assemble the {@link DelegateSubAgentRequest} from the model input plus the - * parent tool context, then run the child via the host `runSubAgent` callback - * while emitting started/progress/completed lifecycle events. A failure is - * converted into a `status: 'failed'` output (never thrown) so one failed - * delegation can't abort the parent's run; an abort is rethrown, because a - * cancelled run must end the parent too. - */ -async function handleDelegateSubAgent( - input: DelegateSubAgentInput, - ctx: ToolContext | InterruptibleToolContext, - options: CreateDelegateSubAgentToolOptions, - childPathIndexes: Map, -): Promise { - let taskPath: SubAgentTaskPath | undefined; - let request: DelegateSubAgentRequest | undefined; - let startedAt: number | undefined; - try { - if ( - options.resumeSubAgent !== undefined && - isInterruptibleToolContext(ctx) && - ctx.continuation !== undefined - ) { - const restored = restoreDelegateRequest( - input, - ctx, - ctx.continuation, - options.policy, - childPathIndexes, - ); - const { checkpoint } = restored; - taskPath = checkpoint.taskPath; - request = restored.request; - startedAt = Date.now(); - emitSubAgentStarted(ctx, request, startedAt); - let output: DelegateSubAgentToolOutput; - try { - output = await options.resumeSubAgent( - { - ...request, - ...getChildCheckpointTarget(checkpoint), - resumeData: ctx.resumeData, - }, - createRunnerHelpers(ctx, request, options.name), - ); - } catch (error) { - if (ctx.abortSignal?.aborted || isAbortError(error)) throw error; - if (options.shouldRetrySubAgentResumeError?.(error) === false) throw error; - return await ctx.suspend(ctx.suspendPayload); - } - emitSubAgentCompleted(ctx, request, output, startedAt); - if (output.status === 'suspended') { - return await cascadeChildSuspension(ctx, request, output); - } - return output; - } - - const childPathIndexKey = getChildPathIndexKey(ctx); - const childPathIndex = childPathIndexes.get(childPathIndexKey) ?? 0; - - taskPath = createChildSubAgentTaskPath(input.taskName, childPathIndex); - childPathIndexes.set(childPathIndexKey, childPathIndex + 1); - - request = createDelegateSubAgentRequest(input, ctx, taskPath, childPathIndex, options.policy); - - startedAt = Date.now(); - emitSubAgentStarted(ctx, request, startedAt); - const toolName = options.name ?? DELEGATE_SUB_AGENT_TOOL_NAME; - if (!options.runSubAgent) { - throw new Error( - `${toolName} 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, - createRunnerHelpers(ctx, request, options.name), - ); - emitSubAgentCompleted(ctx, request, output, startedAt); - if ( - output.status === 'suspended' && - options.resumeSubAgent !== undefined && - isInterruptibleToolContext(ctx) - ) { - return await cascadeChildSuspension(ctx, request, output); - } - return output; - } catch (error) { - // When the parent has a signal it is the authority: `isAbortError` also - // matches by message text, and an unrelated child error must not be - // mistaken for a cancellation and kill the parent run. - const aborted = ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(error); - const output: DelegateSubAgentToolOutput = { - status: aborted ? 'cancelled' : 'failed', - ...(taskPath !== undefined ? { taskPath } : {}), - answer: '', - ...(aborted ? {} : { error: stringifyUnknown(error) }), - }; - if (request !== undefined && startedAt !== undefined) { - emitSubAgentCompleted(ctx, request, output, startedAt); - } - if (aborted) throw error; - return output; - } -} - -function createDelegateSubAgentRequest( - input: DelegateSubAgentInput, - ctx: ToolContext, - taskPath: SubAgentTaskPath, - childCount: number, - policy?: DelegateSubAgentPolicy, -): DelegateSubAgentRequest { - return { - ...input, - taskPath, - childCount, - ...(ctx.runId !== undefined ? { parentRunId: ctx.runId } : {}), - ...(ctx.persistence?.threadId !== undefined - ? { parentThreadId: ctx.persistence.threadId } - : {}), - ...(ctx.persistence?.resourceId !== undefined - ? { parentResourceId: ctx.persistence.resourceId } - : {}), - ...(ctx.persistence?.hostMetadata !== undefined - ? { parentHostMetadata: ctx.persistence.hostMetadata } - : {}), - ...(ctx.abortSignal !== undefined ? { parentAbortSignal: ctx.abortSignal } : {}), - ...(ctx.toolCallId !== undefined ? { parentToolCallId: ctx.toolCallId } : {}), - ...(ctx.executionCounter !== undefined - ? { parentExecutionCounter: withoutMessageCount(ctx.executionCounter) } - : {}), - ...(ctx.parentTelemetry !== undefined ? { parentTelemetry: ctx.parentTelemetry } : {}), - ...(policy !== undefined ? { policy } : {}), - }; -} - -function isJsonSchemaObject(value: unknown): value is JSONSchema7 { - return isRecord(value); -} - -async function cancelDelegatedSubAgent( - input: DelegateSubAgentInput, - ctx: ToolCancellationContext, - options: CreateDelegateSubAgentToolOptions, - childPathIndexes: Map, -): Promise { - if (options.cancelSubAgent === undefined || ctx.continuation === undefined) return; - const { checkpoint, request } = restoreDelegateRequest( - input, - ctx, - ctx.continuation, - options.policy, - childPathIndexes, - ); - await options.cancelSubAgent( - { - ...request, - ...getChildCheckpointTarget(checkpoint), - reason: ctx.cancellation.message, - }, - createRunnerHelpers(ctx, request, options.name), - ); -} - -function restoreDelegateRequest( - input: DelegateSubAgentInput, - ctx: ToolContext, - continuation: JSONValue, - policy: DelegateSubAgentPolicy | undefined, - childPathIndexes: Map, -): { - checkpoint: DelegateSubAgentContinuation & { taskPath: SubAgentTaskPath }; - request: DelegateSubAgentRequest; -} { - const checkpoint = parseDelegateSubAgentContinuation(continuation); - if (!checkpoint) { - throw new Error('Delegated child checkpoint metadata is missing or invalid'); - } - if (checkpoint.subAgentId !== input.subAgentId) { - throw new Error('Delegated child checkpoint does not match the selected sub-agent'); - } - const { taskPath } = checkpoint; - assertSubAgentTaskPath(taskPath); - const key = getChildPathIndexKey(ctx); - childPathIndexes.set(key, Math.max(childPathIndexes.get(key) ?? 0, checkpoint.childCount + 1)); - const request = createDelegateSubAgentRequest( - input, - ctx, - taskPath, - checkpoint.childCount, - policy, - ); - return { checkpoint: { ...checkpoint, taskPath }, request }; -} - -function getChildCheckpointTarget(checkpoint: DelegateSubAgentContinuation) { - return { - childRunId: checkpoint.runId, - childToolCallId: checkpoint.toolCallId, - ...(checkpoint.threadId !== undefined ? { childThreadId: checkpoint.threadId } : {}), - ...(checkpoint.resumeContext !== undefined ? { resumeContext: checkpoint.resumeContext } : {}), - }; -} - -function createRunnerHelpers( - ctx: ToolContext | InterruptibleToolContext, - request: DelegateSubAgentRequest, - configuredToolName?: string, -): DelegateSubAgentRunnerHelpers { - const toolName = configuredToolName ?? DELEGATE_SUB_AGENT_TOOL_NAME; - return { - runInlineSubAgent: () => { - throw new Error( - `${toolName} host runner does not support inline delegation without helpers.runInlineSubAgent from an Agent build.`, - ); - }, - emitChunk: createEmitChunkHelper(ctx, request), - }; -} - -function isInterruptibleToolContext( - ctx: ToolContext | InterruptibleToolContext, -): ctx is InterruptibleToolContext { - return 'suspend' in ctx; -} - -async function cascadeChildSuspension( - ctx: InterruptibleToolContext, - request: DelegateSubAgentRequest, - output: DelegateSubAgentToolOutput, -): Promise { - const suspension = output.pendingSuspend?.[0]; - if (!suspension || !isJsonSchemaObject(suspension.resumeSchema)) { - throw new Error(DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE); - } - - return await ctx.suspend(suspension.suspendPayload, { - resumeSchema: suspension.resumeSchema, - continuation: { - runId: suspension.runId, - toolCallId: suspension.toolCallId, - taskPath: request.taskPath, - subAgentId: request.subAgentId, - childCount: request.childCount, - ...(output.threadId !== undefined ? { threadId: output.threadId } : {}), - ...(output.resumeContext !== undefined ? { resumeContext: output.resumeContext } : {}), - }, - }); -} - -function emitSubAgentStarted( - ctx: ToolContext, - request: DelegateSubAgentRequest, - startedAt: number, -): void { - ctx.emitEvent?.({ - type: AgentEvent.SubAgentStarted, - ...subAgentLifecycleBase(request), - startedAt, - }); -} - -function emitSubAgentCompleted( - ctx: ToolContext, - request: DelegateSubAgentRequest, - output: DelegateSubAgentToolOutput, - startedAt: number, -): void { - const finishedAt = Date.now(); - ctx.emitEvent?.({ - type: AgentEvent.SubAgentCompleted, - ...subAgentLifecycleBase(request), - status: output.status, - startedAt, - finishedAt, - durationMs: finishedAt - startedAt, - ...(output.runId !== undefined ? { runId: output.runId } : {}), - ...(output.threadId !== undefined ? { threadId: output.threadId } : {}), - ...(output.usage !== undefined ? { usage: output.usage } : {}), - ...(output.finishReason !== undefined ? { finishReason: output.finishReason } : {}), - ...(output.model !== undefined ? { model: output.model } : {}), - ...(output.error !== undefined ? { error: output.error } : {}), - }); -} - -function subAgentLifecycleBase(request: DelegateSubAgentRequest) { - return { - taskName: request.taskName, - taskPath: request.taskPath, - ...(request.parentRunId !== undefined ? { parentRunId: request.parentRunId } : {}), - ...(request.parentToolCallId !== undefined - ? { parentToolCallId: request.parentToolCallId } - : {}), - ...(request.subAgentId !== undefined ? { subAgentId: request.subAgentId } : {}), - }; -} - -function getChildPathIndexKey(ctx: ToolContext): string { - return ctx.runId ?? ctx.persistence?.threadId ?? ctx.persistence?.resourceId ?? 'adhoc'; -} - -function stringifyUnknown(value: unknown): string { - if (value instanceof Error) return value.message; - if (typeof value === 'string') return value; - if (typeof value === 'number' || typeof value === 'boolean' || value === null) { - return String(value); - } - try { - return JSON.stringify(value); - } catch { - return 'Unknown error'; - } -} - -/** - * Optional helpers for a `runSubAgent` implementation. - * - * A host that runs the child by calling `agent.generate(...)`/`stream(...)` can - * reuse these instead of hand-rolling the delegation prompt and the result - * mapping. They are NOT wired into the tool — call them from your `runSubAgent` - * (the n8n CLI runner does). - */ - -/** Render the default delegation prompt from a request's goal / context / expectedOutput. */ -export function renderDelegateSubAgentPrompt(request: { - goal: string; - context?: string; - expectedOutput?: string; -}): string { - const sections = [ - 'You are a focused subagent working on a specific delegated task.', - `YOUR TASK:\n${request.goal}`, - ]; - - if (request.context) { - sections.push(`CONTEXT:\n${request.context}`); - } - - if (request.expectedOutput) { - sections.push(`EXPECTED OUTPUT:\n${request.expectedOutput}`); - } - - sections.push( - [ - '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) { - // An aborted child sets its state to `cancelled` but still reports an - // error, so the state decides which of the two this really was. - return result.getState().status === 'cancelled' ? 'cancelled' : '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, - model?: string, -): DelegateSubAgentToolOutput { - return { - status: 'failed', - taskPath, - answer: '', - error: DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE, - ...(model !== undefined ? { model } : {}), - }; -} - -/** 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, - taskPath, - runId: result.runId, - ...(threadId !== undefined ? { threadId } : {}), - ...(result.model !== undefined ? { model: result.model } : {}), - answer: lastText(result.messages), - ...(result.structuredOutput !== undefined ? { structuredOutput: result.structuredOutput } : {}), - ...(result.usage !== undefined - ? { - usage: { - promptTokens: result.usage.promptTokens, - completionTokens: result.usage.completionTokens, - totalTokens: result.usage.totalTokens, - ...(result.usage.cost !== undefined ? { cost: result.usage.cost } : {}), - }, - } - : {}), - ...(result.finishReason !== undefined ? { finishReason: result.finishReason } : {}), - ...(result.error !== undefined ? { error: stringifyUnknown(result.error) } : {}), - ...(status === 'suspended' && result.pendingSuspend !== undefined - ? { pendingSuspend: result.pendingSuspend } - : {}), - }; -} - -/** Last non-empty assistant text across the run's messages. */ -function lastText(messages: AgentMessage[]): string { - const llmMessages = filterLlmMessages(messages); - for (let i = llmMessages.length - 1; i >= 0; i--) { - const message = llmMessages[i]; - if (!message) continue; - - const text = message.content - .filter((content) => content.type === 'text') - .map((content) => content.text) - .join('\n') - .trim(); - if (text) return text; - } - - return ''; -} diff --git a/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts b/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts index 41fe09bac1f..ae58c6ff8dd 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts @@ -117,42 +117,14 @@ export async function executeTool( throw new Error(`No handler found for tool "${builtTool.name}"`); } - // Anchor programmatic skill activations to the calling tool's result by - // default, so the skill body rides on that result (kept out of `system`) - // instead of forcing a full prompt-prefix rewrite. An explicit anchor wins. - const baseLoadSkill = executionContext.loadSkill; - const loadSkill: ToolExecutionContext['loadSkill'] = baseLoadSkill - ? async (skillId, anchor) => - await baseLoadSkill(skillId, anchor ?? (toolCallId ? { toolCallId } : undefined)) - : undefined; - + const loadSkill = anchorSkillLoader(executionContext.loadSkill, toolCallId); if (builtTool.suspendSchema) { const isCancelled = isCancellation(resumeData); const ctx: InterruptibleToolContext = { - suspend: async (payload: unknown, options?: ToolSuspendOptions): Promise => { - const resolvedOptions: ToolSuspendOptions = { - continuation: executionContext.continuation, - ...options, - resumeSchema: options?.resumeSchema ?? executionContext.resumeSchema, - }; - await executionContext.onSuspend?.(payload, resolvedOptions); - return await Promise.resolve({ - [SUSPEND_BRAND]: true, - payload, - ...resolvedOptions, - } as never); - }, + suspend: createSuspendHandler(executionContext), resumeData: isCancelled ? undefined : resumeData, cancellation: isCancelled ? { message: resumeData.message } : undefined, - parentTelemetry, - toolCallId, - toolName: builtTool.name, - runId: executionContext.runId, - ...(loadSkill ? { loadSkill } : {}), - persistence: executionContext.persistence, - emitEvent: executionContext.emitEvent, - abortSignal: executionContext.abortSignal, - executionCounter: executionContext.executionCounter, + ...createToolContext(builtTool, executionContext, parentTelemetry, toolCallId, loadSkill), suspendPayload: executionContext.suspendPayload, continuation: executionContext.continuation, resumeSchema: executionContext.resumeSchema, @@ -160,7 +132,34 @@ export async function executeTool( return await builtTool.handler(args, ctx); } - const ctx: ToolContext = { + const ctx = createToolContext( + builtTool, + executionContext, + parentTelemetry, + toolCallId, + loadSkill, + ); + return await builtTool.handler(args, ctx); +} + +function anchorSkillLoader( + loadSkill: ToolExecutionContext['loadSkill'], + toolCallId: string | undefined, +): ToolExecutionContext['loadSkill'] { + if (!loadSkill) return undefined; + // Keep skill content on the tool result. An explicit anchor takes precedence. + return async (skillId, anchor) => + await loadSkill(skillId, anchor ?? (toolCallId ? { toolCallId } : undefined)); +} + +function createToolContext( + builtTool: BuiltTool, + executionContext: ToolExecutionContext, + parentTelemetry: BuiltTelemetry | undefined, + toolCallId: string | undefined, + loadSkill: ToolExecutionContext['loadSkill'], +): ToolContext { + return { parentTelemetry, toolCallId, toolName: builtTool.name, @@ -171,7 +170,24 @@ export async function executeTool( abortSignal: executionContext.abortSignal, executionCounter: executionContext.executionCounter, }; - return await builtTool.handler(args, ctx); +} + +function createSuspendHandler( + executionContext: ToolExecutionContext, +): InterruptibleToolContext['suspend'] { + return async (payload: unknown, options?: ToolSuspendOptions): Promise => { + const resolvedOptions: ToolSuspendOptions = { + continuation: executionContext.continuation, + ...options, + resumeSchema: options?.resumeSchema ?? executionContext.resumeSchema, + }; + await executionContext.onSuspend?.(payload, resolvedOptions); + return await Promise.resolve({ + [SUSPEND_BRAND]: true, + payload, + ...resolvedOptions, + } as never); + }; } /** diff --git a/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts b/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts index 95ea6bd6338..cbe3b908bb3 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts @@ -1,203 +1,43 @@ -import { zodSchemaToJsonSchema } from '@n8n/ai-utilities/json-schema'; -import type { JSONSchema7 } from 'json-schema'; - import { getInlineDelegateSubAgentToolOptions, isDelegateSubAgentTool, } from './delegate-sub-agent-tool'; import { DEFAULT_SUB_AGENT_MAX_CHILDREN } from './sub-agent-task-path'; -import { executeTool, isSuspendedToolResult, type SuspendedToolResult } from './tool-adapter'; -import { - guardToolErrorForModel, - guardToolMessageForModel, - guardToolResultForModel, - type ToolResultGuardStorage, -} from './tool-result-guard'; -import { - protectUntrustedToolError, - protectUntrustedToolMessage, - protectUntrustedToolResult, -} from './untrusted-tool-output'; -import { isAbortError, raceWithAbort } from '../../sdk/abort'; -import { isCancellation } from '../../sdk/cancellation'; -import { isLlmMessage } from '../../sdk/message'; -import type { RuntimeSkillLoader } from '../../skills/types'; +import { ToolCallRunner } from './tool-call-runner'; +import type { BuiltTool, PendingToolCall } from '../../types'; import type { - AgentExecutionCounter, - BuiltTelemetry, - BuiltTool, - PendingToolCall, - ToolSuspendOptions, -} from '../../types'; -import { AgentEvent } from '../../types/runtime/event'; -import type { AgentPersistenceOptions, ToolResultEntry } from '../../types/sdk/agent'; -import type { AgentMessage, ContentToolCall, Message } from '../../types/sdk/message'; -import type { JSONObject, JSONValue } from '../../types/utils/json'; -import { parseWithSchema } from '../../utils/parse'; -import { isZodSchema } from '../../utils/zod'; -import type { WorkspaceFilesystem } from '../../workspace/types'; + ProcessToolCallParams, + ResumeToolBatchContext, + ToolBatchContext, + ToolCallBatchResult, + ToolCallError, + ToolCallExecutorDeps, + ToolCallIdentity, + ToolCallInput, + ToolCallOutcome, + ToolCallSuccess, + ToolCallSuspension, +} from '../../types/runtime/tool-execution'; +import type { JSONObject } from '../../types/utils/json'; import { incrementToolCallCount } from '../loop/execution-counter'; -import { stringifyError } from '../loop/runtime-helpers'; import type { AgentMessageList } from '../model/message-list'; import { normalizeToolInputForModel } from '../model/messages'; -import type { TokenCounter } from '../model/model-token-counter'; -import type { AgentEventBus } from '../state/event-bus'; -import type { RuntimeTelemetry } from '../telemetry/runtime-telemetry'; -/** Pending tool calls from a suspended run, passed into the loop to execute before the first LLM call. */ -export interface PendingResume { - pendingToolCalls: Record; - /** The tool call being resumed with new data. */ - resumeToolCallId: string; - resumeData: unknown; -} +export type { + PendingResume, + ToolCallSuccess, + ToolCallSuspension, + ToolCallError, + ToolCallBatchResult, + ToolBatchContext, + ToolCallExecutorDeps, +} from '../../types/runtime/tool-execution'; -type ToolCallOutcome = - | { - outcome: 'success'; - toolEntry: ToolResultEntry; - /** - * Output as the LLM sees it (after `toModelOutput`). Same as - * `toolEntry.output` when no `toModelOutput` transform is configured. - * Surfaced on the `tool-result` wire chunk so consumers see what the - * LLM saw (rather than the larger raw output). - */ - modelOutput: unknown; - customMessage?: AgentMessage; - mcpServerName?: string; - } - | { - outcome: 'suspended'; - payload: unknown; - resumeSchema: JSONSchema7; - continuation?: JSONValue; - } - | { - outcome: 'cancelled'; - toolEntry: ToolResultEntry; - modelOutput: string; - userMessage: string; - canceled: true; - } - | { outcome: 'retryable-error' } - | { outcome: 'error'; error: unknown } - | { outcome: 'noop' }; // tool call shouldn't be saved or logged anywhere, usually means that if was executed by AI SDK - -/** A tool call that completed successfully. */ -export interface ToolCallSuccess { - toolCallId: string; - toolName: string; - input: JSONValue; - toolEntry: ToolResultEntry; - modelOutput: unknown; - customMessage?: AgentMessage; - /** Set when the tool belongs to an MCP server, so hosts can attribute the result to it. */ - mcpServerName?: string; -} - -/** Info about a tool call that suspended (before persistence — no runId yet). */ -export interface ToolCallSuspension { - toolCallId: string; - toolName: string; - input: JSONValue; - payload: unknown; - /** JSON Schema describing the shape of resume data, derived from the tool's resumeSchema. */ - resumeSchema: JSONSchema7; -} - -/** Info about a tool call that failed — carries enough data for stream chunks. */ -export interface ToolCallError { - toolCallId: string; - toolName: string; - input: JSONValue; - error: unknown; -} - -/** Result of executing a batch of tool calls (before persistence). */ -export interface ToolCallBatchResult { - results: ToolCallSuccess[]; - suspensions: ToolCallSuspension[]; - errors: ToolCallError[]; - /** All items to persist: suspended tools (with suspendPayload) + unexecuted tools (without). */ - pending: Record; -} - -interface RuntimeToolCall { - toolCallId: string; - toolName: string; +interface RuntimeToolCall extends ToolCallInput { input: JSONObject; - providerExecuted?: boolean; } -/** Shared input for the tool-call batch iterators. */ -export interface ToolBatchContext { - toolMap: Map; - list: AgentMessageList; - runId: string; - persistence?: AgentPersistenceOptions; - telemetry?: BuiltTelemetry; - executionCounter?: AgentExecutionCounter; - abortSignal: AbortSignal; - isAborted: () => boolean; -} - -/** A tool-call content block that has already been settled by the AI SDK. */ -type SettledToolCall = ContentToolCall & { state: 'resolved' | 'rejected' }; - -/** Inputs for executing a single tool call. */ -interface ProcessToolCallParams { - toolCallId: string; - toolName: string; - input: JSONValue; - toolMap: Map; - list: AgentMessageList; - runId: string; - persistence?: AgentPersistenceOptions; - resumeData?: unknown; - resolvedTelemetry?: BuiltTelemetry; - executionCounter?: AgentExecutionCounter; - abortSignal?: AbortSignal; - /** Whether this counts as a new tool-call invocation. Default `true`; `false` on resume. */ - countToolCall?: boolean; - /** Checkpointed suspend payload of the tool call being resumed. */ - suspendPayload?: unknown; - /** Checkpointed private continuation of the tool call being resumed. */ - continuation?: JSONValue; - /** Checkpointed resume schema of the tool call being resumed. */ - resumeSchema?: ToolSuspendOptions['resumeSchema']; -} - -function isDeniedApprovalResumeData(value: unknown): boolean { - return value !== null && typeof value === 'object' && Reflect.get(value, 'approved') === false; -} - -function shouldEmitToolExecutionStart(tool: BuiltTool, resumeData: unknown): boolean { - if (!tool.approval) return true; - if (!tool.approval.required && tool.approval.conditional !== true) return true; - if (resumeData === undefined) return false; - return !isDeniedApprovalResumeData(resumeData); -} - -function getToolResumeJsonSchema( - tool: BuiltTool, - resumeSchemaOverride?: ToolSuspendOptions['resumeSchema'], -): JSONSchema7 | undefined { - const resolvedSchema = resumeSchemaOverride ?? tool.resumeSchema; - if (!resolvedSchema) return undefined; - return isZodSchema(resolvedSchema) ? zodSchemaToJsonSchema(resolvedSchema) : resolvedSchema; -} - -export interface ToolCallExecutorDeps { - loadSkill?: RuntimeSkillLoader; - telemetry: RuntimeTelemetry; - eventBus: AgentEventBus; - /** Effective tool-call concurrency (default 1 = sequential). */ - concurrency: number; - /** Invoked when a run is aborted mid-batch so the runtime can set cancelled state. */ - onCancelled: () => void; - tokenCounter: TokenCounter; - workspaceFilesystem?: WorkspaceFilesystem; -} +type SuspendedToolOutcome = Extract; /** * Executes a turn's tool calls: batches them by concurrency (with delegated @@ -207,21 +47,15 @@ export interface ToolCallExecutorDeps { * emission are owned by the caller. */ export class ToolCallExecutor { - private offloadedToolResults = false; + private readonly runner: ToolCallRunner; - constructor(private readonly deps: ToolCallExecutorDeps) {} + constructor(private readonly deps: ToolCallExecutorDeps) { + this.runner = new ToolCallRunner(deps); + } /** Whether any tool result was offloaded to the workspace filesystem during this runtime's lifetime. */ get hasOffloadedToolResults(): boolean { - return this.offloadedToolResults; - } - - private get telemetry(): RuntimeTelemetry { - return this.deps.telemetry; - } - - private get eventBus(): AgentEventBus { - return this.deps.eventBus; + return this.runner.hasOffloadedToolResults; } private get concurrency(): number { @@ -288,33 +122,54 @@ export class ToolCallExecutor { * even if one throws, then re-throws the first error. */ async iterateToolCallsConcurrent( - ctx: ToolBatchContext & { - toolCalls: Array<{ - toolCallId: string; - toolName: string; - input: unknown; - providerExecuted?: boolean; - }>; - }, + ctx: ToolBatchContext & { toolCalls: ToolCallInput[] }, ): Promise { - const { - toolCalls, - toolMap, - list, - runId, - telemetry: resolvedTelemetry, - executionCounter, - abortSignal, - } = ctx; const errors: ToolCallError[] = []; - const runtimeToolCalls: RuntimeToolCall[] = []; + const runtimeToolCalls = this.normalizeToolCalls(ctx.toolCalls, ctx, errors); + const executableCalls = runtimeToolCalls.filter((tc) => !tc.providerExecuted); + const providerExecutedCount = runtimeToolCalls.length - executableCalls.length; + for (let i = 0; i < providerExecutedCount; i++) { + incrementToolCallCount(ctx.executionCounter); + } + const executableCallsById = new Map(executableCalls.map((tc) => [tc.toolCallId, tc])); + const unexecutedIds = new Set(executableCalls.map((tc) => tc.toolCallId)); + const result: ToolCallBatchResult = { results: [], suspensions: [], errors, pending: {} }; + for (let batchStart = 0; batchStart < executableCalls.length; ) { + if (ctx.isAborted()) { + this.skipAbortedToolCalls(unexecutedIds, executableCallsById, ctx.list, result.results); + return await this.finalizeBatch(result, ctx); + } + const batch = this.takeNextToolCallBatch(executableCalls, batchStart, ctx.toolMap); + batchStart += batch.length; + const settled = await this.executeToolCallBatch(batch, ctx); + for (const tc of batch) unexecutedIds.delete(tc.toolCallId); + const hasSuspension = this.collectBatchResults(batch, settled, result, ctx); + + if (ctx.isAborted()) { + this.skipAbortedToolCalls(unexecutedIds, executableCallsById, ctx.list, result.results); + return await this.finalizeBatch(result, ctx); + } + if (hasSuspension) { + this.retainUnexecutedToolCalls(unexecutedIds, executableCallsById, result.pending); + break; + } + } + return await this.finalizeBatch(result, ctx); + } + + private normalizeToolCalls( + toolCalls: ToolCallInput[], + ctx: ToolBatchContext, + errors: ToolCallError[], + ): RuntimeToolCall[] { + const calls: RuntimeToolCall[] = []; for (const toolCall of toolCalls) { const normalizedInput = normalizeToolInputForModel(toolCall.input); if (!normalizedInput.ok) { const error = new Error(normalizedInput.error); - incrementToolCallCount(executionCounter); - list.setToolCallError(toolCall.toolCallId, error); + incrementToolCallCount(ctx.executionCounter); + ctx.list.setToolCallError(toolCall.toolCallId, error); errors.push({ toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, @@ -323,177 +178,180 @@ export class ToolCallExecutor { }); continue; } - - runtimeToolCalls.push({ ...toolCall, input: normalizedInput.input }); + calls.push({ ...toolCall, input: normalizedInput.input }); } + return calls; + } - const executableCalls = runtimeToolCalls.filter((tc) => !tc.providerExecuted); - const providerExecutedCount = runtimeToolCalls.length - executableCalls.length; - for (let i = 0; i < providerExecutedCount; i++) { - incrementToolCallCount(executionCounter); + private async executeToolCallBatch( + batch: RuntimeToolCall[], + ctx: ToolBatchContext, + ): Promise>> { + return await Promise.allSettled( + batch.map( + async (tc) => + await this.runner.processToolCall({ + toolCallId: tc.toolCallId, + toolName: tc.toolName, + input: tc.input, + toolMap: ctx.toolMap, + list: ctx.list, + runId: ctx.runId, + persistence: ctx.persistence, + resolvedTelemetry: ctx.telemetry, + executionCounter: ctx.executionCounter, + abortSignal: ctx.abortSignal, + countToolCall: true, + }), + ), + ); + } + + private collectBatchResults( + calls: RuntimeToolCall[], + settled: Array>, + batch: ToolCallBatchResult, + ctx: ToolBatchContext, + ): boolean { + let hasSuspension = false; + for (let i = 0; i < settled.length; i++) { + if (this.collectSettledToolCall(calls[i], settled[i], batch, ctx)) hasSuspension = true; } - const executableCallsById = new Map(executableCalls.map((tc) => [tc.toolCallId, tc])); - const unexecutedIds = new Set(executableCalls.map((tc) => tc.toolCallId)); - const results: ToolCallSuccess[] = []; - const suspensions: ToolCallSuspension[] = []; - const pending: Record = {}; + return hasSuspension; + } - for (let batchStart = 0; batchStart < executableCalls.length; ) { - if (ctx.isAborted()) { - this.deps.onCancelled(); - for (const id of unexecutedIds) { - const tc = executableCallsById.get(id)!; - const modelOutput = '[Skipped: run was aborted]'; - list.setToolCallResult(tc.toolCallId, modelOutput, { canceled: true }); - results.push({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: tc.input, - toolEntry: { - tool: tc.toolName, - input: tc.input, - output: modelOutput, - transformed: false, - canceled: true, - }, - modelOutput, - }); - } - return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx); - } - - const batch = this.takeNextToolCallBatch(executableCalls, batchStart, toolMap); - batchStart += batch.length; - - const settledResults = await Promise.allSettled( - batch.map( - async (tc) => - await this.processToolCall({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: tc.input, - toolMap, - list, - runId, - persistence: ctx.persistence, - resolvedTelemetry, - executionCounter, - abortSignal, - countToolCall: true, - }), - ), - ); - - for (const tc of batch) { - unexecutedIds.delete(tc.toolCallId); - } - - let hasSuspension = false; - - for (let i = 0; i < settledResults.length; i++) { - const result = settledResults[i]; - const tc = batch[i]; - const toolInput = tc.input; - - if (result.status === 'rejected') { - list.setToolCallError(tc.toolCallId, result.reason); - errors.push({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: toolInput, - error: result.reason, - }); - } else if (result.value.outcome === 'suspended') { - hasSuspension = true; - suspensions.push({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: toolInput, - payload: result.value.payload, - resumeSchema: result.value.resumeSchema, - }); - pending[tc.toolCallId] = { - suspended: true, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: toolInput, - suspendPayload: result.value.payload, - resumeSchema: result.value.resumeSchema, - ...(result.value.continuation !== undefined - ? { continuation: result.value.continuation } - : {}), - runId, - }; - } else if (result.value.outcome === 'success') { - results.push({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: toolInput, - toolEntry: result.value.toolEntry, - modelOutput: result.value.modelOutput, - customMessage: result.value.customMessage, - ...(result.value.mcpServerName !== undefined - ? { mcpServerName: result.value.mcpServerName } - : {}), - }); - } else if (result.value.outcome === 'cancelled') { - results.push({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: toolInput, - toolEntry: result.value.toolEntry, - modelOutput: result.value.modelOutput, - }); - } else if (result.value.outcome === 'error') { - errors.push({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: toolInput, - error: result.value.error, - }); - } else if (result.value.outcome === 'noop') { - // noop - } - } - - if (ctx.isAborted()) { - this.deps.onCancelled(); - for (const id of unexecutedIds) { - const tc = executableCallsById.get(id)!; - const modelOutput = '[Skipped: run was aborted]'; - list.setToolCallResult(tc.toolCallId, modelOutput, { canceled: true }); - results.push({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: tc.input, - toolEntry: { - tool: tc.toolName, - input: tc.input, - output: modelOutput, - transformed: false, - canceled: true, - }, - modelOutput, - }); - } - return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx); - } - - if (hasSuspension) { - for (const id of unexecutedIds) { - const tc = executableCallsById.get(id)!; - pending[tc.toolCallId] = { - suspended: false, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: tc.input, - }; - } + private collectSettledToolCall( + call: RuntimeToolCall, + result: PromiseSettledResult, + batch: ToolCallBatchResult, + ctx: ToolBatchContext, + ): boolean { + if (result.status === 'rejected') { + ctx.list.setToolCallError(call.toolCallId, result.reason); + batch.errors.push({ + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + error: result.reason, + }); + return false; + } + const outcome = result.value; + switch (outcome.outcome) { + case 'suspended': + batch.suspensions.push(this.buildToolCallSuspension(call, outcome)); + batch.pending[call.toolCallId] = this.buildPendingToolCall(call, outcome, ctx.runId); + return true; + case 'success': + case 'cancelled': + this.appendCompletedToolCall(call, outcome, batch.results); + break; + case 'error': + batch.errors.push({ + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + error: outcome.error, + }); break; - } } + return false; + } - return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx); + private buildToolCallSuspension( + call: ToolCallIdentity, + outcome: SuspendedToolOutcome, + ): ToolCallSuspension { + return { + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + payload: outcome.payload, + resumeSchema: outcome.resumeSchema, + }; + } + + private buildPendingToolCall( + call: ToolCallIdentity, + outcome: SuspendedToolOutcome, + runId: string, + ): PendingToolCall { + return { + suspended: true, + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + suspendPayload: outcome.payload, + resumeSchema: outcome.resumeSchema, + ...(outcome.continuation !== undefined ? { continuation: outcome.continuation } : {}), + runId, + }; + } + + private appendCompletedToolCall( + call: ToolCallIdentity, + outcome: Extract, + results: ToolCallSuccess[], + ): void { + const result: ToolCallSuccess = { + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + toolEntry: outcome.toolEntry, + modelOutput: outcome.modelOutput, + }; + if (outcome.outcome === 'success') { + result.customMessage = outcome.customMessage; + if (outcome.mcpServerName !== undefined) result.mcpServerName = outcome.mcpServerName; + } + results.push(result); + } + + private skipAbortedToolCalls( + ids: Set, + calls: Map, + list: AgentMessageList, + results: ToolCallSuccess[], + ): void { + this.deps.onCancelled(); + for (const id of ids) { + const call = calls.get(id)!; + const modelOutput = '[Skipped: run was aborted]'; + list.setToolCallResult(call.toolCallId, modelOutput, { canceled: true }); + results.push(this.buildSkippedToolResult(call, modelOutput)); + } + } + + private buildSkippedToolResult(call: ToolCallIdentity, modelOutput: string): ToolCallSuccess { + return { + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + toolEntry: { + tool: call.toolName, + input: call.input, + output: modelOutput, + transformed: false, + canceled: true, + }, + modelOutput, + }; + } + + private retainUnexecutedToolCalls( + ids: Set, + calls: Map, + pending: Record, + ): void { + for (const id of ids) { + const call = calls.get(id)!; + pending[call.toolCallId] = { + suspended: false, + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + }; + } } /** @@ -507,302 +365,114 @@ export class ToolCallExecutor { * Returns a `ToolCallBatchResult` — the caller handles persistence. */ async iteratePendingToolCallsConcurrent( - ctx: ToolBatchContext & { pendingResume: PendingResume }, + ctx: ResumeToolBatchContext, ): Promise { - const { - pendingResume, - toolMap, - list, - runId, - persistence, - telemetry: resolvedTelemetry, - executionCounter, - abortSignal, - } = ctx; + const { pendingResume } = ctx; const resumedId = pendingResume.resumeToolCallId; const resumedEntry = pendingResume.pendingToolCalls[resumedId]; - if (!resumedEntry) { - throw new Error(`No pending tool call found for toolCallId: ${resumedId}`); - } - - const resumedToolName = resumedEntry.toolName; - const results: ToolCallSuccess[] = []; - const suspensions: ToolCallSuspension[] = []; - const errors: ToolCallError[] = []; - const pending: Record = {}; - - // 1. Execute the resumed tool - const processResult = await this.processToolCall( + if (!resumedEntry) throw new Error(`No pending tool call found for toolCallId: ${resumedId}`); + const batch: ToolCallBatchResult = { results: [], suspensions: [], errors: [], pending: {} }; + const outcome = await this.runner.processToolCall( this.pendingToolCallParams(resumedEntry, ctx, pendingResume.resumeData), ); - if (processResult.outcome === 'suspended') { - pending[resumedId] = { - suspended: true, - toolCallId: resumedEntry.toolCallId, - toolName: resumedToolName, - input: resumedEntry.input, - suspendPayload: processResult.payload, - resumeSchema: processResult.resumeSchema, - ...(processResult.continuation !== undefined - ? { continuation: processResult.continuation } - : {}), - runId, - }; - suspensions.push({ - toolCallId: resumedId, - toolName: resumedToolName, - input: resumedEntry.input, - payload: processResult.payload, - resumeSchema: processResult.resumeSchema, - }); - } else if (processResult.outcome === 'success') { - results.push({ - toolCallId: resumedEntry.toolCallId, - toolName: resumedToolName, - input: resumedEntry.input, - toolEntry: processResult.toolEntry, - modelOutput: processResult.modelOutput, - customMessage: processResult.customMessage, - ...(processResult.mcpServerName !== undefined - ? { mcpServerName: processResult.mcpServerName } - : {}), - }); - } else if (processResult.outcome === 'cancelled') { - results.push({ - toolCallId: resumedEntry.toolCallId, - toolName: resumedToolName, - input: resumedEntry.input, - toolEntry: processResult.toolEntry, - modelOutput: processResult.modelOutput, - }); - list.addInput([ - { role: 'user', content: [{ type: 'text', text: processResult.userMessage }] }, - ]); - - for (const id of Object.keys(pendingResume.pendingToolCalls)) { - if (id !== resumedId) { - const siblingEntry = pendingResume.pendingToolCalls[id]; - const siblingTool = toolMap.get(siblingEntry.toolName); - const siblingParams = this.pendingToolCallParams( - siblingEntry, - ctx, - pendingResume.resumeData, - ); - if (siblingEntry.suspended && siblingTool?.onCancellation) { - try { - await this.runCancellationCleanup( - siblingParams, - siblingTool, - processResult.userMessage, - ); - } catch { - this.retainPendingToolCall(id, siblingEntry, pending, suspensions); - continue; - } - } - const modelOutput = '[Skipped: a sibling tool call was cancelled]'; - list.setToolCallResult(id, modelOutput, { - canceled: true, - }); - results.push({ - toolCallId: siblingEntry.toolCallId, - toolName: siblingEntry.toolName, - input: siblingEntry.input, - toolEntry: { - tool: siblingEntry.toolName, - input: siblingEntry.input, - output: modelOutput, - transformed: false, - canceled: true, - }, - modelOutput, - }); - } - } - - return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx); - } else if (processResult.outcome === 'retryable-error') { + if (outcome.outcome === 'cancelled') { + this.appendCompletedToolCall(resumedEntry, outcome, batch.results); + ctx.list.addInput([{ role: 'user', content: [{ type: 'text', text: outcome.userMessage }] }]); + await this.cancelSiblingToolCalls(ctx, batch, outcome.userMessage); + return await this.finalizeBatch(batch, ctx); + } + if (outcome.outcome === 'retryable-error') { for (const [id, entry] of Object.entries(pendingResume.pendingToolCalls)) { - this.retainPendingToolCall(id, entry, pending, suspensions); + this.retainPendingToolCall(id, entry, batch.pending, batch.suspensions); } - return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx); - } else if (processResult.outcome === 'error') { - errors.push({ - toolCallId: resumedEntry.toolCallId, - toolName: resumedToolName, - input: resumedEntry.input, - error: processResult.error, - }); - } else if (processResult.outcome === 'noop') { - // noop + return await this.finalizeBatch(batch, ctx); } - // 2. Process remaining items - const unexecuted: Array<{ toolCallId: string; toolName: string; input: unknown }> = []; + this.collectResumedToolCall(resumedId, resumedEntry, outcome, batch, ctx.runId); + await this.executeRemainingToolCalls(ctx, batch); + return await this.finalizeBatch(batch, ctx); + } - for (const [id, entry] of Object.entries(pendingResume.pendingToolCalls)) { - if (id === resumedId) continue; - - if (entry.suspended) { - this.retainPendingToolCall(id, entry, pending, suspensions); - } else { - // Unexecuted — collect for batch execution - unexecuted.push({ - toolCallId: id, + private collectResumedToolCall( + id: string, + entry: PendingToolCall, + outcome: ToolCallOutcome, + batch: ToolCallBatchResult, + runId: string, + ): void { + switch (outcome.outcome) { + case 'suspended': + batch.pending[id] = this.buildPendingToolCall(entry, outcome, runId); + batch.suspensions.push(this.buildToolCallSuspension({ ...entry, toolCallId: id }, outcome)); + break; + case 'success': + this.appendCompletedToolCall(entry, outcome, batch.results); + break; + case 'error': + batch.errors.push({ + toolCallId: entry.toolCallId, toolName: entry.toolName, input: entry.input, + error: outcome.error, }); - } + break; } - - // Execute unexecuted tools via iterateToolCallsConcurrent - if (unexecuted.length > 0) { - const batch = await this.iterateToolCallsConcurrent({ - toolCalls: unexecuted, - toolMap, - list, - runId, - persistence, - telemetry: resolvedTelemetry, - executionCounter, - abortSignal, - isAborted: ctx.isAborted, - }); - results.push(...batch.results); - suspensions.push(...batch.suspensions); - errors.push(...batch.errors); - Object.assign(pending, batch.pending); - } - return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx); } - /** - * Execute a single tool call, emit lifecycle events, record the result in the - * message list, and return the outcome. The caller is responsible for writing - * any stream chunks and for handling suspension (building pendingToolCalls and - * persisting state). - * - * On tool execution errors, emits ToolExecutionEnd with isError=true, adds an - * error tool-result message to the list so the LLM can self-correct, and returns - * `{ outcome: 'error' }` — never re-throws. - */ - private async processToolCall(params: ProcessToolCallParams): Promise { - const { toolName, toolMap, list, toolCallId, resumeData } = params; - const builtTool = toolMap.get(toolName); - - if (!builtTool) { - return await this.toolError(params, new Error(`Tool ${toolName} not found`)); - } - - // Already settled by the AI SDK (e.g. provider-executed tools): emit the - // lifecycle end and skip re-execution. - const settledBlock = this.findSettledToolCall(list, toolCallId); - if (settledBlock) { - return this.completeSettledToolCall(params, settledBlock); - } - - if (isCancellation(resumeData) && !builtTool.handleCancellation) { - try { - await this.runCancellationCleanup(params, builtTool, resumeData.message); - } catch { - return { outcome: 'retryable-error' }; - } - return this.buildCancelledOutcome(params, resumeData.message); - } - - if (params.countToolCall ?? true) { - incrementToolCallCount(params.executionCounter); - } - - const validation = await this.validateToolInput(params, builtTool); - if (!validation.ok) return validation.outcome; - const input = validation.input; - - if (shouldEmitToolExecutionStart(builtTool, resumeData)) { - this.eventBus.emit({ - type: AgentEvent.ToolExecutionStart, - toolCallId, - toolName, - args: input, - }); - } - - let toolResult: unknown; - let interruptedSuspendPayload: unknown; - let interruptedSuspendOptions: ToolSuspendOptions | undefined; - let didSuspend = false; - let abortObserved = false; - let suspensionCleanup: Promise | undefined; - const cleanupInterruptedSuspension = async () => { - suspensionCleanup ??= this.runCancellationCleanup( - { - ...params, - input, - suspendPayload: interruptedSuspendPayload, - continuation: interruptedSuspendOptions?.continuation, - resumeSchema: getToolResumeJsonSchema(builtTool, interruptedSuspendOptions?.resumeSchema), - }, - builtTool, - 'Run aborted', - ).catch(() => undefined); - await suspensionCleanup; - }; - try { - toolResult = await this.runToolHandler(params, builtTool, input, async (payload, options) => { - didSuspend = true; - interruptedSuspendPayload = payload; - interruptedSuspendOptions = options; - if (abortObserved || params.abortSignal?.aborted) { - await cleanupInterruptedSuspension(); - } - }); - } catch (error) { - if (isAbortError(error) || params.abortSignal?.aborted) { - abortObserved = true; - if (didSuspend) { - await cleanupInterruptedSuspension(); - } else if (params.suspendPayload !== undefined || params.continuation !== undefined) { - try { - await this.runCancellationCleanup({ ...params, input }, builtTool, 'Run aborted'); - } catch { - // Parent shutdown must continue; persistent stores will prune stale checkpoints. - } - } - this.deps.onCancelled(); - return this.buildCancelledOutcome(params, 'Run aborted'); - } - - return await this.toolError(params, error, builtTool); - } - - if (isSuspendedToolResult(toolResult)) { - return await this.buildSuspendedOutcome(params, builtTool, toolResult); - } - - return await this.buildSuccessOutcome(params, builtTool, input, toolResult); - } - - private async runCancellationCleanup( - params: ProcessToolCallParams, - builtTool: BuiltTool, - message: string, + private async cancelSiblingToolCalls( + ctx: ResumeToolBatchContext, + batch: ToolCallBatchResult, + userMessage: string, ): Promise { - if (!builtTool.onCancellation) return; - await builtTool.onCancellation(params.input, { - cancellation: { message }, - toolCallId: params.toolCallId, - toolName: params.toolName, - runId: params.runId, - persistence: params.persistence, - parentTelemetry: params.resolvedTelemetry, - emitEvent: (event) => this.eventBus.emit(event), - abortSignal: params.abortSignal, - executionCounter: params.executionCounter, - suspendPayload: params.suspendPayload, - continuation: params.continuation, - resumeSchema: params.resumeSchema, + for (const id of Object.keys(ctx.pendingResume.pendingToolCalls)) { + if (id === ctx.pendingResume.resumeToolCallId) continue; + const entry = ctx.pendingResume.pendingToolCalls[id]; + const tool = ctx.toolMap.get(entry.toolName); + const params = this.pendingToolCallParams(entry, ctx, ctx.pendingResume.resumeData); + if (entry.suspended && tool?.onCancellation) { + try { + await this.runner.runCancellationCleanup(params, tool, userMessage); + } catch { + this.retainPendingToolCall(id, entry, batch.pending, batch.suspensions); + continue; + } + } + const modelOutput = '[Skipped: a sibling tool call was cancelled]'; + ctx.list.setToolCallResult(id, modelOutput, { canceled: true }); + batch.results.push(this.buildSkippedToolResult(entry, modelOutput)); + } + } + + private async executeRemainingToolCalls( + ctx: ResumeToolBatchContext, + result: ToolCallBatchResult, + ): Promise { + const unexecuted: ToolCallInput[] = []; + for (const [id, entry] of Object.entries(ctx.pendingResume.pendingToolCalls)) { + if (id === ctx.pendingResume.resumeToolCallId) continue; + if (entry.suspended) { + this.retainPendingToolCall(id, entry, result.pending, result.suspensions); + } else { + unexecuted.push({ toolCallId: id, toolName: entry.toolName, input: entry.input }); + } + } + if (unexecuted.length === 0) return; + const batch = await this.iterateToolCallsConcurrent({ + toolCalls: unexecuted, + toolMap: ctx.toolMap, + list: ctx.list, + runId: ctx.runId, + persistence: ctx.persistence, + telemetry: ctx.telemetry, + executionCounter: ctx.executionCounter, + abortSignal: ctx.abortSignal, + isAborted: ctx.isAborted, }); + result.results.push(...batch.results); + result.suspensions.push(...batch.suspensions); + result.errors.push(...batch.errors); + Object.assign(result.pending, batch.pending); } async cleanupPendingToolCalls( @@ -815,7 +485,11 @@ export class ToolCallExecutor { const tool = ctx.toolMap.get(entry.toolName); if (!tool?.onCancellation) continue; try { - await this.runCancellationCleanup(this.pendingToolCallParams(entry, ctx), tool, message); + await this.runner.runCancellationCleanup( + this.pendingToolCallParams(entry, ctx), + tool, + message, + ); } catch { // Parent shutdown must continue; persistent stores will prune stale child checkpoints. } @@ -875,271 +549,4 @@ export class ToolCallExecutor { resumeSchema: entry.resumeSchema, }); } - - /** - * Emit a failed ToolExecutionEnd, record the error on the list, return an - * error outcome. Pass `builtTool` only for errors authored by the tool - * itself (handler or transform failures) so runtime-authored errors such as - * input validation stay plain; tool-authored text from untrusted tools is - * wrapped before the size guard so any offloaded copy stays protected. - */ - private async toolError( - params: ProcessToolCallParams, - error: unknown, - builtTool?: BuiltTool, - ): Promise { - this.eventBus.emit({ - type: AgentEvent.ToolExecutionEnd, - toolCallId: params.toolCallId, - toolName: params.toolName, - result: error, - isError: true, - }); - const errorText = stringifyError(error); - const guardedError = await guardToolErrorForModel( - builtTool?.outputTrust === 'untrusted' - ? protectUntrustedToolError(errorText, builtTool) - : errorText, - this.deps.tokenCounter, - this.getResultStorage(params), - ); - params.list.setToolCallError(params.toolCallId, guardedError); - return { outcome: 'error', error }; - } - - /** Find an already-settled (resolved/rejected) tool-call block for this id, if any. */ - private findSettledToolCall( - list: AgentMessageList, - toolCallId: string, - ): SettledToolCall | undefined { - return list - .responseDelta() - .flatMap((m) => (isLlmMessage(m) && 'content' in m ? (m as Message).content : [])) - .find( - (c): c is SettledToolCall => - c.type === 'tool-call' && c.toolCallId === toolCallId && c.state !== 'pending', - ); - } - - /** Emit ToolExecutionEnd for a block the AI SDK already settled; the result was written earlier. */ - private completeSettledToolCall( - params: ProcessToolCallParams, - settledBlock: SettledToolCall, - ): ToolCallOutcome { - const settledResult = - settledBlock.state === 'resolved' ? settledBlock.output : settledBlock.error; - this.eventBus.emit({ - type: AgentEvent.ToolExecutionEnd, - toolCallId: params.toolCallId, - toolName: params.toolName, - result: settledResult, - isError: settledBlock.state === 'rejected', - }); - return { outcome: 'noop' }; - } - - /** Record a cancelled tool call (user declined / cancelled) and build its outcome. */ - private buildCancelledOutcome( - params: ProcessToolCallParams, - userMessage: string, - ): ToolCallOutcome { - const { toolCallId, toolName, input, list } = params; - const modelOutput = `[Tool call cancelled. User said: "${userMessage}"]`; - this.eventBus.emit({ - type: AgentEvent.ToolExecutionEnd, - toolCallId, - toolName, - result: modelOutput, - isError: false, - }); - list.setToolCallResult(toolCallId, modelOutput, { canceled: true }); - return { - outcome: 'cancelled', - toolEntry: { tool: toolName, input, output: modelOutput, transformed: false, canceled: true }, - modelOutput, - userMessage, - canceled: true, - }; - } - - /** Validate input against the tool's input schema (if any). */ - private async validateToolInput( - params: ProcessToolCallParams, - builtTool: BuiltTool, - ): Promise<{ ok: true; input: JSONValue } | { ok: false; outcome: ToolCallOutcome }> { - if (!builtTool.inputSchema) return { ok: true, input: params.input }; - const result = await parseWithSchema(builtTool.inputSchema, params.input); - if (!result.success) { - const reason = result.schemaInvalid - ? `Tool ${params.toolName} has an input schema that could not be compiled: ${result.error}` - : `Invalid tool input: ${result.error}`; - return { ok: false, outcome: await this.toolError(params, new Error(reason)) }; - } - return { ok: true, input: result.data as JSONValue }; - } - - /** Execute the tool handler inside a telemetry span. Throws on handler failure. */ - private async runToolHandler( - params: ProcessToolCallParams, - builtTool: BuiltTool, - input: JSONValue, - onSuspend: (payload: unknown, options?: ToolSuspendOptions) => void, - ): Promise { - const { - toolCallId, - toolName, - runId, - persistence, - resumeData, - resolvedTelemetry, - executionCounter, - abortSignal, - suspendPayload, - continuation, - resumeSchema, - } = params; - return await this.telemetry.withToolSpan( - toolCallId, - toolName, - input, - resolvedTelemetry, - async () => - await raceWithAbort( - async () => - await executeTool(input, builtTool, resumeData, resolvedTelemetry, toolCallId, { - runId, - persistence, - ...(this.deps.loadSkill ? { loadSkill: this.deps.loadSkill } : {}), - emitEvent: (event) => this.eventBus.emit(event), - abortSignal, - executionCounter, - suspendPayload, - continuation, - resumeSchema, - onSuspend, - }), - abortSignal, - ), - ); - } - - /** Validate a suspend payload + resume schema and build the suspended outcome. */ - private async buildSuspendedOutcome( - params: ProcessToolCallParams, - builtTool: BuiltTool, - toolResult: SuspendedToolResult, - ): Promise { - if (builtTool.suspendSchema) { - const parseResult = await parseWithSchema(builtTool.suspendSchema, toolResult.payload); - if (!parseResult.success) { - return await this.toolError( - params, - new Error(`Invalid suspend payload: ${parseResult.error}`), - ); - } - toolResult.payload = parseResult.data; - } - const resumeSchema = getToolResumeJsonSchema(builtTool, toolResult.resumeSchema); - if (!resumeSchema) { - return await this.toolError( - params, - new Error(`Tool ${params.toolName} has no resume schema`), - ); - } - return { - outcome: 'suspended', - payload: toolResult.payload, - resumeSchema, - ...(toolResult.continuation !== undefined ? { continuation: toolResult.continuation } : {}), - }; - } - - /** Apply toModelOutput, emit ToolExecutionEnd, build the success outcome. */ - private async buildSuccessOutcome( - params: ProcessToolCallParams, - builtTool: BuiltTool, - input: JSONValue, - toolResult: unknown, - ): Promise { - const { toolCallId, toolName, list } = params; - - // Apply toModelOutput transform before emitting the success event. - // If the transform throws, treat it as a tool error so processToolCall - // never re-throws (preserving the "never re-throws" contract). - // Untrusted results are wrapped before the size guard so any offloaded - // or truncated copy stays protected while the guard's own envelope - // remains plain runtime text. - let modelResult: unknown; - try { - modelResult = builtTool.toModelOutput ? builtTool.toModelOutput(toolResult) : toolResult; - if (builtTool.outputTrust === 'untrusted') { - modelResult = protectUntrustedToolResult(modelResult, builtTool); - } - } catch (error) { - return await this.toolError(params, error, builtTool); - } - const storage = this.getResultStorage(params); - const guardedResult = await guardToolResultForModel( - modelResult, - this.deps.tokenCounter, - storage, - ); - - this.eventBus.emit({ - type: AgentEvent.ToolExecutionEnd, - toolCallId, - toolName, - result: toolResult, - isError: false, - }); - - list.setToolCallResult(toolCallId, guardedResult.historyOutput); - - let customMessage = await builtTool.toMessage?.(toolResult); - if (customMessage && builtTool.outputTrust === 'untrusted') { - customMessage = protectUntrustedToolMessage(customMessage, builtTool); - } - let guardedCustomMessage = customMessage - ? await guardToolMessageForModel(customMessage, this.deps.tokenCounter, storage) - : undefined; - // Stamp tool provenance so derived transcripts (e.g. the observation - // log observer) can keep this content inside untrusted-data boundaries. - if (guardedCustomMessage && 'role' in guardedCustomMessage) { - guardedCustomMessage = { - ...guardedCustomMessage, - origin: { kind: 'tool', toolName }, - }; - } - if (guardedCustomMessage) { - list.addResponse([guardedCustomMessage]); - } - - return { - outcome: 'success', - toolEntry: { - tool: toolName, - input, - output: toolResult, - transformed: !!builtTool.toModelOutput, - }, - modelOutput: guardedResult.wireOutput, - customMessage: guardedCustomMessage, - ...(builtTool.mcpServerName !== undefined ? { mcpServerName: builtTool.mcpServerName } : {}), - }; - } - - private getResultStorage(params: ProcessToolCallParams): ToolResultGuardStorage | undefined { - const filesystem = this.deps.workspaceFilesystem; - if (!filesystem) return undefined; - - return { - filesystem, - runId: params.runId, - toolCallId: params.toolCallId, - onOffloaded: () => { - this.offloadedToolResults = true; - }, - ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}), - }; - } } diff --git a/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts b/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts new file mode 100644 index 00000000000..d50f509069c --- /dev/null +++ b/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts @@ -0,0 +1,520 @@ +import { zodSchemaToJsonSchema } from '@n8n/ai-utilities/json-schema'; +import type { JSONSchema7 } from 'json-schema'; + +import { executeTool, isSuspendedToolResult, type SuspendedToolResult } from './tool-adapter'; +import { + guardToolErrorForModel, + guardToolMessageForModel, + guardToolResultForModel, + type ToolResultGuardStorage, +} from './tool-result-guard'; +import { + protectUntrustedToolError, + protectUntrustedToolMessage, + protectUntrustedToolResult, +} from './untrusted-tool-output'; +import { isAbortError, raceWithAbort } from '../../sdk/abort'; +import { isCancellation } from '../../sdk/cancellation'; +import { isLlmMessage } from '../../sdk/message'; +import type { BuiltTool, ToolSuspendOptions } from '../../types'; +import { AgentEvent } from '../../types/runtime/event'; +import type { + ProcessToolCallParams, + ToolCallExecutorDeps, + ToolCallOutcome, +} from '../../types/runtime/tool-execution'; +import type { AgentMessage, ContentToolCall, Message } from '../../types/sdk/message'; +import type { JSONValue } from '../../types/utils/json'; +import { parseWithSchema } from '../../utils/parse'; +import { isZodSchema } from '../../utils/zod'; +import { incrementToolCallCount } from '../loop/execution-counter'; +import { stringifyError } from '../loop/runtime-helpers'; +import type { AgentMessageList } from '../model/message-list'; +import type { AgentEventBus } from '../state/event-bus'; +import type { RuntimeTelemetry } from '../telemetry/runtime-telemetry'; + +interface InterruptedToolSuspension { + didSuspend: boolean; + abortObserved: boolean; + payload: unknown; + options?: ToolSuspendOptions; + cleanup?: Promise; +} + +/** A tool-call content block that has already been settled by the AI SDK. */ +type SettledToolCall = ContentToolCall & { state: 'resolved' | 'rejected' }; + +function isDeniedApprovalResumeData(value: unknown): boolean { + return value !== null && typeof value === 'object' && Reflect.get(value, 'approved') === false; +} + +function shouldEmitToolExecutionStart(tool: BuiltTool, resumeData: unknown): boolean { + if (!tool.approval) return true; + if (!tool.approval.required && tool.approval.conditional !== true) return true; + if (resumeData === undefined) return false; + return !isDeniedApprovalResumeData(resumeData); +} + +function getToolResumeJsonSchema( + tool: BuiltTool, + resumeSchemaOverride?: ToolSuspendOptions['resumeSchema'], +): JSONSchema7 | undefined { + const resolvedSchema = resumeSchemaOverride ?? tool.resumeSchema; + if (!resolvedSchema) return undefined; + return isZodSchema(resolvedSchema) ? zodSchemaToJsonSchema(resolvedSchema) : resolvedSchema; +} + +/** Execute one tool call and keep its result, cancellation, and lifecycle events together. */ +export class ToolCallRunner { + private offloadedToolResults = false; + + constructor(private readonly deps: ToolCallExecutorDeps) {} + + get hasOffloadedToolResults(): boolean { + return this.offloadedToolResults; + } + + private get telemetry(): RuntimeTelemetry { + return this.deps.telemetry; + } + + private get eventBus(): AgentEventBus { + return this.deps.eventBus; + } + + /** + * Execute a single tool call, emit lifecycle events, record the result in the + * message list, and return the outcome. The caller is responsible for writing + * any stream chunks and for handling suspension (building pendingToolCalls and + * persisting state). + * + * On tool execution errors, emits ToolExecutionEnd with isError=true, adds an + * error tool-result message to the list so the LLM can self-correct, and returns + * `{ outcome: 'error' }` — never re-throws. + */ + async processToolCall(params: ProcessToolCallParams): Promise { + const { toolName, toolMap, list, toolCallId, resumeData } = params; + const builtTool = toolMap.get(toolName); + + if (!builtTool) { + return await this.toolError(params, new Error(`Tool ${toolName} not found`)); + } + + // Already settled by the AI SDK (e.g. provider-executed tools): emit the + // lifecycle end and skip re-execution. + const settledBlock = this.findSettledToolCall(list, toolCallId); + if (settledBlock) { + return this.completeSettledToolCall(params, settledBlock); + } + + if (isCancellation(resumeData) && !builtTool.handleCancellation) { + try { + await this.runCancellationCleanup(params, builtTool, resumeData.message); + } catch { + return { outcome: 'retryable-error' }; + } + return this.buildCancelledOutcome(params, resumeData.message); + } + + if (params.countToolCall ?? true) { + incrementToolCallCount(params.executionCounter); + } + + const validation = await this.validateToolInput(params, builtTool); + if (!validation.ok) return validation.outcome; + const input = validation.input; + + if (shouldEmitToolExecutionStart(builtTool, resumeData)) { + this.eventBus.emit({ + type: AgentEvent.ToolExecutionStart, + toolCallId, + toolName, + args: input, + }); + } + + return await this.executeValidatedToolCall(params, builtTool, input); + } + + private async executeValidatedToolCall( + params: ProcessToolCallParams, + builtTool: BuiltTool, + input: JSONValue, + ): Promise { + let toolResult: unknown; + const suspension: InterruptedToolSuspension = { + didSuspend: false, + abortObserved: false, + payload: undefined, + }; + try { + toolResult = await this.runToolHandler(params, builtTool, input, async (payload, options) => { + suspension.didSuspend = true; + suspension.payload = payload; + suspension.options = options; + if (suspension.abortObserved || params.abortSignal?.aborted) { + await this.cleanupInterruptedSuspension(params, builtTool, input, suspension); + } + }); + } catch (error) { + if (isAbortError(error) || params.abortSignal?.aborted) { + suspension.abortObserved = true; + await this.cleanupAbortedTool(params, builtTool, input, suspension); + this.deps.onCancelled(); + return this.buildCancelledOutcome(params, 'Run aborted'); + } + return await this.toolError(params, error, builtTool); + } + + if (isSuspendedToolResult(toolResult)) { + return await this.buildSuspendedOutcome(params, builtTool, toolResult); + } + return await this.buildSuccessOutcome(params, builtTool, input, toolResult); + } + + private async cleanupInterruptedSuspension( + params: ProcessToolCallParams, + builtTool: BuiltTool, + input: JSONValue, + suspension: InterruptedToolSuspension, + ): Promise { + // Both abort and late suspension can reach this path. Run cleanup only once. + suspension.cleanup ??= this.runCancellationCleanup( + { + ...params, + input, + suspendPayload: suspension.payload, + continuation: suspension.options?.continuation, + resumeSchema: getToolResumeJsonSchema(builtTool, suspension.options?.resumeSchema), + }, + builtTool, + 'Run aborted', + ).catch(() => undefined); + await suspension.cleanup; + } + + private async cleanupAbortedTool( + params: ProcessToolCallParams, + builtTool: BuiltTool, + input: JSONValue, + suspension: InterruptedToolSuspension, + ): Promise { + if (suspension.didSuspend) { + await this.cleanupInterruptedSuspension(params, builtTool, input, suspension); + } else if (params.suspendPayload !== undefined || params.continuation !== undefined) { + try { + await this.runCancellationCleanup({ ...params, input }, builtTool, 'Run aborted'); + } catch { + // Parent shutdown must continue; persistent stores will prune stale checkpoints. + } + } + } + + async runCancellationCleanup( + params: ProcessToolCallParams, + builtTool: BuiltTool, + message: string, + ): Promise { + if (!builtTool.onCancellation) return; + await builtTool.onCancellation(params.input, { + cancellation: { message }, + toolCallId: params.toolCallId, + toolName: params.toolName, + runId: params.runId, + persistence: params.persistence, + parentTelemetry: params.resolvedTelemetry, + emitEvent: (event) => this.eventBus.emit(event), + abortSignal: params.abortSignal, + executionCounter: params.executionCounter, + suspendPayload: params.suspendPayload, + continuation: params.continuation, + resumeSchema: params.resumeSchema, + }); + } + + /** + * Emit a failed ToolExecutionEnd, record the error on the list, return an + * error outcome. Pass `builtTool` only for errors authored by the tool + * itself (handler or transform failures) so runtime-authored errors such as + * input validation stay plain; tool-authored text from untrusted tools is + * wrapped before the size guard so any offloaded copy stays protected. + */ + private async toolError( + params: ProcessToolCallParams, + error: unknown, + builtTool?: BuiltTool, + ): Promise { + this.eventBus.emit({ + type: AgentEvent.ToolExecutionEnd, + toolCallId: params.toolCallId, + toolName: params.toolName, + result: error, + isError: true, + }); + const errorText = stringifyError(error); + const guardedError = await guardToolErrorForModel( + builtTool?.outputTrust === 'untrusted' + ? protectUntrustedToolError(errorText, builtTool) + : errorText, + this.deps.tokenCounter, + this.getResultStorage(params), + ); + params.list.setToolCallError(params.toolCallId, guardedError); + return { outcome: 'error', error: guardedError }; + } + + /** Find an already-settled (resolved/rejected) tool-call block for this id, if any. */ + private findSettledToolCall( + list: AgentMessageList, + toolCallId: string, + ): SettledToolCall | undefined { + return list + .responseDelta() + .flatMap((m) => (isLlmMessage(m) && 'content' in m ? (m as Message).content : [])) + .find( + (c): c is SettledToolCall => + c.type === 'tool-call' && c.toolCallId === toolCallId && c.state !== 'pending', + ); + } + + /** Emit ToolExecutionEnd for a block the AI SDK already settled; the result was written earlier. */ + private completeSettledToolCall( + params: ProcessToolCallParams, + settledBlock: SettledToolCall, + ): ToolCallOutcome { + const settledResult = + settledBlock.state === 'resolved' ? settledBlock.output : settledBlock.error; + this.eventBus.emit({ + type: AgentEvent.ToolExecutionEnd, + toolCallId: params.toolCallId, + toolName: params.toolName, + result: settledResult, + isError: settledBlock.state === 'rejected', + }); + return { outcome: 'noop' }; + } + + /** Record a cancelled tool call (user declined / cancelled) and build its outcome. */ + private buildCancelledOutcome( + params: ProcessToolCallParams, + userMessage: string, + ): ToolCallOutcome { + const { toolCallId, toolName, input, list } = params; + const modelOutput = `[Tool call cancelled. User said: "${userMessage}"]`; + this.eventBus.emit({ + type: AgentEvent.ToolExecutionEnd, + toolCallId, + toolName, + result: modelOutput, + isError: false, + }); + list.setToolCallResult(toolCallId, modelOutput, { canceled: true }); + return { + outcome: 'cancelled', + toolEntry: { tool: toolName, input, output: modelOutput, transformed: false, canceled: true }, + modelOutput, + userMessage, + canceled: true, + }; + } + + /** Validate input against the tool's input schema (if any). */ + private async validateToolInput( + params: ProcessToolCallParams, + builtTool: BuiltTool, + ): Promise<{ ok: true; input: JSONValue } | { ok: false; outcome: ToolCallOutcome }> { + if (!builtTool.inputSchema) return { ok: true, input: params.input }; + const result = await parseWithSchema(builtTool.inputSchema, params.input); + if (!result.success) { + const reason = result.schemaInvalid + ? `Tool ${params.toolName} has an input schema that could not be compiled: ${result.error}` + : `Invalid tool input: ${result.error}`; + return { ok: false, outcome: await this.toolError(params, new Error(reason)) }; + } + return { ok: true, input: result.data as JSONValue }; + } + + /** Execute the tool handler inside a telemetry span. Throws on handler failure. */ + private async runToolHandler( + params: ProcessToolCallParams, + builtTool: BuiltTool, + input: JSONValue, + onSuspend: (payload: unknown, options?: ToolSuspendOptions) => void, + ): Promise { + const { + toolCallId, + toolName, + runId, + persistence, + resumeData, + resolvedTelemetry, + executionCounter, + abortSignal, + suspendPayload, + continuation, + resumeSchema, + } = params; + return await this.telemetry.withToolSpan( + toolCallId, + toolName, + input, + resolvedTelemetry, + async () => + await raceWithAbort( + async () => + await executeTool(input, builtTool, resumeData, resolvedTelemetry, toolCallId, { + runId, + persistence, + ...(this.deps.loadSkill ? { loadSkill: this.deps.loadSkill } : {}), + emitEvent: (event) => this.eventBus.emit(event), + abortSignal, + executionCounter, + suspendPayload, + continuation, + resumeSchema, + onSuspend, + }), + abortSignal, + ), + ); + } + + /** Validate a suspend payload + resume schema and build the suspended outcome. */ + private async buildSuspendedOutcome( + params: ProcessToolCallParams, + builtTool: BuiltTool, + toolResult: SuspendedToolResult, + ): Promise { + if (builtTool.suspendSchema) { + const parseResult = await parseWithSchema(builtTool.suspendSchema, toolResult.payload); + if (!parseResult.success) { + return await this.toolError( + params, + new Error(`Invalid suspend payload: ${parseResult.error}`), + ); + } + toolResult.payload = parseResult.data; + } + const resumeSchema = getToolResumeJsonSchema(builtTool, toolResult.resumeSchema); + if (!resumeSchema) { + return await this.toolError( + params, + new Error(`Tool ${params.toolName} has no resume schema`), + ); + } + return { + outcome: 'suspended', + payload: toolResult.payload, + resumeSchema, + ...(toolResult.continuation !== undefined ? { continuation: toolResult.continuation } : {}), + }; + } + + /** Convert the tool output before recording success. */ + private async buildSuccessOutcome( + params: ProcessToolCallParams, + builtTool: BuiltTool, + input: JSONValue, + toolResult: unknown, + ): Promise { + const { toolCallId, toolName, list } = params; + + // Apply toModelOutput transform before emitting the success event. + // If the transform throws, treat it as a tool error so processToolCall + // never re-throws (preserving the "never re-throws" contract). + // Untrusted results are wrapped before the size guard so any offloaded + // or truncated copy stays protected while the guard's own envelope + // remains plain runtime text. + let modelResult: unknown; + try { + modelResult = builtTool.toModelOutput ? builtTool.toModelOutput(toolResult) : toolResult; + if (builtTool.outputTrust === 'untrusted') { + modelResult = protectUntrustedToolResult(modelResult, builtTool); + } + } catch (error) { + return await this.toolError(params, error, builtTool); + } + const storage = this.getResultStorage(params); + const guardedResult = await guardToolResultForModel( + modelResult, + this.deps.tokenCounter, + storage, + ); + let guardedCustomMessage: AgentMessage | undefined; + try { + guardedCustomMessage = await this.prepareCustomToolMessage( + params, + builtTool, + toolResult, + storage, + ); + } catch (error) { + return await this.toolError(params, error, builtTool); + } + + this.eventBus.emit({ + type: AgentEvent.ToolExecutionEnd, + toolCallId, + toolName, + result: toolResult, + isError: false, + }); + + list.setToolCallResult(toolCallId, guardedResult.historyOutput); + if (guardedCustomMessage) { + list.addResponse([guardedCustomMessage]); + } + + return { + outcome: 'success', + toolEntry: { + tool: toolName, + input, + output: toolResult, + transformed: !!builtTool.toModelOutput, + }, + modelOutput: guardedResult.wireOutput, + customMessage: guardedCustomMessage, + ...(builtTool.mcpServerName !== undefined ? { mcpServerName: builtTool.mcpServerName } : {}), + }; + } + + private async prepareCustomToolMessage( + params: ProcessToolCallParams, + builtTool: BuiltTool, + toolResult: unknown, + storage: ToolResultGuardStorage | undefined, + ): Promise { + let customMessage = await builtTool.toMessage?.(toolResult); + if (customMessage && builtTool.outputTrust === 'untrusted') { + customMessage = protectUntrustedToolMessage(customMessage, builtTool); + } + let guardedCustomMessage = customMessage + ? await guardToolMessageForModel(customMessage, this.deps.tokenCounter, storage) + : undefined; + // Stamp tool provenance so derived transcripts (e.g. the observation + // log observer) can keep this content inside untrusted-data boundaries. + if (guardedCustomMessage && 'role' in guardedCustomMessage) { + guardedCustomMessage = { + ...guardedCustomMessage, + origin: { kind: 'tool', toolName: params.toolName }, + }; + } + return guardedCustomMessage; + } + + private getResultStorage(params: ProcessToolCallParams): ToolResultGuardStorage | undefined { + const filesystem = this.deps.workspaceFilesystem; + if (!filesystem) return undefined; + + return { + filesystem, + runId: params.runId, + toolCallId: params.toolCallId, + onOffloaded: () => { + this.offloadedToolResults = true; + }, + ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}), + }; + } +} diff --git a/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts b/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts index 5ba0dd55479..3106a786493 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts @@ -1,10 +1,8 @@ -import { isRecord } from '@n8n/utils/is-record'; import { toJsonValue } from '@n8n/utils/json/to-json-value'; -import type { AgentDbMessage, AgentMessage, MessageContent } from '../../types/sdk/message'; +import type { AgentMessage } from '../../types/sdk/message'; import type { JSONObject, JSONValue } from '../../types/utils/json'; import { - isToolResultPath, storeToolResult, type ToolResultKind, type ToolResultStorageScope, @@ -13,6 +11,11 @@ import type { WorkspaceFilesystem } from '../../workspace/types'; import { isContentToolResultOutput, type ContentToolResultOutput } from '../model/messages'; import { estimateObservationTokens, type TokenCounter } from '../model/model-token-counter'; +export { + EXPIRED_OFFLOADED_TOOL_RESULT, + sanitizeOffloadedToolResultsForMemory, +} from '../memory/tool-result-memory'; + export const MAX_MODEL_TOOL_RESULT_TOKENS = 50_000; // BPE tokens cannot exceed UTF-8 bytes; reserve room for provider message framing. const SMALL_PAYLOAD_OVERHEAD_BYTES = 1_024; @@ -40,14 +43,6 @@ interface OffloadedToolResult extends JSONObject { message: string; } -export const EXPIRED_OFFLOADED_TOOL_RESULT = { - _offloaded: true, - expired: true, - message: 'The stored tool result expired with its originating run.', -} satisfies JSONObject; - -const EXPIRED_OFFLOADED_TOOL_RESULT_JSON = JSON.stringify(EXPIRED_OFFLOADED_TOOL_RESULT); - export interface ToolResultGuardStorage extends ToolResultStorageScope { filesystem: WorkspaceFilesystem; /** Invoked after a result was successfully offloaded to the workspace filesystem. */ @@ -120,14 +115,7 @@ async function guardContentToolResultForModel( } const replacement = JSON.stringify(guardedText.historyOutput); - let replacedText = false; - const value = output.value.flatMap((part): ContentToolResultOutput['value'] => { - if (part.type !== 'text') return [part]; - if (replacedText) return []; - - replacedText = true; - return [{ ...part, text: replacement }]; - }); + const value = replaceTextParts(output.value, replacement); const wireOutput: ContentToolResultOutput = { ...output, value }; return { @@ -161,72 +149,18 @@ export async function guardToolMessageForModel( if (!guarded.truncated && !guarded.offloaded) return message; const replacement = JSON.stringify(guarded.historyOutput); - let replacedText = false; - const content = message.content.flatMap((block): MessageContent[] => { - if (block.type !== 'text') return [block]; - if (replacedText) return []; - - replacedText = true; - return [{ ...block, text: replacement }]; - }); + const content = replaceTextParts(message.content, replacement); return { ...message, content }; } -function isOffloadedToolResult(value: unknown): boolean { - return ( - isRecord(value) && - value._offloaded === true && - typeof value.path === 'string' && - isToolResultPath(value.path) - ); -} - -function isSerializedOffloadedToolResult(value: string): boolean { - try { - const parsed: unknown = JSON.parse(value); - return isOffloadedToolResult(parsed); - } catch { - return false; - } -} - -export function sanitizeOffloadedToolResultsForMemory( - messages: AgentDbMessage[], -): AgentDbMessage[] { - return messages.map((message) => { - if (!('content' in message)) return { ...message }; - - const content = message.content.map((block): MessageContent => { - if (block.type === 'tool-call') { - if (block.state === 'resolved' && isOffloadedToolResult(block.output)) { - return { ...block, output: { ...EXPIRED_OFFLOADED_TOOL_RESULT } }; - } - if (block.state === 'resolved' && isContentToolResultOutput(block.output)) { - return { - ...block, - output: toJsonValue({ - type: 'content', - value: block.output.value.map((part) => - part.type === 'text' && isSerializedOffloadedToolResult(part.text) - ? { ...part, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON } - : part, - ), - }), - }; - } - if (block.state === 'rejected' && isSerializedOffloadedToolResult(block.error)) { - return { ...block, error: EXPIRED_OFFLOADED_TOOL_RESULT_JSON }; - } - } - - if (block.type === 'text' && isSerializedOffloadedToolResult(block.text)) { - return { ...block, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON }; - } - - return { ...block }; - }); - return { ...message, content }; +function replaceTextParts(parts: T[], replacement: string): T[] { + let replacedText = false; + return parts.flatMap((part) => { + if (part.type !== 'text') return [part]; + if (replacedText) return []; + replacedText = true; + return [{ ...part, text: replacement }]; }); } diff --git a/packages/@n8n/agents/src/types/runtime/agent-loop.ts b/packages/@n8n/agents/src/types/runtime/agent-loop.ts new file mode 100644 index 00000000000..fdc85340232 --- /dev/null +++ b/packages/@n8n/agents/src/types/runtime/agent-loop.ts @@ -0,0 +1,137 @@ +import type { + LanguageModel, + LanguageModelCallOptions, + ModelMessage, + Output, + SystemModelMessage, + TelemetryOptions, + ToolCallRepairFunction, + ToolSet, +} from 'ai'; + +import type { FinishReason, SerializableAgentState, TokenUsage } from '../index'; +import type { ExecutionOptions, RunOptions } from '../sdk/agent'; +import type { AgentMessage } from '../sdk/message'; +import type { JSONObject } from '../utils/json'; +import type { AgentMessageList } from '../../runtime/model/message-list'; +import type { ToolCallBatchResult, ToolCallInput, ToolCallSuspension } from './tool-execution'; + +type RunCallOptions = (RunOptions & ExecutionOptions) | undefined; + +/** + * Recognized ways a model turn can fail while the provider still returns + * "successfully" (no thrown SDK error). Extend this as new failure modes are + * recognized so consumers switch on `type` instead of probing separate fields. + * - `prompt_blocked`: the provider rejected the prompt (e.g. a safety block). + * - `no_output`: the model returned nothing with a non-stop finish reason. + */ +export type ModelTurnErrorType = 'prompt_blocked' | 'no_output'; + +/** A recognized model-turn failure: a `type` to branch on and a user-facing `message`. */ +export interface ModelTurnError { + type: ModelTurnErrorType; + message: string; +} + +/** Normalized result of a single LLM turn, independent of generate vs stream. */ +export interface ModelTurnResult { + /** Raw AI SDK finish reason (used to detect the `tool-calls` continuation). */ + aiFinishReason: string; + finishReason: FinishReason; + /** Normalized usage, including any cache-token details available from the provider. */ + usage: TokenUsage | undefined; + newMessages: AgentMessage[]; + toolCalls: ToolCallInput[]; + /** Resolved structured output, only when an output spec is set and the turn finished. */ + structuredOutput: unknown; + /** + * Set when the turn failed in a recognized way despite the provider returning + * without a thrown error (e.g. a prompt safety block, or empty output). The + * loop surfaces this instead of ending the run silently. Check this single + * field rather than probing per-failure-mode properties. + */ + errorReason?: ModelTurnError; +} + +/** Per-iteration inputs for the LLM call, assembled by the shared loop. */ +export interface ModelCallContext { + model: LanguageModel; + system: SystemModelMessage | SystemModelMessage[]; + messages: ModelMessage[]; + abortSignal: AbortSignal; + hasTools: boolean; + aiTools: ToolSet; + reasoning?: LanguageModelCallOptions['reasoning']; + providerOptions?: Record; + outputSpec?: ReturnType; + maxOutputTokens?: number; + onInputRejected?: (error: unknown) => Promise; + aiSdkOptions: { + telemetry?: TelemetryOptions; + repairToolCall?: ToolCallRepairFunction>; + }; +} + +/** Data needed to emit a terminal suspension result. */ +export interface SuspendEmission { + suspendRunId: string; + list: AgentMessageList; + usage: TokenUsage | undefined; + suspensions: ToolCallSuspension[]; +} + +/** Data needed to emit a terminal completion result. */ +export interface CompleteEmission { + list: AgentMessageList; + options: RunCallOptions; + finishReason: FinishReason; + usage: TokenUsage | undefined; + structuredOutput: unknown; +} + +/** + * Runtime services the sinks call into for shared terminal concerns (cost, + * memory persistence, title generation, telemetry flush, state). Implemented by + * `AgentRuntime` and passed to each sink. + */ +export interface RunServices { + readonly runId: string; + readonly modelId: string; + applyCost(usage: TokenUsage | undefined): TokenUsage | undefined; + saveToMemory(list: AgentMessageList, options: RunCallOptions): Promise; + maybeGenerateTitle(list: AgentMessageList, options: RunCallOptions): Promise; + flushTelemetry(options: RunCallOptions): Promise; + cleanupRun(): Promise; + updateState(patch: Partial): void; + emitAgentEnd(messages: AgentMessage[]): void; + getState(): SerializableAgentState; +} + +/** + * Strategy that adapts the shared agentic loop to a concrete output channel: + * `GenerateSink` accumulates a `GenerateResult`; `StreamSink` writes + * `StreamChunk`s through the stream writer guard. + */ +export interface RunOutputSink { + /** Run one LLM turn. Streaming implementations also emit text/tool chunks here. */ + callModel(ctx: ModelCallContext): Promise; + /** + * Report the run's cumulative token usage after each turn. Streaming + * implementations keep it so an aborted run can still emit a terminal + * finish chunk carrying the tokens consumed before the stop. + */ + reportUsage(usage: TokenUsage | undefined): void; + /** + * Signal that the just-returned turn's messages have been folded into the list. + * Streaming implementations drop the retained streamed text here so a later abort + * can't duplicate a turn that is already in the list — while a stop landing before + * this call still recovers the turn from the retained text. + */ + onTurnFolded?(): void; + /** Emit the results/errors of a completed tool-call batch. */ + emitToolBatch(batch: ToolCallBatchResult): Promise; + /** Produce the terminal result when the run suspends for HITL / suspend-resume. */ + finishSuspended(emission: SuspendEmission): Promise; + /** Produce the terminal result when the run completes normally. */ + finishComplete(emission: CompleteEmission): Promise; +} diff --git a/packages/@n8n/agents/src/types/runtime/agent-runtime.ts b/packages/@n8n/agents/src/types/runtime/agent-runtime.ts new file mode 100644 index 00000000000..eb61d322ffc --- /dev/null +++ b/packages/@n8n/agents/src/types/runtime/agent-runtime.ts @@ -0,0 +1,96 @@ +import type { ProviderOptions } from '@ai-sdk/provider-utils'; +import type { JSONSchema7 } from 'json-schema'; +import type { z } from 'zod'; + +import type { FetchFn } from '../../runtime/model/model-factory'; +import type { ScopedMemoryTaskEvent } from '../../runtime/memory/scoped-memory-task-runner'; +import type { AgentEventBus } from '../../runtime/state/event-bus'; +import type { RunStateManager } from '../../runtime/state/run-state'; +import type { ModelCost } from '../../sdk/catalog'; +import type { RuntimeSkillSource } from '../../skills/types'; +import type { WorkspaceFilesystem } from '../../workspace'; +import type { + BuiltFileStore, + BuiltMemory, + BuiltProviderTool, + BuiltTelemetry, + BuiltTool, + CheckpointStore, + EpisodicMemoryConfig, + McpConnectionFailedEvent, + ObservationalMemoryConfig, + ObservationLogMemoryConfig, + ReasoningLevel, + ThinkingConfig, + TitleGenerationConfig, +} from '../index'; +import type { AgentPersistenceOptions, ModelConfig, PromptCachingConfig } from '../sdk/agent'; + +export interface VolatileInstructionsContext { + persistence?: AgentPersistenceOptions; +} + +export type VolatileInstructionsProvider = ( + context: VolatileInstructionsContext, +) => Promise; + +export interface AgentRuntimeConfig { + name: string; + model: ModelConfig; + /** + * Proxy-aware `fetch` used for all model calls in this runtime (main model and + * title generation). When unset, model construction falls back to the ambient + * HTTP_PROXY resolver. + */ + modelFetch?: FetchFn; + instructions: string; + skillSource?: RuntimeSkillSource; + instructionProviderOptions?: ProviderOptions; + tools?: BuiltTool[]; + deferredTools?: BuiltTool[]; + workspaceFilesystem?: WorkspaceFilesystem; + toolSearch?: { + topK?: number; + }; + providerTools?: BuiltProviderTool[]; + memory?: BuiltMemory; + /** Host store resolving file-reference content parts to bytes before LLM calls. */ + fileStore?: BuiltFileStore; + observationLog?: ObservationLogMemoryConfig; + observationalMemory?: ObservationalMemoryConfig; + episodicMemory?: EpisodicMemoryConfig; + structuredOutput?: z.ZodType | JSONSchema7; + checkpointStorage?: 'memory' | CheckpointStore; + thinking?: ThinkingConfig; + reasoning?: ReasoningLevel; + promptCaching?: PromptCachingConfig; + eventBus?: AgentEventBus; + /** Number of tool calls to execute concurrently. Default `1` (sequential). */ + toolCallConcurrency?: number; + titleGeneration?: TitleGenerationConfig; + telemetry?: BuiltTelemetry; + /** Existing run id to continue, used when resuming a suspended run. */ + runId?: string; + /** + * Pre-fetched model cost from the catalog. When provided, skips the per-run + * catalog fetch. Set once during Agent.build() and shared across per-run runtimes. + */ + modelCost?: ModelCost; + /** + * Shared RunStateManager for suspend/resume. When provided, per-run runtimes + * use the same store so resume() can find state from a prior run. + */ + runState?: RunStateManager; + /** Host callback for observational-memory background task lifecycle events. */ + onMemoryTaskEvent?: (event: ScopedMemoryTaskEvent) => void; + /** + * Per-server MCP connection failures recorded during `Agent.build()` when + * resolving MCP tools. Tools from these servers were skipped; the runtime + * surfaces each as a non-fatal `warning` stream chunk at the start of a + * stream so hosts can tell the user an MCP server was unavailable without + * aborting the run. + */ + mcpConnectionFailures?: McpConnectionFailedEvent[]; + /** The runtime loads these host instructions before each model call but does not save them. */ + volatileInstructionsProvider?: VolatileInstructionsProvider; +} diff --git a/packages/@n8n/agents/src/types/runtime/delegation.ts b/packages/@n8n/agents/src/types/runtime/delegation.ts new file mode 100644 index 00000000000..d8561d48dcc --- /dev/null +++ b/packages/@n8n/agents/src/types/runtime/delegation.ts @@ -0,0 +1,301 @@ +import { z } from 'zod'; + +import type { + SubAgentTaskPath, + SubAgentTaskPathPolicy, +} from '../../runtime/tools/sub-agent-task-path'; +import type { + AgentExecutionCounter, + FinishReason, + GenerateResult, + ModelConfig, + StreamChunk, + TokenUsage, +} from '../sdk/agent'; +import type { BuiltProviderTool } from '../sdk/tool'; +import type { BuiltTelemetry } from '../telemetry'; +import type { JSONObject, JSONValue } from '../utils/json'; + +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'; + +export const SUB_AGENT_TASK_DIFFICULTIES = ['low', 'medium', 'high'] as const; +const SubAgentTaskDifficultySchema = z.enum(SUB_AGENT_TASK_DIFFICULTIES); +export type SubAgentTaskDifficulty = z.infer; + +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]), +); + +// 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. +export const delegateSubAgentInputSchema = z.object({ + subAgentId: z + .string() + .min(1) + .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) + .describe('Short human-readable name for this delegated task, e.g. "research_api".'), + goal: z.string().min(1).describe('The concrete goal the sub-agent should accomplish.'), + context: z + .string() + .optional() + .describe( + 'All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.', + ), + expectedOutput: z.string().optional().describe('The expected shape or contents of the answer.'), + difficulty: SubAgentTaskDifficultySchema.optional().describe( + 'Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.', + ), +}); + +// Documents the tool result shape for typing/introspection. Note: the handler's +// returned object (not this schema) is what is actually sent back to the model, +// so this is kept in sync with DelegateSubAgentToolOutput by hand. +export const delegateSubAgentOutputSchema = z.object({ + status: z.enum(['completed', 'failed', 'suspended', 'cancelled']), + taskPath: z.string().optional(), + runId: z.string().optional(), + threadId: z.string().optional(), + model: z.string().optional(), + answer: z.string(), + structuredOutput: z.unknown().optional(), + usage: z + .object({ + promptTokens: z.number().optional(), + completionTokens: z.number().optional(), + totalTokens: z.number().optional(), + cost: z.number().optional(), + }) + .optional(), + finishReason: z.string().optional(), + error: z.string().optional(), + resumeContext: jsonValueSchema.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(), +}); + +const delegateSubAgentContinuationSchema = z.object({ + runId: z.string(), + toolCallId: z.string(), + taskPath: z.string(), + subAgentId: z.string(), + childCount: z.number().int().nonnegative(), + threadId: z.string().optional(), + resumeContext: jsonValueSchema.optional(), +}); + +export const delegateSubAgentSuspendSchema = z.unknown(); +export const delegateSubAgentResumeSchema = z.unknown(); + +export type DelegateSubAgentContinuation = z.infer; + +export function parseDelegateSubAgentContinuation( + value: unknown, +): DelegateSubAgentContinuation | undefined { + const parsed = delegateSubAgentContinuationSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; +} + +/** The arguments the LLM provides when calling delegate_subagent. */ +export type DelegateSubAgentInput = z.infer; + +/** + * Limits the delegate tool enforces structurally for a delegation: fan-out + * and the on/off switch (see {@link SubAgentTaskPathPolicy}). + * + * Per-run runtime constraints (e.g. a wall-clock timeout) are intentionally not + * here — they're a host concern, enforced inside the `runSubAgent` callback (as + * the n8n CLI runner does). + */ +export type DelegateSubAgentPolicy = SubAgentTaskPathPolicy; + +/** + * What a host's `runSubAgent` callback receives: the model's + * {@link DelegateSubAgentInput} plus runtime-derived context the host needs to + * run and link the child. All `parent*` fields come from the parent's tool + * execution context and are used for tracing/linkage, not required to run. + */ +export interface DelegateSubAgentRequest extends DelegateSubAgentInput { + /** 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; + /** Parent's persisted memory thread id (`ctx.persistence.threadId`). */ + parentThreadId?: string; + /** Parent's episodic-memory resource id (`ctx.persistence.resourceId`). */ + parentResourceId?: string; + /** Opaque host metadata from the parent's persistence scope. */ + parentHostMetadata?: JSONObject; + /** Parent's tool-call id that triggered this delegation. */ + parentToolCallId?: string; + /** + * Parent run's abort signal (`ctx.abortSignal`). Forward it to the child so + * cancelling the parent run also cancels the delegated work. + */ + parentAbortSignal?: AbortSignal; + /** Parent aggregate execution counter (`ctx.executionCounter`) for inline child accounting. */ + parentExecutionCounter?: AgentExecutionCounter; + /** + * Parent's live, resolved telemetry (`ctx.parentTelemetry`). Hosts derive the + * child's own telemetry from this (see `deriveSubAgentTelemetry`) so the + * sub-agent shares the parent's tracer and nests under the parent's + * delegate-tool-call span instead of starting a separate root trace. + */ + parentTelemetry?: BuiltTelemetry; + /** How many siblings the parent already spawned before this one (0-based). */ + childCount: number; + /** Effective policy for this delegation. */ + policy?: DelegateSubAgentPolicy; +} + +/** The result a delegation returns to the parent model and to lifecycle events. */ +export interface DelegateSubAgentToolOutput { + status: 'completed' | 'failed' | 'suspended' | 'cancelled'; + /** Echoed back so consumers can correlate the result with the delegation. */ + taskPath?: SubAgentTaskPath; + /** The child run's id, when the executor produced one. */ + runId?: string; + /** + * The child run's memory thread id (`persistence.threadId`), when the + * executor used one. Surfaced so a consumer can correlate the child run or + * re-supply it to continue the same thread on a later delegation. + */ + threadId?: string; + /** Effective child model id used for this delegation (e.g. `anthropic/claude-haiku-4-5`). */ + model?: string; + /** The child's answer — the main payload the parent acts on. */ + answer: string; + structuredOutput?: unknown; + /** Child token usage + cost, surfaced so the parent can account for it. */ + usage?: Pick; + finishReason?: FinishReason; + /** Present when status is 'failed'. */ + error?: string; + /** Host-owned, serializable context required to reconstruct this exact child. */ + resumeContext?: JSONValue; + /** Present when status is 'suspended' — child run paused awaiting tool resume. */ + pendingSuspend?: GenerateResult['pendingSuspend']; +} + +/** + * 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 local/deferred tool set. */ + runInlineSubAgent: (request: DelegateSubAgentRequest) => Promise; + /** + * Forward a child stream chunk into the parent run's event bus (allowlisted + * types only, with a per-delegation character budget). No-op when the + * parent tool call id is missing. + */ + emitChunk: (chunk: StreamChunk) => void; +} + +export type InlineSubAgentProviderToolsResolver = ( + modelConfig: ModelConfig, +) => BuiltProviderTool[] | Promise; + +export type DelegateSubAgentRunner = ( + request: DelegateSubAgentRequest, + helpers: DelegateSubAgentRunnerHelpers, +) => Promise; + +export interface DelegateSubAgentCheckpointTarget { + childRunId: string; + childToolCallId: string; + childThreadId?: string; + resumeContext?: JSONValue; +} + +export interface DelegateSubAgentResumeRequest + extends DelegateSubAgentRequest, + DelegateSubAgentCheckpointTarget { + resumeData: unknown; +} + +export type DelegateSubAgentResumeRunner = ( + request: DelegateSubAgentResumeRequest, + helpers: DelegateSubAgentRunnerHelpers, +) => Promise; + +export interface DelegateSubAgentCancelRequest + extends DelegateSubAgentRequest, + DelegateSubAgentCheckpointTarget { + reason: string; +} + +export type DelegateSubAgentCancelRunner = ( + request: DelegateSubAgentCancelRequest, + helpers: DelegateSubAgentRunnerHelpers, +) => Promise; + +export interface CreateDelegateSubAgentToolOptions { + /** Model-facing tool name. Defaults to {@link DELEGATE_SUB_AGENT_TOOL_NAME}; the default description and system instruction adapt to it. */ + name?: string; + /** Model-facing tool description. Anything other than a non-blank string falls back to the built-in text. */ + description?: string | null; + /** System-prompt delegation guidance. Anything other than a non-blank string falls back to the built-in guidance. */ + systemInstruction?: string | null; + /** + * Sub-agents the model may choose between. Listed in the system prompt; the + * model selects one by passing its id as `subAgentId`. + */ + availableSubAgents?: Array<{ id: string; name: string; useWhen?: string }>; + /** Parallelism limit for delegated child runs (also used as delegate_subagent batch size). */ + policy?: DelegateSubAgentPolicy; + /** Additional local/deferred tool names the host removes from inline children. */ + inlineSubAgentBlockedTools?: string[]; + /** + * Resolved inline sub-agent models by task difficulty. Hosts map persisted config + * to runtime {@link ModelConfig} values before registering the delegate tool. + */ + inlineSubAgentModelsByDifficulty?: Partial>; + /** + * Resolve provider-defined tools for an inline child after its model has been chosen. + * Inline children do not inherit parent provider tools. + */ + resolveInlineSubAgentProviderTools?: InlineSubAgentProviderToolsResolver; + /** + * 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; + /** Resume a child checkpoint previously cascaded through this delegate tool. */ + resumeSubAgent?: DelegateSubAgentResumeRunner; + /** Return true only when a thrown child-resume error is safe to retry. */ + shouldRetrySubAgentResumeError?: (error: unknown) => boolean; + /** Clean up a child checkpoint when the parent cancels the suspended delegation. */ + cancelSubAgent?: DelegateSubAgentCancelRunner; + + toModelOutput?: (output: z.infer) => unknown; +} + +export type DelegateSubAgentToolMetadata = CreateDelegateSubAgentToolOptions; diff --git a/packages/@n8n/agents/src/types/runtime/tool-execution.ts b/packages/@n8n/agents/src/types/runtime/tool-execution.ts new file mode 100644 index 00000000000..dda211ab507 --- /dev/null +++ b/packages/@n8n/agents/src/types/runtime/tool-execution.ts @@ -0,0 +1,144 @@ +import type { JSONSchema7 } from 'json-schema'; + +import type { AgentMessageList } from '../../runtime/model/message-list'; +import type { TokenCounter } from '../../runtime/model/model-token-counter'; +import type { AgentEventBus } from '../../runtime/state/event-bus'; +import type { RuntimeTelemetry } from '../../runtime/telemetry/runtime-telemetry'; +import type { RuntimeSkillLoader } from '../../skills/types'; +import type { WorkspaceFilesystem } from '../../workspace/types'; +import type { + AgentExecutionCounter, + BuiltTelemetry, + BuiltTool, + PendingToolCall, + ToolSuspendOptions, +} from '../index'; +import type { AgentPersistenceOptions, ToolResultEntry } from '../sdk/agent'; +import type { AgentMessage } from '../sdk/message'; +import type { JSONValue } from '../utils/json'; + +/** Pending tool calls from a suspended run, passed into the loop to execute before the first LLM call. */ +export interface PendingResume { + pendingToolCalls: Record; + /** The tool call being resumed with new data. */ + resumeToolCallId: string; + resumeData: unknown; +} + +export type ToolCallOutcome = + | { + outcome: 'success'; + toolEntry: ToolResultEntry; + /** + * Output as the LLM sees it (after `toModelOutput`). Same as + * `toolEntry.output` when no `toModelOutput` transform is configured. + * Surfaced on the `tool-result` wire chunk so consumers see what the + * LLM saw (rather than the larger raw output). + */ + modelOutput: unknown; + customMessage?: AgentMessage; + mcpServerName?: string; + } + | { + outcome: 'suspended'; + payload: unknown; + resumeSchema: JSONSchema7; + continuation?: JSONValue; + } + | { + outcome: 'cancelled'; + toolEntry: ToolResultEntry; + modelOutput: string; + userMessage: string; + canceled: true; + } + | { outcome: 'retryable-error' } + | { outcome: 'error'; error: unknown } + | { outcome: 'noop' }; // tool call shouldn't be saved or logged anywhere, usually means that if was executed by AI SDK + +export type ToolCallIdentity = Pick; + +/** A tool call that completed successfully. */ +export interface ToolCallSuccess extends ToolCallIdentity { + toolEntry: ToolResultEntry; + modelOutput: unknown; + customMessage?: AgentMessage; + /** Set when the tool belongs to an MCP server, so hosts can attribute the result to it. */ + mcpServerName?: string; +} + +/** Info about a tool call that suspended (before persistence — no runId yet). */ +export interface ToolCallSuspension extends ToolCallIdentity { + payload: unknown; + /** JSON Schema describing the shape of resume data, derived from the tool's resumeSchema. */ + resumeSchema: JSONSchema7; +} + +/** Info about a tool call that failed — carries enough data for stream chunks. */ +export interface ToolCallError extends ToolCallIdentity { + error: unknown; +} + +/** Result of executing a batch of tool calls (before persistence). */ +export interface ToolCallBatchResult { + results: ToolCallSuccess[]; + suspensions: ToolCallSuspension[]; + errors: ToolCallError[]; + /** All items to persist: suspended tools (with suspendPayload) + unexecuted tools (without). */ + pending: Record; +} + +export interface ToolCallInput { + toolCallId: string; + toolName: string; + input: unknown; + providerExecuted?: boolean; +} + +/** Shared input for the tool-call batch iterators. */ +export interface ToolBatchContext { + toolMap: Map; + list: AgentMessageList; + runId: string; + persistence?: AgentPersistenceOptions; + telemetry?: BuiltTelemetry; + executionCounter?: AgentExecutionCounter; + abortSignal: AbortSignal; + isAborted: () => boolean; +} + +export interface ResumeToolBatchContext extends ToolBatchContext { + pendingResume: PendingResume; +} + +/** Inputs for executing a single tool call. */ +export interface ProcessToolCallParams extends ToolCallIdentity { + toolMap: Map; + list: AgentMessageList; + runId: string; + persistence?: AgentPersistenceOptions; + resumeData?: unknown; + resolvedTelemetry?: BuiltTelemetry; + executionCounter?: AgentExecutionCounter; + abortSignal?: AbortSignal; + /** Whether this counts as a new tool-call invocation. Default `true`; `false` on resume. */ + countToolCall?: boolean; + /** Checkpointed suspend payload of the tool call being resumed. */ + suspendPayload?: unknown; + /** Checkpointed private continuation of the tool call being resumed. */ + continuation?: JSONValue; + /** Checkpointed resume schema of the tool call being resumed. */ + resumeSchema?: ToolSuspendOptions['resumeSchema']; +} + +export interface ToolCallExecutorDeps { + loadSkill?: RuntimeSkillLoader; + telemetry: RuntimeTelemetry; + eventBus: AgentEventBus; + /** Effective tool-call concurrency (default 1 = sequential). */ + concurrency: number; + /** Invoked when a run is aborted mid-batch so the runtime can set cancelled state. */ + onCancelled: () => void; + tokenCounter: TokenCounter; + workspaceFilesystem?: WorkspaceFilesystem; +} diff --git a/packages/@n8n/agents/src/types/sdk/agent-builder.ts b/packages/@n8n/agents/src/types/sdk/agent-builder.ts index 538c8b5daf5..1d9ceaee4f2 100644 --- a/packages/@n8n/agents/src/types/sdk/agent-builder.ts +++ b/packages/@n8n/agents/src/types/sdk/agent-builder.ts @@ -4,7 +4,7 @@ import type { BuiltGuardrail } from './guardrail'; import type { CheckpointStore } from './memory'; import type { ReasoningLevel } from './reasoning'; import type { BuiltProviderTool, BuiltTool } from './tool'; -import type { VolatileInstructionsProvider } from '../../runtime/loop/agent-runtime'; +import type { VolatileInstructionsProvider } from '../runtime/agent-runtime'; import type { ScopedMemoryTaskEvent } from '../../runtime/memory/scoped-memory-task-runner'; import type { RuntimeSkill, RuntimeSkillSource } from '../../skills';