UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

1,244 lines 83.1 kB
import { stripToSpecMiddleware } from "../../strip-to-spec-middleware.js"; import { isCancelRequestedReason } from "./cancel.js"; import { CapabilityRegistry } from "./middleware/capabilities.js"; import { getRunDetached } from "./middleware/run-store.js"; import { publishRunDetachedSignal } from "../../delivery-detach.js"; import { publishRunDisconnectHandler } from "../../delivery-disconnect.js"; import { EventType } from "../../types.js"; import { resolveDebugOption } from "../../logger/resolve.js"; import { streamToText } from "../../stream-to-response.js"; import { canonicalInterruptJson, digestInterruptJson } from "../../interrupt-serialization.js"; import "../../interrupts.js"; import { convertSchemaForStructuredOutput, convertSchemaToJsonSchema, isStandardSchema, parseWithStandardSchema } from "./tools/schema-converter.js"; import { hashSchemaInput, normalizeApprovalSchema } from "./tools/approval-schema.js"; import { INTERRUPT_BINDING_METADATA_KEY, InterruptResumeValidationError, readUnopenedInterruptBinding, validateInterruptResumeBatch } from "../../interrupt-resume.js"; import { normalizeToolResult } from "../../utilities/tool-result.js"; import { isProviderExecutedToolCall } from "../../utilities/provider-executed.js"; import { LazyToolManager } from "./tools/lazy-tool-manager.js"; import { MiddlewareAbortError, ToolCallManager, executeToolCalls } from "./tools/tool-calls.js"; import { maxIterations } from "./agent-loop-strategies.js"; import { convertMessagesToModelMessages, generateMessageId } from "./messages.js"; import { MiddlewareRunner } from "./middleware/compose.js"; import { provideSandboxRuntime } from "./middleware/sandbox-runtime.js"; import { provideRunDisconnect } from "./middleware/run-disconnect.js"; import { validateCapabilities } from "./middleware/validate.js"; import { MCPManager } from "./mcp/manager.js"; import "./adapter.js"; import { devtoolsMiddleware } from "@tanstack/ai-event-client"; import { undoNullWidening } from "@tanstack/ai-utils"; //#region src/activities/chat/index.ts /** * Text Activity * * Handles agentic text generation, one-shot text generation, and agentic structured output. * This is a self-contained module with implementation, types, and JSDoc. */ /** The adapter kind this activity handles */ var kind = "text"; var interruptBindingMetadataKey = INTERRUPT_BINDING_METADATA_KEY; function isInterruptSubmissionError(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; if (!("scope" in value) || !("code" in value) || !("message" in value) || !("source" in value) || !("retryable" in value) || !("threadId" in value) || !("interruptedRunId" in value) || !("generation" in value) || typeof value.code !== "string" || typeof value.message !== "string" || typeof value.retryable !== "boolean" || typeof value.threadId !== "string" || typeof value.interruptedRunId !== "string" || typeof value.generation !== "number") return false; if (value.scope === "item") return "interruptId" in value && typeof value.interruptId === "string" && (value.source === "client" || value.source === "server"); return value.scope === "batch" && "interruptIds" in value && Array.isArray(value.interruptIds) && value.interruptIds.every((id) => typeof id === "string") && (value.source === "client" || value.source === "server" || value.source === "transport"); } function structuralInterruptFailure(error) { if (!(error instanceof Error) || error.name !== "InterruptResumeValidationError" || !("errors" in error) || !Array.isArray(error.errors) || error.errors.length === 0 || !error.errors.every(isInterruptSubmissionError)) return; return { error, errors: error.errors }; } function normalizePublicInterruptBinding(value, expectedInterruptId) { if (value === null || typeof value !== "object" || Array.isArray(value)) return; const binding = Object.fromEntries(Object.entries(value)); if (binding.interruptId !== expectedInterruptId || binding.v !== void 0 && binding.v !== 1 || typeof binding.interruptedRunId !== "string" || typeof binding.generation !== "number" || !Number.isInteger(binding.generation) || binding.generation < 0 || typeof binding.responseSchemaHash !== "string" || binding.expiresAt !== void 0 && typeof binding.expiresAt !== "string") return; const base = { v: 1, interruptId: binding.interruptId, interruptedRunId: binding.interruptedRunId, generation: binding.generation, responseSchemaHash: binding.responseSchemaHash, ...typeof binding.expiresAt === "string" ? { expiresAt: binding.expiresAt } : {} }; if (binding.kind === "generic") return { kind: binding.kind, ...base }; if (typeof binding.toolName !== "string" || typeof binding.toolCallId !== "string") return; if (binding.kind === "client-tool-execution" && typeof binding.outputSchemaHash === "string") return { kind: binding.kind, ...base, toolName: binding.toolName, toolCallId: binding.toolCallId, outputSchemaHash: binding.outputSchemaHash }; if (binding.kind === "tool-approval" && Object.prototype.hasOwnProperty.call(binding, "originalArgs") && typeof binding.inputSchemaHash === "string" && typeof binding.approvalSchemaHash === "string") return { kind: binding.kind, ...base, toolName: binding.toolName, toolCallId: binding.toolCallId, originalArgs: binding.originalArgs, inputSchemaHash: binding.inputSchemaHash, approvalSchemaHash: binding.approvalSchemaHash }; } /** * Create typed options for the chat() function without executing. * This is useful for pre-defining configurations with full type inference. * * @example * ```ts * const chatOptions = createChatOptions({ * adapter: anthropicText('claude-sonnet-4-5'), * }) * * const stream = chat({ ...chatOptions, messages }) * ``` */ function createChatOptions(options) { return options; } /** * Combine two optional AbortSignals into one that aborts when either does. * Returns the other signal directly when one is absent or already aborted. * (Manual implementation — `AbortSignal.any` requires Node >= 20.3.) */ function combineAbortSignals(a, b) { if (!a) return b; if (!b) return a; if (a.aborted) return a; if (b.aborted) return b; const controller = new AbortController(); const onAbort = (source) => () => { controller.abort(source.reason); }; a.addEventListener("abort", onAbort(a), { once: true }); b.addEventListener("abort", onAbort(b), { once: true }); return controller.signal; } var TextEngine = class { adapter; params; systemPrompts; tools; loopStrategy; toolCallManager; lazyToolManager; initialMessageCount; requestId; streamId; effectiveRequest; effectiveSignal; messages; iterationCount = 0; /** Cumulative tool calls counted in this run (emitted + pending resume). */ toolCallCount = 0; /** Tool calls in the most recent budgeted batch (0 when none). */ lastTurnToolCallCount = 0; /** Tool call IDs already counted toward `toolCallCount` (avoids double-count on resume). */ countedToolCallIds = /* @__PURE__ */ new Set(); lastFinishReason = null; streamStartTime = 0; totalChunkCount = 0; currentMessageId = null; accumulatedContent = ""; accumulatedThinking = []; currentThinkingContent = ""; currentThinkingSignature = ""; eventOptions; eventToolNames; finishedEvent = null; deferredToolCallRunFinishedChunks = []; earlyTermination = false; toolPhase = "continue"; cyclePhase = "processText"; initialApprovals; initialClientToolResults; resumeApprovals = /* @__PURE__ */ new Map(); resumeClientToolResults = /* @__PURE__ */ new Map(); resumeDeniedToolResults = /* @__PURE__ */ new Map(); resumeCancelledToolCallIds = /* @__PURE__ */ new Set(); threadId; runIdOverride; parentRunIdOverride; middlewareRunner; middlewareCtx; sandboxFileQueue = []; deferredPromises = []; abortReason; middlewareAbortController; toolAbortSignal; terminalHookCalled = false; /** * Latched the first time the delivery socket closes; see `notifyDisconnected`. * Also read by `subscribe` so a listener registered AFTER the disconnect (a * middleware whose `setup` was still running at the time — the common case) is * called immediately rather than never. */ disconnected = false; disconnectListeners = []; logger; structuredOutputResult = null; combinedStartEmitted = false; combinedStructuredMessageId = null; validatedStructuredOutput = void 0; hasValidatedStructuredOutput = false; finalizationError = null; finalStructuredOutput; constructor(config, logger) { this.logger = logger; this.adapter = config.adapter; this.finalStructuredOutput = config.finalStructuredOutput; this.params = config.params; this.systemPrompts = config.params.systemPrompts || []; this.loopStrategy = config.params.agentLoopStrategy || maxIterations(5); this.initialMessageCount = config.params.messages.length; const { approvals, clientToolResults } = this.extractClientStateFromOriginalMessages(config.params.messages); this.initialApprovals = approvals; this.initialClientToolResults = clientToolResults; this.messages = convertMessagesToModelMessages(config.params.messages); this.lazyToolManager = new LazyToolManager(config.params.tools || [], this.messages, config.params.lazyToolsConfig); this.tools = this.lazyToolManager.getActiveTools(); this.toolCallManager = new ToolCallManager(this.tools); this.requestId = this.createId("chat"); this.streamId = this.createId("stream"); this.effectiveRequest = config.params.abortController ? { signal: config.params.abortController.signal } : void 0; this.effectiveSignal = config.params.abortController?.signal; this.threadId = config.params.threadId || config.params.conversationId || this.createId("thread"); this.runIdOverride = config.params.runId; this.parentRunIdOverride = config.params.parentRunId; const allMiddleware = [ devtoolsMiddleware(), ...config.middleware || [], stripToSpecMiddleware() ]; this.middlewareRunner = new MiddlewareRunner(allMiddleware, logger); this.middlewareAbortController = new AbortController(); this.toolAbortSignal = combineAbortSignals(this.effectiveSignal, this.middlewareAbortController.signal); this.middlewareCtx = { requestId: this.requestId, streamId: this.streamId, runId: this.runIdOverride ?? this.requestId, parentRunId: this.parentRunIdOverride, threadId: this.threadId, conversationId: this.threadId, phase: "init", iteration: 0, chunkIndex: 0, signal: this.effectiveSignal, abort: (reason) => { this.abortReason = reason; this.middlewareAbortController?.abort(reason); }, context: config.context, defer: (promise) => { this.deferredPromises.push(promise); }, activity: "chat", provider: config.adapter.name, model: config.params.model, source: "server", streaming: true, systemPrompts: this.systemPrompts, toolNames: void 0, options: void 0, modelOptions: config.params.modelOptions, messageCount: this.initialMessageCount, hasTools: this.tools.length > 0, currentMessageId: null, accumulatedContent: "", messages: this.messages, createId: (prefix) => this.createId(prefix), capabilities: new CapabilityRegistry(), get: (capability) => capability[0](this.middlewareCtx), getOptional: (capability) => capability[0](this.middlewareCtx, { optional: true }), provide: (capability, value) => capability[1](this.middlewareCtx, value) }; provideRunDisconnect(this.middlewareCtx, { subscribe: (listener) => { this.disconnectListeners.push(listener); if (this.disconnected) this.runDisconnectListener(listener); } }); provideSandboxRuntime(this.middlewareCtx, { logger: this.logger, emit: (event) => { this.logger.sandbox(`file ${event.type} ${event.path}`, { event: { type: event.type, path: event.path, timestamp: event.timestamp } }); this.middlewareRunner.runSandboxFile(this.middlewareCtx, event).catch((err) => { this.logger.errors("sandbox file hook failed", { error: err }); }); this.sandboxFileQueue.push(this.createCustomEventChunk("sandbox.file", { type: event.type, path: event.path, timestamp: event.timestamp })); }, emitFileDiff: (value) => { this.sandboxFileQueue.push(this.createCustomEventChunk("sandbox.file.diff", value)); } }); } /** Get the accumulated content after the chat loop completes */ getAccumulatedContent() { return this.accumulatedContent; } /** Get the final messages array after the chat loop completes */ getMessages() { return this.messages; } /** Returns the structured-output result if finalization ran successfully. */ getStructuredOutputResult() { return this.structuredOutputResult; } /** * Returns the validated structured-output value (the result of running * `finalStructuredOutput.validate` against the raw structured-output data) * wrapped in a `{ value }` object so callers can distinguish "no validation * happened" from "validation produced undefined". Returns `null` when no * validator was configured or validation hasn't been performed yet. */ getValidatedStructuredOutput() { return this.hasValidatedStructuredOutput ? { value: this.validatedStructuredOutput } : null; } /** Returns the recorded finalization error, if any. */ getFinalizationError() { return this.finalizationError; } async *run() { this.beforeRun(); this.logger.agentLoop("run started", { threadId: this.middlewareCtx.threadId }); try { await this.middlewareRunner.runSetup(this.middlewareCtx); this.middlewareCtx.phase = "init"; const initialConfig = this.buildMiddlewareConfig(); const transformedConfig = await this.middlewareRunner.runOnConfig(this.middlewareCtx, initialConfig); this.applyMiddlewareConfig(transformedConfig); await this.applyEphemeralInterruptResume(transformedConfig); await this.middlewareRunner.runOnStart(this.middlewareCtx); if ((yield* this.checkForPendingToolCalls()) === "wait") return; if (!(!!this.finalStructuredOutput && this.tools.length === 0 && this.finalStructuredOutput.nativeCombined !== true)) do { if (this.earlyTermination || this.isCancelled()) return; this.logger.agentLoop(`iteration=${this.middlewareCtx.iteration}`, { iteration: this.middlewareCtx.iteration }); await this.beginCycle(); if (this.cyclePhase === "processText") { this.middlewareCtx.phase = "beforeModel"; this.middlewareCtx.iteration = this.iterationCount; const iterConfig = this.buildMiddlewareConfig(); const iterTransformedConfig = await this.middlewareRunner.runOnConfig(this.middlewareCtx, iterConfig); this.applyMiddlewareConfig(iterTransformedConfig); yield* this.streamModelResponse(); } else yield* this.processToolCalls(); this.endCycle(); } while (await this.shouldContinue()); this.logger.agentLoop("run finished", { finishReason: this.lastFinishReason }); if (this.finalStructuredOutput && this.toolPhase !== "wait" && !this.isCancelled() && !this.finalizationError) if (this.finalStructuredOutput.nativeCombined === true) yield* this.harvestCombinedStructuredOutput(); else yield* this.runStructuredFinalization(); if (!this.terminalHookCalled && this.toolPhase !== "wait" && !this.isCancelled()) if (this.finalizationError) { this.terminalHookCalled = true; const errForHook = new Error(this.finalizationError.message, this.finalizationError.cause !== void 0 ? { cause: this.finalizationError.cause } : void 0); if (this.finalizationError.code !== void 0) Object.defineProperty(errForHook, "code", { value: this.finalizationError.code, enumerable: true }); await this.middlewareRunner.runOnError(this.middlewareCtx, { error: errForHook, duration: Date.now() - this.streamStartTime }); } else { this.terminalHookCalled = true; await this.middlewareRunner.runOnFinish(this.middlewareCtx, { finishReason: this.lastFinishReason, duration: Date.now() - this.streamStartTime, content: this.accumulatedContent, usage: this.finishedEvent?.usage }); } } catch (error) { if (error instanceof Error && error.name === "InterruptReplaySignal" && "continuationRunId" in error && typeof error.continuationRunId === "string") { this.terminalHookCalled = true; yield { type: EventType.RUN_FINISHED, timestamp: Date.now(), threadId: this.threadId, runId: this.runIdOverride ?? this.requestId, finishReason: "stop", outcome: { type: "success" }, result: { replayed: true, continuationRunId: error.continuationRunId } }; return; } const interruptFailure = structuralInterruptFailure(error); if (interruptFailure) { this.terminalHookCalled = true; this.logger.errors("chat interrupt resume failed", { error, threadId: this.middlewareCtx.threadId }); await this.middlewareRunner.runOnError(this.middlewareCtx, { error: interruptFailure.error, duration: Date.now() - this.streamStartTime }); yield this.buildInterruptRunErrorChunk(error); return; } if (!this.terminalHookCalled) { this.terminalHookCalled = true; if (error instanceof MiddlewareAbortError) { this.abortReason = error.message; await this.middlewareRunner.runOnAbort(this.middlewareCtx, { reason: error.message, duration: Date.now() - this.streamStartTime, cancelRequested: isCancelRequestedReason(error.message) }); } else { this.logger.errors("chat run failed", { error, threadId: this.middlewareCtx.threadId }); await this.middlewareRunner.runOnError(this.middlewareCtx, { error, duration: Date.now() - this.streamStartTime }); } } if (!(error instanceof MiddlewareAbortError)) throw error; } finally { if (!this.terminalHookCalled && this.isCancelled()) { this.terminalHookCalled = true; const reason = this.resolveAbortReason(); await this.middlewareRunner.runOnAbort(this.middlewareCtx, { reason, duration: Date.now() - this.streamStartTime, cancelRequested: isCancelRequestedReason(reason) }); } if (this.deferredPromises.length > 0) await Promise.allSettled(this.deferredPromises); } } beforeRun() { this.streamStartTime = Date.now(); const { tools, metadata } = this.params; const options = {}; if (metadata !== void 0) options.metadata = metadata; this.eventOptions = Object.keys(options).length > 0 ? options : void 0; this.eventToolNames = tools?.map((t) => t.name); this.middlewareCtx.options = this.eventOptions; this.middlewareCtx.toolNames = this.eventToolNames; } async beginCycle() { if (this.cyclePhase === "processText") await this.beginIteration(); } endCycle() { if (this.cyclePhase === "processText") { this.cyclePhase = "executeToolCalls"; return; } this.cyclePhase = "processText"; this.iterationCount++; } async beginIteration() { this.currentMessageId = this.createId("msg"); this.accumulatedContent = ""; this.accumulatedThinking = []; this.currentThinkingContent = ""; this.currentThinkingSignature = ""; this.finishedEvent = null; this.middlewareCtx.currentMessageId = this.currentMessageId; this.middlewareCtx.accumulatedContent = ""; await this.middlewareRunner.runOnIteration(this.middlewareCtx, { iteration: this.iterationCount, messageId: this.currentMessageId }); } async *streamModelResponse() { const { metadata, modelOptions } = this.params; const toolsWithJsonSchemas = this.tools.map((tool) => ({ ...tool, inputSchema: tool.inputSchema ? convertSchemaToJsonSchema(tool.inputSchema) : void 0, outputSchema: tool.outputSchema ? convertSchemaToJsonSchema(tool.outputSchema) : void 0 })); this.middlewareCtx.phase = "modelStream"; const providerName = this.adapter.provider ?? this.adapter.name; this.logger.request(`activity=chat provider=${providerName} model=${this.params.model} messages=${this.messages.length} tools=${this.tools.length} stream=true`, { provider: providerName, model: this.params.model, messageCount: this.messages.length, toolCount: this.tools.length }); const combinedSchema = this.finalStructuredOutput?.nativeCombined === true ? this.finalStructuredOutput.jsonSchema : void 0; const { approvals } = this.collectClientState(); const adapterApprovals = /* @__PURE__ */ new Map(); for (const [approvalId, resolution] of approvals) adapterApprovals.set(approvalId, typeof resolution === "boolean" ? resolution : resolution.approved); for await (const chunk of this.adapter.chatStream({ model: this.params.model, messages: this.messages, tools: toolsWithJsonSchemas, metadata, request: this.effectiveRequest, modelOptions, systemPrompts: this.systemPrompts, logger: this.logger, threadId: this.threadId, runId: this.runIdOverride, parentRunId: this.parentRunIdOverride, capabilities: this.middlewareCtx, approvals: adapterApprovals, ...combinedSchema ? { outputSchema: combinedSchema } : {} })) { if (this.isCancelled()) break; this.totalChunkCount++; this.handleStreamChunk(chunk); if (this.finalStructuredOutput?.nativeCombined === true && this.finalStructuredOutput.yieldChunks && !this.combinedStartEmitted && chunk.type === EventType.TEXT_MESSAGE_START) { this.combinedStartEmitted = true; const messageId = typeof chunk.messageId === "string" && chunk.messageId !== "" ? chunk.messageId : generateMessageId(); this.combinedStructuredMessageId = messageId; const synthStart = { type: EventType.CUSTOM, name: "structured-output.start", value: { messageId }, model: this.params.model, timestamp: Date.now(), threadId: this.threadId, ...this.runIdOverride ? { runId: this.runIdOverride } : {} }; const synthOutputs = await this.middlewareRunner.runOnChunk(this.middlewareCtx, synthStart); for (const outputChunk of synthOutputs) { yield outputChunk; this.middlewareCtx.chunkIndex++; } } const outputChunks = await this.middlewareRunner.runOnChunk(this.middlewareCtx, chunk); const suppressAgentLifecycle = !!this.finalStructuredOutput && this.finalStructuredOutput.yieldChunks && this.finalStructuredOutput.nativeCombined !== true; for (const outputChunk of outputChunks) { if (suppressAgentLifecycle && (outputChunk.type === EventType.RUN_STARTED || outputChunk.type === EventType.RUN_FINISHED)) continue; if (this.shouldDeferToolCallRunFinished(outputChunk)) { this.deferredToolCallRunFinishedChunks.push(outputChunk); continue; } this.logger.output(`type=${outputChunk.type}`, { chunk: outputChunk }); yield outputChunk; this.middlewareCtx.chunkIndex++; } if (chunk.type === "RUN_FINISHED" && chunk.usage) await this.middlewareRunner.runOnUsage(this.middlewareCtx, chunk.usage); yield* this.drainSandboxFileQueue(); if (this.earlyTermination) break; } yield* this.drainSandboxFileQueue(); } handleStreamChunk(chunk) { switch (chunk.type) { case "TEXT_MESSAGE_CONTENT": this.handleTextMessageContentEvent(chunk); break; case "TOOL_CALL_START": this.handleToolCallStartEvent(chunk); break; case "TOOL_CALL_ARGS": this.handleToolCallArgsEvent(chunk); break; case "TOOL_CALL_END": this.handleToolCallEndEvent(chunk); break; case "RUN_FINISHED": this.handleRunFinishedEvent(chunk); break; case "RUN_ERROR": this.handleRunErrorEvent(chunk); break; case "STEP_STARTED": this.handleStepStartedEvent(); break; case "STEP_FINISHED": this.handleStepFinishedEvent(chunk); break; case "TOOL_CALL_RESULT": break; case "REASONING_START": case "REASONING_MESSAGE_START": case "REASONING_MESSAGE_CONTENT": case "REASONING_MESSAGE_END": case "REASONING_END": break; default: break; } } handleTextMessageContentEvent(chunk) { if (chunk.content) this.accumulatedContent = chunk.content; else this.accumulatedContent += chunk.delta; this.middlewareCtx.accumulatedContent = this.accumulatedContent; } handleToolCallStartEvent(chunk) { this.toolCallManager.addToolCallStartEvent(chunk); } handleToolCallArgsEvent(chunk) { this.toolCallManager.addToolCallArgsEvent(chunk); } handleToolCallEndEvent(chunk) { this.toolCallManager.completeToolCall(chunk); } handleRunFinishedEvent(chunk) { this.finishedEvent = chunk; this.lastFinishReason = chunk.finishReason ?? null; } handleRunErrorEvent(_chunk) { this.earlyTermination = true; } finalizeCurrentThinkingStep() { if (this.currentThinkingContent) { this.accumulatedThinking.push({ content: this.currentThinkingContent, ...this.currentThinkingSignature && { signature: this.currentThinkingSignature } }); this.currentThinkingContent = ""; this.currentThinkingSignature = ""; } } handleStepStartedEvent() { this.finalizeCurrentThinkingStep(); } handleStepFinishedEvent(chunk) { if (chunk.delta) this.currentThinkingContent += chunk.delta; if (chunk.signature) this.currentThinkingSignature = chunk.signature; } /** * Tools available for execution this turn. The discovery tool is dropped * from the advertised set (`this.tools`) once every lazy tool is discovered, * but a model may still re-request discovery; this widens execution lookup * to include it so such calls don't fail with "Unknown tool". Centralised so * both execution sites (`processToolCalls` and `checkForPendingToolCalls`) * stay in sync. */ resolveExecutableTools(toolCalls) { return this.lazyToolManager.getExecutableTools(this.tools, toolCalls.map((tc) => tc.function.name)); } async *checkForPendingToolCalls() { const pendingToolCalls = this.getPendingToolCallsFromMessages(); if (pendingToolCalls.length === 0) return "continue"; const finishEvent = this.createSyntheticFinishedEvent(); this.recordToolCalls(pendingToolCalls); const undiscoveredLazyResults = []; const executablePendingCalls = pendingToolCalls.filter((tc) => { if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) { undiscoveredLazyResults.push({ toolCallId: tc.id, toolName: tc.function.name, result: { error: this.lazyToolManager.getUndiscoveredToolError(tc.function.name) }, state: "output-error" }); return false; } return true; }); const deferredErrorResults = [...undiscoveredLazyResults]; const argsMap = /* @__PURE__ */ new Map(); for (const tc of pendingToolCalls) argsMap.set(tc.id, tc.function.arguments); if (executablePendingCalls.length === 0) { if (deferredErrorResults.length > 0) for (const chunk of this.buildToolResultChunks(deferredErrorResults, finishEvent, argsMap)) yield* this.pipeThroughMiddleware(chunk); return "continue"; } const { approvals, clientToolResults } = this.collectClientState(); const generator = executeToolCalls(executablePendingCalls, this.resolveExecutableTools(executablePendingCalls), approvals, clientToolResults, (eventName, data) => this.createCustomEventChunk(eventName, data), { onBeforeToolCall: async (toolCall, tool, args) => { this.logger.tools(`phase=before name=${toolCall.function.name}`, { name: toolCall.function.name, args }); const hookCtx = { toolCall, tool, args, toolName: toolCall.function.name, toolCallId: toolCall.id }; return this.middlewareRunner.runOnBeforeToolCall(this.middlewareCtx, hookCtx); }, onAfterToolCall: async (info) => { this.logger.tools(`phase=after name=${info.toolName}`, { name: info.toolName, result: info.result }); await this.middlewareRunner.runOnAfterToolCall(this.middlewareCtx, info); } }, this.middlewareCtx.context, this.toolAbortSignal, { deniedToolResults: this.resumeDeniedToolResults, cancelledToolCallIds: this.resumeCancelledToolCallIds }); const executionResult = yield* this.drainToolCallGenerator(generator); if (this.isMiddlewareAborted()) { this.setToolPhase("stop"); return "stop"; } const allResults = [...executionResult.results, ...deferredErrorResults]; await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, { toolCalls: pendingToolCalls, results: allResults, needsApproval: executionResult.needsApproval, needsClientExecution: executionResult.needsClientExecution }); if (executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0) { this.discardDeferredToolCallRunFinishedChunks(); if (allResults.length > 0) for (const chunk of this.buildToolResultChunks(allResults, finishEvent)) yield* this.pipeThroughMiddleware(chunk); const emitted = yield* this.emitActionableInterruptBoundary(finishEvent, executionResult.needsApproval, executionResult.needsClientExecution); this.setToolPhase(emitted ? "wait" : "stop"); return emitted ? "wait" : "stop"; } const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent); for (const chunk of toolResultChunks) yield* this.pipeThroughMiddleware(chunk); return "continue"; } async *processToolCalls() { if (!this.shouldExecuteToolPhase()) { this.lastTurnToolCallCount = 0; this.setToolPhase("stop"); return; } const toolCalls = this.toolCallManager.getToolCalls(); const finishEvent = this.finishedEvent; if (!finishEvent || toolCalls.length === 0) { this.lastTurnToolCallCount = 0; this.setToolPhase("stop"); return; } this.recordToolCalls(toolCalls); this.addAssistantToolCallMessage(toolCalls); const undiscoveredLazyResults = []; const executableToolCalls = toolCalls.filter((tc) => { if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) { undiscoveredLazyResults.push({ toolCallId: tc.id, toolName: tc.function.name, result: { error: this.lazyToolManager.getUndiscoveredToolError(tc.function.name) }, state: "output-error" }); return false; } return true; }); const deferredErrorResults = [...undiscoveredLazyResults]; if (executableToolCalls.length === 0) { yield* this.flushDeferredToolCallRunFinishedChunks(); if (deferredErrorResults.length > 0) for (const chunk of this.buildToolResultChunks(deferredErrorResults, finishEvent)) yield* this.pipeThroughMiddleware(chunk); this.toolCallManager.clear(); this.setToolPhase("continue"); return; } this.middlewareCtx.phase = "beforeTools"; const { approvals, clientToolResults } = this.collectClientState(); const generator = executeToolCalls(executableToolCalls, this.resolveExecutableTools(executableToolCalls), approvals, clientToolResults, (eventName, data) => this.createCustomEventChunk(eventName, data), { onBeforeToolCall: async (toolCall, tool, args) => { this.logger.tools(`phase=before name=${toolCall.function.name}`, { name: toolCall.function.name, args }); const hookCtx = { toolCall, tool, args, toolName: toolCall.function.name, toolCallId: toolCall.id }; return this.middlewareRunner.runOnBeforeToolCall(this.middlewareCtx, hookCtx); }, onAfterToolCall: async (info) => { this.logger.tools(`phase=after name=${info.toolName}`, { name: info.toolName, result: info.result }); await this.middlewareRunner.runOnAfterToolCall(this.middlewareCtx, info); } }, this.middlewareCtx.context, this.toolAbortSignal, { deniedToolResults: this.resumeDeniedToolResults, cancelledToolCallIds: this.resumeCancelledToolCallIds }); const executionResult = yield* this.drainToolCallGenerator(generator); this.middlewareCtx.phase = "afterTools"; if (this.isMiddlewareAborted()) { this.setToolPhase("stop"); return; } const allResults = [...executionResult.results, ...deferredErrorResults]; await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, { toolCalls, results: allResults, needsApproval: executionResult.needsApproval, needsClientExecution: executionResult.needsClientExecution }); if (executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0) { if (allResults.length > 0) for (const chunk of this.buildToolResultChunks(allResults, finishEvent)) yield* this.pipeThroughMiddleware(chunk); const emitted = yield* this.emitActionableInterruptBoundary(finishEvent, executionResult.needsApproval, executionResult.needsClientExecution); this.setToolPhase(emitted ? "wait" : "stop"); return; } yield* this.flushDeferredToolCallRunFinishedChunks(); const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent); for (const chunk of toolResultChunks) yield* this.pipeThroughMiddleware(chunk); if (this.lazyToolManager.hasNewlyDiscoveredTools()) { this.tools = this.lazyToolManager.getActiveTools(); this.toolCallManager = new ToolCallManager(this.tools); this.setToolPhase("continue"); return; } this.toolCallManager.clear(); this.setToolPhase("continue"); } shouldDeferToolCallRunFinished(chunk) { return chunk.type === EventType.RUN_FINISHED && this.finishedEvent?.finishReason === "tool_calls" && this.tools.length > 0 && this.toolCallManager.hasToolCalls(); } *flushDeferredToolCallRunFinishedChunks() { for (const chunk of this.deferredToolCallRunFinishedChunks) { this.logger.output(`type=${chunk.type}`, { chunk }); yield chunk; this.middlewareCtx.chunkIndex++; } this.deferredToolCallRunFinishedChunks = []; } discardDeferredToolCallRunFinishedChunks() { this.deferredToolCallRunFinishedChunks = []; } shouldExecuteToolPhase() { return this.finishedEvent?.finishReason === "tool_calls" && this.tools.length > 0 && this.toolCallManager.hasToolCalls(); } addAssistantToolCallMessage(toolCalls) { this.finalizeCurrentThinkingStep(); this.messages = [...this.messages, { role: "assistant", content: this.accumulatedContent || null, toolCalls, ...this.accumulatedThinking.length > 0 && { thinking: this.accumulatedThinking } }]; this.middlewareCtx.messages = this.messages; } /** * Extract client state (approvals and client tool results) from original messages. * This is called in the constructor BEFORE converting to ModelMessage format, * because the parts array (which contains approval state) is lost during conversion. */ extractClientStateFromOriginalMessages(originalMessages) { const approvals = /* @__PURE__ */ new Map(); const clientToolResults = /* @__PURE__ */ new Map(); for (const message of originalMessages) if (message.role === "assistant" && message.parts) { for (const part of message.parts) if (part.type === "tool-call") { if (part.output !== void 0 && !part.approval) clientToolResults.set(part.id, part.output); if (part.approval?.id && part.approval?.approved !== void 0 && part.state === "approval-responded") approvals.set(part.approval.id, part.approval.approved); } } return { approvals, clientToolResults }; } collectClientState() { const approvals = new Map(this.initialApprovals); const clientToolResults = new Map(this.initialClientToolResults); for (const [approvalId, approved] of this.resumeApprovals) approvals.set(approvalId, approved); for (const [toolCallId, result] of this.resumeClientToolResults) clientToolResults.set(toolCallId, result); for (const message of this.messages) if (message.role === "tool" && message.toolCallId) { let output; if (Array.isArray(message.content)) output = message.content; else try { output = JSON.parse(message.content); } catch { output = message.content; } if (output && typeof output === "object" && output.pendingExecution === true) continue; clientToolResults.set(message.toolCallId, output); } return { approvals, clientToolResults }; } buildActionableInterrupts(approvals, clientRequests) { const interrupts = []; for (const approval of approvals) { const tool = this.tools.find((candidate) => candidate.name === approval.toolName); const normalized = normalizeApprovalSchema(tool?.approvalSchema, tool?.inputSchema); interrupts.push({ id: approval.approvalId, reason: "tool_call", message: `Approval required to run ${approval.toolName}`, toolCallId: approval.toolCallId, responseSchema: normalized.responseSchema, metadata: { kind: "approval", toolName: approval.toolName, input: approval.input, [interruptBindingMetadataKey]: { v: 1, kind: "tool-approval", interruptId: approval.approvalId, toolName: approval.toolName, toolCallId: approval.toolCallId, originalArgs: approval.input, inputSchemaHash: hashSchemaInput(tool?.inputSchema), approvalSchemaHash: normalized.approvalSchemaHash, responseSchemaHash: normalized.responseSchemaHash } } }); } for (const clientTool of clientRequests) { const tool = this.tools.find((candidate) => candidate.name === clientTool.toolName); const responseSchema = convertSchemaToJsonSchema(tool?.outputSchema) ?? {}; interrupts.push({ id: `client_tool_${clientTool.toolCallId}`, reason: "tanstack:client_tool_execution", message: `Client tool ${clientTool.toolName} is ready to run`, toolCallId: clientTool.toolCallId, responseSchema, metadata: { kind: "client_tool", toolName: clientTool.toolName, input: clientTool.input, [interruptBindingMetadataKey]: { v: 1, kind: "client-tool-execution", interruptId: `client_tool_${clientTool.toolCallId}`, toolName: clientTool.toolName, toolCallId: clientTool.toolCallId, outputSchemaHash: hashSchemaInput(tool?.outputSchema), responseSchemaHash: digestInterruptJson(canonicalInterruptJson(responseSchema)) } } }); } return interrupts; } buildInterruptFinishedChunk(finishEvent, approvals, clientRequests) { return { ...finishEvent, timestamp: Date.now(), outcome: { type: "interrupt", interrupts: this.buildActionableInterrupts(approvals, clientRequests) } }; } buildMessagesSnapshotChunk() { const messages = this.messages.map((message, index) => { const content = typeof message.content === "string" ? message.content : message.content === null ? void 0 : JSON.stringify(message.content); return { id: `snapshot_${this.runIdOverride ?? this.requestId}_${index}`, role: message.role, ...content !== void 0 ? { content } : {}, ..."toolCalls" in message && message.toolCalls ? { toolCalls: message.toolCalls } : {}, ..."toolCallId" in message && message.toolCallId ? { toolCallId: message.toolCallId } : {} }; }); return { type: EventType.MESSAGES_SNAPSHOT, timestamp: Date.now(), model: this.params.model, messages }; } publicInterruptTerminal(chunk) { if (chunk.type !== EventType.RUN_FINISHED || chunk.outcome?.type !== "interrupt") return chunk; return { ...chunk, outcome: { ...chunk.outcome, interrupts: chunk.outcome.interrupts.map((interrupt) => { if (!interrupt.metadata || typeof interrupt.metadata !== "object" || Array.isArray(interrupt.metadata)) return interrupt; const metadata = { ...interrupt.metadata }; const binding = normalizePublicInterruptBinding(metadata[interruptBindingMetadataKey], interrupt.id); if (binding) metadata[interruptBindingMetadataKey] = binding; else delete metadata[interruptBindingMetadataKey]; return { ...interrupt, metadata }; }) } }; } interruptFailure(error) { const structured = structuralInterruptFailure(error); if (structured) return { message: structured.error.message, code: structured.errors[0]?.code ?? "server", errors: structured.errors }; if (error && typeof error === "object" && "errors" in error) { const errors = error.errors; if (Array.isArray(errors)) { const first = errors[0]; if (first && typeof first === "object") return { message: "message" in first && typeof first.message === "string" ? first.message : "Interrupt persistence failed.", code: "code" in first && typeof first.code === "string" ? first.code : "server" }; } } return { message: error instanceof Error ? error.message : "Interrupt persistence failed.", code: "server" }; } buildInterruptRunErrorChunk(error) { const failure = this.interruptFailure(error); return { type: EventType.RUN_ERROR, timestamp: Date.now(), runId: this.runIdOverride ?? this.requestId, threadId: this.threadId, message: failure.message, code: failure.code, error: { message: failure.message, code: failure.code }, ...failure.errors !== void 0 ? { "tanstack:interruptErrors": failure.errors } : {} }; } async *emitInterruptRunError(error) { const failure = this.interruptFailure(error); this.finalizationError = { message: failure.message, code: failure.code, cause: error }; yield* this.pipeThroughMiddleware(this.buildInterruptRunErrorChunk(error)); } async *emitActionableInterruptBoundary(finishEvent, approvals, clientRequests) { const terminal = this.completeEphemeralInterruptBindings(this.buildInterruptFinishedChunk(finishEvent, approvals, clientRequests)); let terminalOutputs; try { terminalOutputs = await this.middlewareRunner.runOnChunk(this.middlewareCtx, terminal); } catch (error) { yield* this.emitInterruptRunError(error); return false; } yield* this.pipeThroughMiddleware(this.buildMessagesSnapshotChunk()); if (this.params.state !== void 0) yield* this.pipeThroughMiddleware({ type: EventType.STATE_SNAPSHOT, timestamp: Date.now(), model: this.params.model, snapshot: this.params.state }); for (const output of terminalOutputs) { yield this.publicInterruptTerminal(output); this.middlewareCtx.chunkIndex++; } return true; } completeEphemeralInterruptBindings(chunk) { if (chunk.type !== EventType.RUN_FINISHED || chunk.outcome?.type !== "interrupt") return chunk; const interruptedRunId = this.runIdOverride ?? this.requestId; return { ...chunk, outcome: { ...chunk.outcome, interrupts: chunk.outcome.interrupts.map((interrupt) => { if (!interrupt.metadata || typeof interrupt.metadata !== "object" || Array.isArray(interrupt.metadata)) return interrupt; const metadata = { ...interrupt.metadata }; const unopened = metadata[interruptBindingMetadataKey]; if (unopened === null || typeof unopened !== "object" || Array.isArray(unopened)) return interrupt; metadata[interruptBindingMetadataKey] = { ...unopened, interruptedRunId, generation: 0 }; return { ...interrupt, metadata }; }) } }; } buildToolResultChunks(results, finishEvent, argsMap) { const chunks = []; for (const result of results) { const content = normalizeToolResult(result.result); const wireContent = typeof content === "string" ? content : JSON.stringify(content); if (argsMap) { chunks.push({ type: "TOOL_CALL_START", timestamp: Date.now(), model: finishEvent.model, toolCallId: result.toolCallId, toolCallName: result.toolName, toolName: result.toolName }); const args = argsMap.get(result.toolCallId) ?? "{}"; chunks.push({ type: "TOOL_CALL_ARGS", timestamp: Date.now(), model: finishEvent.model, toolCallId: result.toolCallId, delta: args, args }); chunks.push({ type: "TOOL_CALL_END", timestamp: Date.now(), model: finishEvent.model, toolCallId: result.toolCallId, toolCallName: result.toolName, toolName: result.toolName, result: wireContent, ...result.input !== void 0 && { input: result.input }, ...result.output !== void 0 && { output: result.output }, ...result.state !== void 0 && { state: result.state } }); } chunks.push({ type: "TOOL_CALL_RESULT", timestamp: Date.now(), model: finishEvent.model, messageId: this.createId("tool-result"), toolCallId: result.toolCallId, content: wireContent, role: "tool", ...result.state !== void 0 && { state: result.state } }); const placeholderIdx = this.messages.findIndex((m) => { if (m.role !== "tool" || m.toolCallId !== result.toolCallId) return false; if (typeof m.content !== "string") return false; try { return JSON.parse(m.content)?.pendingExecution === true; } catch { return false; } }); const newToolMessage = { role: "tool", content, toolCallId: result.toolCallId }; if (placeholderIdx >= 0) this.messages = [ ...this.messages.slice(0, placeholderIdx), newToolMessage, ...this.messages.slice(placeholderIdx + 1) ]; else this.messages = [...this.messages, newToolMessage]; this.middlewareCtx.messages = this.messages; } return chunks; } getPendingToolCallsFromMessages() { const completedToolIds = /* @__PURE__ */ new Set(); for (const message of this.messages) if (message.role === "tool" && message.toolCallId) { let hasPendingExecution = false; if (typeof message.content === "string") try { if (JSON.parse(message.content).pendingExecution === true) hasPendingExecution = true; } catch {} if (!hasPendingExecution) completedToolIds.add(message.toolCallId); } const pending = []; for (const message of this.messages) if (message.role === "assistant" && message.toolCalls) for (const toolCall of message.toolCalls) { if (isProviderExecutedToolCall(toolCall)) continue; if (!completedToolIds.has(toolCall.id)) pending.push(toolCall); } return pending; } /** * Find a tool call by id in message history (including already-completed ones). * Used when the client has already attached a tool result for UI before resume. */ findToolCallInMessages(toolCallId) { for (const message of this.messages) { if (message.role !== "assistant" || !message.toolCalls) continue; for (const toolCall of message.toolCalls) if (toolCall.id === toolCallId) return toolCall; } } /** * Tool calls that must be reconstructed as interrupt pending for ephemeral * resume. Includes outstanding tools plus client tools that already have * results in history when the resume batch still carries `client_tool_*` * entries (the client writes local tool results before submitting resume). */ getToolCallsForEphemeralResume(resume) { const pending = this.getPendingToolCallsFromMessages(); const byId = new Map(pending.map((toolCall) => [toolCall.id, toolCall])); for (const entry of resume ?? []) { let toolCallId; if (entry.interruptId.startsWith("client_tool_")) toolCallId = entry.interruptId.slice(12); else if (entry.interruptId.startsWith("approval_")) toolCallId = entry.interruptId.slice(9); if (toolCallId === void 0 || byId.has(toolCallId)) continue; const toolCall = this.findToolCallInMessages(toolCallId); if (toolCall && !isProviderExecutedToolCall(toolCall)) { pending.push(toolCall); byId.set(toolCallId, toolCall); } } return pending; } createSyntheticFinishedEvent() { return { type: "RUN_FINISHED", runId: this.createId("pending"), threadId: this.threadId, model: this.params.model, timestamp: Date.now(), finishReason: "tool_calls" }; } async shouldContinue() { if (this.cyclePhase === "executeToolCalls") return true; const state = { iterationCount: this.iterationCount, messages: this.messages, finishReason: this.lastFinishReason, toolCallCount: this.toolCallCount, lastTurnToolCallCount: this.lastTurnToolCallCount }; const strategyContinues = this.loopStrategy(state); const middlewareContinues = await this.middlewareRunner.runOnShouldContinue(this.middlewareCtx, state); return strategyContinues && middlewareContinues && this.toolPhase === "continue"; } /** * Record tool calls (deduped by id) toward `toolCallCount` / * `lastTurnToolCallCount` for strategies and middleware `onShouldContinue`. * * Used for both live model turns and pending/resume batches. IDs already * counted in this run (e.g. wait→resume after a live turn) are not * re-added to `toolCallCount`. Per-turn execution caps are app middleware * (`onBeforeToolCall` skip), not engine policy. */ recordToolCalls(toolCalls) { this.lastTurnToolCallCount = toolCalls.length; let newlyCounted = 0; for (const tc of toolCalls) if (!this.countedToolCallIds.has(tc.id)) { this.countedToolCallIds.add(tc.id); newlyCounted++; } this.toolCallCount += newlyCounted; } isAborted() { return !!this.effectiveSignal?.aborted; } isMiddlewareAborted() { return !!this.middlewareAbortController?.signal.aborted; } isCancelled() { return this.isAborted() || this.isMiddlewareAborted(); } /** * The reason to report on `AbortInfo` for a cancelled run. * * `this.abortReason` only ever holds a *middleware*-initiated reason * (`ctx.abort(reason)` / `MiddlewareAbortError`). A caller that aborts its own * controller — `abortController.abort(RUN_CANCEL_REASON)`, the in-process * cancel channel — never touches that field, so the reason has to be read back * off the caller's signal, which is the signal `isCancelled()` consults via * `isAborted()`. A signal aborted with no reason carries a DOMException rather * than a string, so non-string reasons are reported as absent. */ resolveAbortReason() { if (this.abortReason !== void 0) return this.abortReason; const signalReason = this.effectiveSignal?.reason; return typeof signalReason === "string" ? signalReason : void 0; } /** * Whether this run's teardown declared its abort a DETACH — see * {@link RunDetachedCapability}. Only `withSandbox`'s `onAbort` publishes it, * and only for a plain, intentless disconnect of a detachable run, so every * other exit path answers `false`. * * Surfaced on the engine (rather than the ctx being handed out) so the * capability read stays inside core, and so the delivery sink learns