@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
JavaScript
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