@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
2,237 lines • 88.2 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 { assertUniqueToolNames } from "./tools/unique-tool-names.js";
import { MiddlewareAbortError, ToolCallManager, executeToolCalls } from "./tools/tool-calls.js";
import { maxIterations } from "./agent-loop-strategies.js";
import { convertMessagesToModelMessages, generateMessageId, modelMessageToUIMessage } 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;
currentMessageCreatedAt = null;
streamIdentityCaptured = false;
accumulatedContent = "";
accumulatedThinking = [];
currentThinkingContent = "";
currentThinkingSignature = "";
hasSeenReasoningEvents = false;
eventOptions;
eventToolNames;
finishedEvent = null;
streamedToolErrorResults = /* @__PURE__ */ new Map();
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;
combinedCompleteEmitted = false;
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);
assertUniqueToolNames(config.params.tools || []);
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 && !this.earlyTermination) {
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.addTerminalReasoningMessage();
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.currentMessageCreatedAt = /* @__PURE__ */ new Date();
this.streamIdentityCaptured = false;
this.accumulatedContent = "";
this.accumulatedThinking = [];
this.currentThinkingContent = "";
this.currentThinkingSignature = "";
this.hasSeenReasoningEvents = false;
this.finishedEvent = null;
this.streamedToolErrorResults.clear();
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 (chunk.type === EventType.CUSTOM && chunk.name === "structured-output.start") {
this.combinedStartEmitted = true;
const startValue = chunk.value;
if (startValue && typeof startValue === "object" && "messageId" in startValue && typeof startValue.messageId === "string") this.combinedStructuredMessageId = startValue.messageId;
}
let outboundChunk = chunk;
if (this.finalStructuredOutput?.source === "event" && chunk.type === EventType.CUSTOM && chunk.name === "structured-output.complete") {
const parsed = readStructuredOutputCompleteValue(chunk.value);
if (parsed) {
const object = this.finalStructuredOutput.normalize ? this.finalStructuredOutput.normalize(parsed.object) : parsed.object;
this.structuredOutputResult = {
data: object,
rawText: parsed.raw
};
this.combinedCompleteEmitted = true;
const value = chunk.value;
if (object !== parsed.object && value && typeof value === "object") outboundChunk = {
...chunk,
value: {
...value,
object
}
};
}
}
if (this.finalStructuredOutput?.nativeCombined === true && this.finalStructuredOutput.yieldChunks && this.finalStructuredOutput.source !== "event" && !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, outboundChunk);
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_START":
if (typeof chunk.messageId === "string" && chunk.messageId !== "") this.captureStreamMessageIdentity(chunk.messageId);
break;
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 "REASONING_MESSAGE_CONTENT": this.handleReasoningMessageContentEvent(chunk);
}
}
handleTextMessageContentEvent(chunk) {
if (chunk.content) this.accumulatedContent = chunk.content;
else this.accumulatedContent += chunk.delta;
this.middlewareCtx.accumulatedContent = this.accumulatedContent;
}
captureStreamMessageIdentity(messageId) {
this.currentMessageId = messageId;
this.middlewareCtx.currentMessageId = messageId;
if (!this.streamIdentityCaptured) {
this.currentMessageCreatedAt = /* @__PURE__ */ new Date();
this.streamIdentityCaptured = true;
}
}
handleToolCallStartEvent(chunk) {
if (typeof chunk.parentMessageId === "string" && chunk.parentMessageId !== "") this.captureStreamMessageIdentity(chunk.parentMessageId);
this.toolCallManager.addToolCallStartEvent(chunk);
}
handleToolCallArgsEvent(chunk) {
this.toolCallManager.addToolCallArgsEvent(chunk);
}
handleToolCallEndEvent(chunk) {
this.toolCallManager.completeToolCall(chunk);
if (chunk.state !== "output-error" || chunk.result === void 0) return;
const toolCall = this.toolCallManager.getToolCalls().find((candidate) => candidate.id === chunk.toolCallId);
if (!toolCall) return;
this.streamedToolErrorResults.set(chunk.toolCallId, {
toolCallId: chunk.toolCallId,
toolName: toolCall.function.name,
result: chunk.result,
...chunk.input !== void 0 && { input: chunk.input },
state: "output-error"
});
}
handleRunFinishedEvent(chunk) {
this.finishedEvent = chunk;
this.lastFinishReason = chunk.finishReason ?? null;
}
handleRunErrorEvent(chunk) {
this.earlyTermination = true;
if (this.finalStructuredOutput && this.finalizationError === null) {
const message = chunk.message || chunk.error?.message || "Run failed before structured output completed";
this.finalizationError = {
message,
...chunk.code !== void 0 ? { code: chunk.code } : chunk.error?.code !== void 0 ? { code: chunk.error.code } : {}
};
}
}
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 (!this.hasSeenReasoningEvents) {
if (chunk.delta) this.currentThinkingContent += chunk.delta;
else if (chunk.content) {
if (chunk.content.startsWith(this.currentThinkingContent)) this.currentThinkingContent = chunk.content;
else if (!this.currentThinkingContent.startsWith(chunk.content)) this.currentThinkingContent += chunk.content;
}
}
if (chunk.signature) this.currentThinkingSignature = chunk.signature;
}
handleReasoningMessageContentEvent(chunk) {
this.hasSeenReasoningEvents = true;
this.currentThinkingContent += chunk.delta;
}
/**
* 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.streamedToolErrorResults.has(tc.id)) return false;
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 = [...this.streamedToolErrorResults.values(), ...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,
id: this.currentMessageId ?? void 0,
createdAt: this.currentMessageCreatedAt ?? void 0,
...this.accumulatedThinking.length > 0 && { thinking: this.accumulatedThinking }
}];
this.middlewareCtx.messages = this.messages;
}
addTerminalReasoningMessage() {
this.finalizeCurrentThinkingStep();
if (this.accumulatedThinking.length === 0) return;
const messages = this.middlewareCtx.messages;
if (messages.some((message) => message.role === "assistant" && message.id === this.currentMessageId)) return;
this.messages = [...messages, {
role: "assistant",
content: this.accumulatedContent || null,
id: this.currentMessageId ?? void 0,
createdAt: this.currentMessageCreatedAt ?? void 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);
const id = message.id || `snapshot_${this.runIdOverride ?? this.requestId}_${index}`;
const parts = message.role === "assistant" && message.thinking?.length ? modelMessageToUIMessage(message, id).parts : void 0;
return {
id,
role: message.role,
...content !== void 0 ? { content } : {},
...parts ? { parts } : {},
..."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 the
* verdict through {@link publishRunDetachedSignal} instead of reaching into a
* middleware context it has no business holding.
*
* @internal
*/
wasDetached() {
return getRunDetached(this.middlewareCtx, { optional: true }) === true;
}
/**
* The delivery socket closed while this run was still going.
*
* Notifies every subscriber (see {@link RunDisconnectCapability}) and RETURNS
* IMMEDIATELY. Synchronous on purpose: it is called from
* `ReadableStream.cancel()`, which must not be made to wait on a run-store
* write, and the caller ({@link notifyRunDisconnected}) has no consumer left to
* report to anyway.
*
* Subscribers therefore run CONCURRENTLY with the still-executing run — which is
* the entire point. The run is typically suspended inside a slow middleware
* `setup` at this moment, so anything dispatched from the run's own unwinding
* would be minutes late. Nothing on this path aborts the run: a durable run
* outlives its viewer.
*
* Each subscriber's promise is parked on `deferredPromises`, which the run awaits
* in its `finally`, so bookkeeping cannot be lost to a race with the run's own
* completion even though nothing awaits it here.
*
* IDEMPOTENT. A second cancel, or one arriving after a terminal hook already ran,
* is ignored: the terminal hooks own the run's outcome, and re-stamping
* `detachedSince` on a run that has already finished would hand a completed run
* to the reaper as reclaimable work.
*
* @internal
*/
notifyDisconnected() {
if (this.disconnected || this.terminalHookCalled) return;
this.disconnected = true;
for (const listener of this.disconnectListeners) this.runDisconnectListener(listener);
}
/**
* Invoke one disconnect listener, isolated and with its failure SWALLOWED after
* logging.
*
* There is no caller left to report to — the socket this would report on is the
* one that just closed — and a rejection parked on `deferredPromises` would
* surface as the run's failure, replacing a healthy outcome with a bookkeeping
* error. Isolation matters for the usual reason too: one subscriber's failing
* write must not skip the next one's.
*/
runDisconnectListener(listener) {
let result;
try {
result = listener();
} catch (error) {
this.logger.errors("run disconnect listener failed", { error });
return;
}
if (result === void 0) return;
this.deferredPromises.push(result.catch((error) => {
this.logger.errors("run disconnect listener failed", { error });
}));
}
/**
* Run the final structured-output adapter call through the middleware
* pipeline. Yields chunks to the caller only when
* `this.finalStructuredOutput.yieldChunks` is true; otherwise consumes
* silently while still piping through middleware.
*
* On success, populates this.structuredOutputResult.
* On failure, populates this.finalizationError.
*/
async *runStructuredFinalization() {
if (!this.finalStructuredOutput) throw new Error("runStructuredFinalization called without finalStructuredOutput config");
this.middlewareCtx.phase = "structuredOutput";
const baseConfig = this.buildMiddlewareConfig();
const { tools: _omitTools, ...baseWithoutTools } = baseConfig;
let structuredConfig = {
...baseWithoutTools,
outputSchema: this.finalStructuredOutput.jsonSchema
};
structuredConfig = await this.middlewareRunner.runOnStructuredOutputConfig(this.middlewareCtx, structuredConfig);
const { outputSchema: pinnedSchema, ...chatConfigSlice } = structuredConfig;
const postOnConfig = await this.middlewareRunner.runOnConfig(this.middlewareCtx, {
...chatConfigSlice,
tools: baseConfig.tools
});
this.applyMiddlewareConfig(postOnConfig);
const structuredCallOptions = {
chatOptions: {
model: this.params.model,
messages: this.messages,
metadata: postOnConfig.metadata,
modelOptions: postOnConfig.modelOptions,
systemPrompts: postOnConfig.systemPrompts,
logger: this.logger,
threadId: this.threadId,
runId: this.runIdOverride,
parentRunId: this.parentRunIdOverride,
...this.effectiveRequest ? { request: this.effectiveRequest } : {}
},
outputSchema: pinnedSchema
};
let fallbackAdapterError = void 0;
const providerStream = this.adapter.structuredOutputStream ? this.adapter.structuredOutputStream(structuredCallOptions) : fallbackStructuredOutputStream(this.adapter, structuredCallOptions, (err) => {
fallbackAdapterError = err;
});
let startEmitted = false;
let structuredMessageId = null;
const extractMessageId = (c) => {
if (c.type === EventType.TEXT_MESSAGE_START || c.type === EventType.TEXT_MESSAGE_CONTENT || c.type === EventType.TEXT_MESSAGE_END) return typeof c.messageId === "string" && c.messageId !== "" ? c.messageId : null;
return null;
};
const buildSynthesizedStart = (timestamp = Date.now()) => {
const idForStart = structuredMessageId ?? generateMessageId();
structuredMessageId = idForStart;
return {
type: EventType.CUSTOM,
name: "structured-output.start",
value: { messageId: idForStart },
model: this.params.model,
timestamp,
threadId: this.threadId,
...this.runIdOverride ? { runId: this.runIdOverride } : {}
};
};
const pipeThroughMiddleware = async (synthChunk) => this.middlewareRunner.runOnChunk(this.middlewareCtx, synthChunk);
let runErrorYielded = false;
for await (const chunk of providerStream) {
if (this.isCancelled()) break;
if (!startEmitted && chunk.type === EventType.CUSTOM && chunk.name === "structured-output.start") startEmitted = true;
if (!structuredMessageId) {
const extracted = extractMessageId(chunk);
if (extracted) structuredMessageId = extracted;
}
if (this.finalStructuredOutput.yieldChunks) {
if (!startEmitted && (chunk.type === EventType.TEXT_MESSAGE_START || chunk.type === EventType.TEXT_MESSAGE_CONTENT || chunk.type === EventType.TEXT_MESSAGE_END)) {
startEmitted = true;
const synthOutputs = await pipeThroughMiddleware(buildSynthesizedStart(chunk.timestamp));
for (const outputChunk of synthOutputs) {
yield outputChunk;
this.middlewareCtx.chunkIndex++;
}
}
if (!startEmitted && chunk.type === EventType.RUN_ERROR) {
startEmitted = true;
const synthOutputs = await pipeThroughMiddleware(buildSynthesizedStart(chunk.timestamp));
for (const outputChunk of synthOutputs) {
yield outputChunk;
this.middlewareCtx.chunkIndex++;
}
}
}
let outboundChunk = chunk;
if (chunk.type === EventType.CUSTOM && chunk.name === "structured-output.complete") {
const parsed = readStructuredOutputCompleteValue(chunk.value);
if (parsed) {
const object = this.finalStructuredOutput.normalize ? this.finalStructuredOutput.normalize(parsed.object) : parsed.object;
this.structuredOutputResult = {
data: object,
rawText: parsed.raw
};
const value = chunk.value;
if (object !== parsed.object && value && typeof value === "object") outboundChunk = {
...chunk,
value: {
...value,
object
}
};
}
}
if (chunk.type === EventType.RUN_FINISHED && chunk.usage) await this.middlewareRunner.runOnUsage(this.middlewareCtx, chunk.usage);
if (chunk.type === EventType.RUN_ERROR) this.finalizationError = {
message: chunk.message,
...chunk.code ? { code: chunk.code } : {},
...fallbackAdapterError !== void 0 ? { cause: fallbackAdapterError } : {}
};
const outputChunks = await this.middlewareRunner.runOnChunk(this.middlewareCtx, outboundChunk);
if (this.finalStructuredOutput.yieldChunks) for (const outputChunk of outputChunks) {
if (outputChunk.type === EventType.RUN_ERROR) runErrorYielded = true;
yield outputChunk;
this.middlewareCtx.chunkIndex++;
}
if (this.finalizationError) break;
}
if (this.isCancelled()) return;
if (!this.structuredOutputResult && !this.finalizationError) this.finalizationError = {
message: "missing structured result",
code: "structured-output-missing-result"
};
if (this.structuredOutputResult && !this.finalizationError && this.finalStructuredOutput.validate) try {
const validated = this.finalStructuredOutput.validate(this.structuredOutputResult.data);
this.validatedStructuredOutput = validated;
this.hasValidatedStructuredOutput = true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.finalizationError = {
message,
code: "structured-output-validation-failed",
cause: err
};
}
if (this.finalizationError && this.finalStructuredOutput.yieldChunks && !runErrorYielded) {
if (!startEmitted) {
const startOutputs = await pipeThroughMiddleware(buildSynthesizedStart());
for (const outputChunk of startOutputs) {
yield outputChunk;
this.middlewareCtx.chunkIndex++;
}
startEmitted = true;
}
const errChunk = {
type: EventType.RUN_ERROR,
runId: this.runIdOverride ?? this.requestId,
model: this.params.model,
timestamp: Date.now(),
threadId: this.threadId,
message: this.finalizationError.message,
...this.finalizationError.code ? { code: this.finalizationError.code } : {},
error: {
message: this.finalizationError.message,
...this.finalizationError.code ? { code: this.finalizationError.code } : {}
}
};
const outputChunks = await this.middlewareRunner.runOnChunk(this.middlewareCtx, errChunk);
for (const outputChunk of outputChunks) {
yield outputChunk;
this.middlewareCtx.chunkIndex++;
}
}
}
/**
* Native combined mode: harvest the structured output from the agent
* loop's accumulated final-turn text (no separate provider call).
*
* The adapter wired `outputSchema` into the regular `chatStream` request,
* so the model's final-turn text is the schema-constrained JSON. We parse
* `this.accumulatedContent`, populate `this.structuredOutputResult`, emit
* a synthetic `structured-output.complete` (and a `structured-output.start`
* if one wasn't emitted earlier — only happens on the streaming path when
* the model returned no text at all), and run the validate callback when
* present. Failures populate `this.finalizationError` so the engine's
* terminal-hook chooser routes to `onError` (per spec §7.3).
*
* The `'structuredOutput'` middleware phase intentionally does NOT fire on
* this path — middleware sees the run through `beforeModel` / `modelStream`
* as usual. See PR #605 / issue #605 for the design rationale.
*/
async *harvestCombinedStructuredOutput() {
if (!this.finalStructuredOutput) throw new Error("harvestCombinedStructuredOutput called without finalStructuredOutput config");
const yieldChunks = this.finalStructuredOutput.yieldChunks;
if ((this.finalStructuredOutput.source ?? "text") === "event") {
if (!this.structuredOutputResult) this.finalizationError = {
message: "missing structured result",
code: "structured-output-missing-result"
};
} else {
const rawText = this.accumulatedContent;
if (rawText.length === 0) this.finalizationError = {
message: "missing structured result",
code: "structured-output-missing-result"
};
else try {
const parsed = JSON.parse(rawText);
const data = this.finalStructuredOutput.normalize ? this.finalStructuredOutput.normalize(parsed) : parsed;
this.structuredOutputResult = {
data,
rawText
};
} catch (err) {
const detail = rawText.slice(0, 200) + (rawText.length > 200 ? "..." : "");
this.finalizationError = {
message: `Failed to parse structured output as JSON. Content: ${detail}`,
code: "structured-output-parse-failed",
cause: err
};
}
}
if (this.structuredOutputResult && !this.finalizationError && this.finalStructuredOutput.validate) try {
const validated = this.finalStructuredOutput.validate(this.structuredOutputResult.data);
this.validatedStructuredOutput = validated;
this.hasValidatedStructuredOutput = true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.finalizationError = {
message,
code: "structured-output-validation-failed",
cause: err
};
}
if (!yieldChunks) return;
if (!this.combinedStartEmitted) {
this.combinedStartEmitted = true;
const messageId = this.combinedStructuredMessageId ?? 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 startOutputs = await this.middlewareRunner.runOnChunk(this.middlewareCtx, synthStart);
for (const outputChunk of startOutputs) {
yield outputChunk;
this.middlewareCtx.chunkIndex++;
}
}
if (this.structuredOutputResult && !this.finalizationError && !this.combinedCompleteEmitted) {
const completeChunk = {
type: EventType.CUSTOM,
name: "structured-output.complete",
value: {
object: this.structuredOutputResult.data,
raw: this.structuredOutputResult.rawText,
...this.combinedStructuredMessageId ? { messageId: this.combinedStructuredMessageId } : {}
},
model: this.params.model,
timestamp: Date.now(),
threadId: this.threadId,
...this.runIdOverride ? { runId: this.runIdOverride } : {}
};
const completeOutputs = await this.middlewareRunner.runOnChunk(this.middlewareCtx, completeChunk);
for (const outputChunk of completeOutputs) {
yield outputChunk;
this.middlewareCtx.chunkIndex++;
}
}
if (this.finalizationError) {
const errChunk = {
type: EventType.RUN_ERROR,
runId: this.runIdOverride ?? this.requestId,
model: this.params.model,
timestamp: Date.now(),
threadId: this.threadId,
message: this.finalizationError.message,
...this.finalizationError.code ? { code: this.finalizationError.code } : {},
error: {
message: this.finalizationError.message,
...this.finalizationError.code ? { code: this.finalizationError.code } : {}
}
};
const errOutputs = await this.middlewareRunner.runOnChunk(this.middlewareCtx, errChunk);
for (const outputChunk of errOutputs) {
yield outputChunk;
this.middlewareCtx.chunkIndex++;
}
}
}
buildMiddlewareConfig() {
return {
messages: this.messages,
systemPrompts: [...this.systemPrompts],
tools: [...this.tools],
resume: this.params.resume,
resumeToolState: {
approvals: this.resumeApprovals,
clientToolResults: this.resumeClientToolResults,
deniedToolResults: this.resumeDeniedToolResults,
cancelledToolCallIds: this.resumeCancelledToolCallIds
},
metadata: this.params.metadata,
modelOptions: this.params.modelOptions
};
}
async applyEphemeralInterruptResume(config) {
if ((config.resume?.length ?? 0) === 0) return;
const interruptedRunId = this.parentRunIdOverride;
if (!interruptedRunId) throw new InterruptResumeValidationError([{
scope: "batch",
threadId: this.threadId,
interruptedRunId: this.runIdOverride ?? this.requestId,
generation: 0,
interruptIds: config.resume?.map((entry) => entry.interruptId) ?? [],
code: "stale",
message: "Interrupt continuation requires parentRunId to identify the interrupted run.",
source: "server",
retryable: false
}]);
const approvalRequests = [];
const clientRequests = [];
const pendingToolCalls = this.getToolCallsForEphemeralResume(config.resume);
const resumeInterruptIds = new Set(config.resume?.map((entry) => entry.interruptId));
const toolInputs = /* @__PURE__ */ new Map();
const toolsByCallId = /* @__PURE__ */ new Map();
const clientExecutionCallIds = /* @__PURE__ */ new Set();
for (const toolCall of pendingToolCalls) {
const tool = this.tools.find((candidate) => candidate.name === toolCall.function.name);
if (!tool) continue;
toolsByCallId.set(toolCall.id, tool);
let input = {};
try {
const parsed = JSON.parse(toolCall.function.arguments.trim() || "{}");
input = parsed && typeof parsed === "object" ? parsed : {};
} catch {
input = {};
}
toolInputs.set(toolCall.id, input);
if (!tool.execute && resumeInterruptIds.has(`client_tool_${toolCall.id}`)) clientExecutionCallIds.add(toolCall.id);
}
for (const toolCall of pendingToolCalls) if (toolsByCallId.get(toolCall.id)?.needsApproval && !clientExecutionCallIds.has(toolCall.id)) approvalRequests.push({
toolCallId: toolCall.id,
toolName: toolCall.function.name,
input: toolInputs.get(toolCall.id) ?? {},
approvalId: `approval_${toolCall.id}`
});
for (const toolCall of pendingToolCalls) {
const tool = toolsByCallId.get(toolCall.id);
if (tool !== void 0 && !tool.execute && (!tool.needsApproval || clientExecutionCallIds.has(toolCall.id))) clientRequests.push({
toolCallId: toolCall.id,
toolName: toolCall.function.name,
input: toolInputs.get(toolCall.id) ?? {}
});
}
const pending = this.buildActionableInterrupts(approvalRequests, clientRequests).flatMap((descriptor) => {
const unopened = readUnopenedInterruptBinding(descriptor);
return unopened ? [{
interruptId: descriptor.id,
payload: descriptor,
binding: {
...unopened,
interruptedRunId,
generation: 0
}
}] : [];
});
const validated = await validateInterruptResumeBatch({
threadId: this.threadId,
interruptedRunId,
generation: 0,
pending,
resume: config.resume,
tools: this.tools
});
if (validated.errors.length > 0 || !validated.resumeToolState) throw new InterruptResumeValidationError(validated.errors);
const approvals = new Map(validated.resumeToolState.approvals);
for (const request of clientRequests) if (toolsByCallId.get(request.toolCallId)?.needsApproval) approvals.set(request.toolCallId, true);
this.applyResumeToolState({
...validated.resumeToolState,
approvals
});
}
applyResumeToolState(state) {
if (state?.approvals) for (const [approvalId, resolution] of state.approvals) this.resumeApprovals.set(approvalId, resolution);
if (state?.clientToolResults) for (const [toolCallId, result] of state.clientToolResults) this.resumeClientToolResults.set(toolCallId, result);
if (state?.deniedToolResults) for (const [toolCallId, result] of state.deniedToolResults) this.resumeDeniedToolResults.set(toolCallId, result);
if (state?.cancelledToolCallIds) for (const toolCallId of state.cancelledToolCallIds) this.resumeCancelledToolCallIds.add(toolCallId);
}
applyMiddlewareConfig(config) {
this.applyResumeToolState(config.resumeToolState);
this.messages = config.messages;
this.systemPrompts = config.systemPrompts;
assertUniqueToolNames(config.tools);
this.tools = config.tools;
this.params = {
...this.params,
metadata: config.metadata,
modelOptions: config.modelOptions
};
this.middlewareCtx.messages = this.messages;
this.middlewareCtx.systemPrompts = this.systemPrompts;
this.middlewareCtx.hasTools = this.tools.length > 0;
this.middlewareCtx.toolNames = this.tools.map((t) => t.name);
this.middlewareCtx.modelOptions = config.modelOptions;
}
setToolPhase(phase) {
this.toolPhase = phase;
}
/**
* Pipe a single chunk through the middleware pipeline (strip-to-spec, devtools, etc.)
* and yield all resulting output chunks.
*/
async *pipeThroughMiddleware(chunk) {
const outputChunks = await this.middlewareRunner.runOnChunk(this.middlewareCtx, chunk);
for (const outputChunk of outputChunks) {
yield outputChunk;
this.middlewareCtx.chunkIndex++;
}
}
/**
* Drain queued `sandbox.file` chunks (emitted via the SandboxRuntime sink)
* through the middleware pipeline and into the public stream.
*/
async *drainSandboxFileQueue() {
while (this.sandboxFileQueue.length > 0) {
const chunk = this.sandboxFileQueue.shift();
if (chunk) yield* this.pipeThroughMiddleware(chunk);
}
}
/**
* Drain an executeToolCalls async generator, yielding any CustomEvent chunks
* through the middleware pipeline and returning the final ExecuteToolCallsResult.
*/
async *drainToolCallGenerator(generator) {
let next = await generator.next();
while (!next.done) {
yield* this.pipeThroughMiddleware(next.value);
next = await generator.next();
}
return next.value;
}
createCustomEventChunk(eventName, value) {
return {
type: "CUSTOM",
timestamp: Date.now(),
model: this.params.model,
name: eventName,
value
};
}
createId(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
};
/**
* Text activity - handles agentic text generation, one-shot text generation, and agentic structured output.
*
* This activity supports four modes:
* 1. **Streaming agentic text**: Stream responses with automatic tool execution
* 2. **Streaming one-shot text**: Simple streaming request/response without tools
* 3. **Non-streaming text**: Returns collected text as a string (stream: false)
* 4. **Agentic structured output**: Run tools, then return structured data
*
* @example Full agentic text (streaming with tools)
* ```ts
* import { chat } from '@tanstack/ai'
* import { openaiText } from '@tanstack/ai-openai'
*
* for await (const chunk of chat({
* adapter: openaiText('gpt-5.5'),
* messages: [{ role: 'user', content: 'What is the weather?' }],
* tools: [weatherTool]
* })) {
* if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
* console.log(chunk.delta)
* }
* }
* ```
*
* @example One-shot text (streaming without tools)
* ```ts
* for await (const chunk of chat({
* adapter: openaiText('gpt-5.5'),
* messages: [{ role: 'user', content: 'Hello!' }]
* })) {
* console.log(chunk)
* }
* ```
*
* @example Non-streaming text (stream: false)
* ```ts
* const text = await chat({
* adapter: openaiText('gpt-5.5'),
* messages: [{ role: 'user', content: 'Hello!' }],
* stream: false
* })
* // text is a string with the full response
* ```
*
* @example Agentic structured output (tools + structured response)
* ```ts
* import { z } from 'zod'
*
* const result = await chat({
* adapter: openaiText('gpt-5.5'),
* messages: [{ role: 'user', content: 'Research and summarize the topic' }],
* tools: [researchTool, analyzeTool],
* outputSchema: z.object({
* summary: z.string(),
* keyPoints: z.array(z.string())
* })
* })
* // result is { summary: string, keyPoints: string[] }
* ```
*/
function chat(options) {
validateCapabilities(options.middleware ?? [], options.adapter);
if (options.tools) assertUniqueToolNames(options.tools);
const { outputSchema, stream } = options;
if (outputSchema && stream === true) return runStreamingStructuredOutput({
...options,
outputSchema,
stream
});
if (outputSchema) return runAgenticStructuredOutput({
...options,
outputSchema
});
if (stream === false) return runNonStreamingText({
...options,
outputSchema: void 0,
stream
});
return runStreamingText({
...options,
outputSchema: void 0,
stream
});
}
/**
* Publish both delivery-side seams for `stream`.
*
* Shared by the two streaming paths so they cannot drift apart — the
* structured-output path having been wired for one seam and not the other is
* exactly the bug `publishRunDetachedSignal` picked up last time (a durable
* `chat({ outputSchema, stream: true })` could never detach).
*/
function publishDeliverySeams(stream, engineRef) {
publishRunDetachedSignal(stream, () => engineRef.current?.wasDetached() === true);
publishRunDisconnectHandler(stream, () => {
engineRef.current?.notifyDisconnected();
});
}
/**
* Run streaming text (agentic or one-shot depending on tools).
*
* A thin, NON-generator wrapper, because the stream object is also the key the
* durable delivery sink looks the run's detach verdict up under (see
* `../../delivery-detach`) and delivers its disconnect notification through (see
* `../../delivery-disconnect`). A generator function cannot reach the generator it
* returns, so the identity has to be minted out here and the engine reached back
* through `engineRef`, which the body fills as soon as its engine exists.
*/
function runStreamingText(options) {
const engineRef = {};
const stream = streamTextChunks(options, engineRef);
publishDeliverySeams(stream, engineRef);
return stream;
}
async function* streamTextChunks(options, engineRef) {
const { adapter, middleware, context, debug, mcp, ...textOptions } = options;
const model = adapter.model;
const logger = resolveDebugOption(debug);
const mcpManager = MCPManager.from(mcp);
const mcpTools = await mcpManager.discover();
if (mcpTools.length > 0) textOptions.tools = [...textOptions.tools ?? [], ...mcpTools];
const engine = new TextEngine({
adapter,
params: {
...textOptions,
model,
logger
},
middleware,
context
}, logger);
engineRef.current = engine;
try {
for await (const chunk of engine.run()) yield chunk;
} finally {
await mcpManager.dispose();
}
}
/**
* Run non-streaming text - collects all content and returns as a string.
* Runs the full agentic loop (if tools are provided) but returns collected text.
*/
function runNonStreamingText(options) {
const stream = runStreamingText(options);
return streamToText(stream);
}
/**
* Run agentic structured output:
* 1. Execute the full agentic loop (with tools)
* 2. Once complete, call adapter.structuredOutput with the conversation context
* 3. Validate and return the structured result
*/
async function runAgenticStructuredOutput(options) {
const { adapter, outputSchema, middleware, context, debug, mcp, ...textOptions } = options;
const model = adapter.model;
const logger = resolveDebugOption(debug);
if (!outputSchema) throw new Error("outputSchema is required for structured output");
const { jsonSchema, nullWideningMap } = convertSchemaForStructuredOutput(outputSchema);
if (!jsonSchema) throw new Error("Failed to convert output schema to JSON Schema");
const normalize = (data) => undoNullWidening(data, nullWideningMap);
const validate = isStandardSchema(outputSchema) ? (data) => parseWithStandardSchema(outputSchema, data) : void 0;
const nativeCombined = adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true;
const source = adapter.combinedStructuredOutputSource?.(options.modelOptions) ?? "text";
const mcpManager = MCPManager.from(mcp);
const mcpTools = await mcpManager.discover();
if (mcpTools.length > 0) textOptions.tools = [...textOptions.tools ?? [], ...mcpTools];
const engine = new TextEngine({
adapter,
params: {
...textOptions,
model,
logger
},
middleware,
context,
finalStructuredOutput: {
jsonSchema,
yieldChunks: false,
normalize,
...validate ? { validate } : {},
...nativeCombined ? { nativeCombined: true } : {},
source
}
}, logger);
try {
for await (const _chunk of engine.run());
} finally {
await mcpManager.dispose();
}
const finalizationError = engine.getFinalizationError();
if (finalizationError) {
const err = new Error(finalizationError.message, finalizationError.cause !== void 0 ? { cause: finalizationError.cause } : void 0);
if (finalizationError.code !== void 0) Object.defineProperty(err, "code", {
value: finalizationError.code,
enumerable: true
});
throw err;
}
const validated = engine.getValidatedStructuredOutput();
if (validated) return validated.value;
const result = engine.getStructuredOutputResult();
if (!result) throw new Error("structured output finalization produced no result");
return result.data;
}
/**
* Parse the `value` payload of a `structured-output.complete` CUSTOM event
* into a typed shape, returning `null` if the runtime payload doesn't match.
*
* Uses an `unknown`-input runtime check rather than `as` casts so the engine
* stays cast-free in its hot path.
*/
function readStructuredOutputCompleteValue(value) {
if (typeof value !== "object" || value === null) return null;
if (!("object" in value) || !("raw" in value)) return null;
const raw = value.raw;
if (typeof raw !== "string") return null;
const reasoningField = value.reasoning;
const reasoning = typeof reasoningField === "string" ? reasoningField : void 0;
return {
object: value.object,
raw,
...reasoning !== void 0 ? { reasoning } : {}
};
}
/**
* Synthesize a streaming structured-output stream by wrapping a non-streaming
* `structuredOutput` call. Used when an adapter doesn't implement
* `structuredOutputStream` natively.
*
* `onAdapterError`, when provided, is invoked with the raw error from
* `adapter.structuredOutput` before the synthesized RUN_ERROR is yielded.
* The engine uses this to preserve the original error (stack, cause, custom
* properties like provider `status`/`code`) as `finalizationError.cause`,
* because the RUN_ERROR wire shape only carries `message` and `code`.
*/
async function* fallbackStructuredOutputStream(adapter, options, onAdapterError) {
const { chatOptions } = options;
const fallbackRand = Math.random().toString(36).slice(2);
const runId = chatOptions.runId ?? `fallback-${Date.now()}-${fallbackRand}`;
const threadId = chatOptions.threadId ?? `fallback-${Date.now()}-${fallbackRand}`;
const messageId = `fallback-${Date.now()}-${fallbackRand}`;
const model = chatOptions.model;
const startedAt = Date.now();
yield {
type: EventType.RUN_STARTED,
runId,
threadId,
model,
timestamp: startedAt
};
let result;
try {
result = await adapter.structuredOutput(options);
} catch (error) {
onAdapterError?.(error);
const message = error instanceof Error ? error.message : String(error);
yield {
type: EventType.RUN_ERROR,
runId,
threadId,
model,
timestamp: Date.now(),
message,
error: { message }
};
return;
}
yield {
type: EventType.TEXT_MESSAGE_START,
messageId,
role: "assistant",
model,
timestamp: Date.now()
};
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId,
delta: result.rawText,
model,
timestamp: Date.now()
};
yield {
type: EventType.TEXT_MESSAGE_END,
messageId,
model,
timestamp: Date.now()
};
yield {
type: EventType.CUSTOM,
name: "structured-output.complete",
value: {
object: result.data,
raw: result.rawText
},
model,
timestamp: Date.now()
};
yield {
type: EventType.RUN_FINISHED,
runId,
threadId,
model,
timestamp: Date.now(),
finishReason: "stop",
...result.usage ? { usage: result.usage } : {}
};
}
/**
* Run streaming structured output via the TextEngine, with the engine's
* `finalStructuredOutput.yieldChunks: true` mode. The agent loop's
* RUN_STARTED/RUN_FINISHED are suppressed; the structured-output finalization
* step's pair brackets the run for the consumer.
*
* Standard Schema *validation* is intentionally NOT run on this path — it is
* the consumer's responsibility. This is a deliberate asymmetry vs.
* `runAgenticStructuredOutput` (Promise<T> path), which DOES validate inside
* the engine and routes validation failures through `onError`. The reason:
* streaming consumers typically render partial JSON progressively (via
* `parsePartialJSON` or `useChat`'s `partial` slot) and validate downstream
* after assembly. Running validation server-side would force a hard error
* on partial-by-design payloads. See `docs/structured-outputs/overview.md`.
*
* Null-widening normalization, however, IS run on both paths: the
* `structured-output.complete` CUSTOM event is forwarded with its `value.object`
* already un-widened (synthesized strict-mode nulls dropped, genuine
* `.nullable()` nulls kept), so a consumer validating the assembled object
* against the original schema doesn't choke on a `null` for an `.optional()`
* field. Same `convertSchemaForStructuredOutput` pass and same
* `undoNullWidening` map as the Promise<T> path — the two must not diverge.
*
* Pre-flight validation (missing schema, unconvertible schema) throws
* synchronously at call time rather than as a yielded RUN_ERROR mid-stream —
* those are programmer errors, not runtime conditions.
*/
function runStreamingStructuredOutput(options) {
const { outputSchema } = options;
if (!outputSchema) throw new Error("outputSchema is required for streaming structured output");
const { jsonSchema, nullWideningMap } = convertSchemaForStructuredOutput(outputSchema);
if (!jsonSchema) throw new Error("Failed to convert output schema to JSON Schema");
const normalize = (data) => undoNullWidening(data, nullWideningMap);
const engineRef = {};
const stream = runStreamingStructuredOutputImpl(options, jsonSchema, normalize, engineRef);
publishDeliverySeams(stream, engineRef);
return stream;
}
async function* runStreamingStructuredOutputImpl(options, jsonSchema, normalize, engineRef) {
const { adapter, outputSchema, middleware, context, debug, mcp, ...textOptions } = options;
const model = adapter.model;
const logger = resolveDebugOption(debug);
const nativeCombined = adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true;
const source = adapter.combinedStructuredOutputSource?.(options.modelOptions) ?? "text";
const mcpManager = MCPManager.from(mcp);
const mcpTools = await mcpManager.discover();
if (mcpTools.length > 0) textOptions.tools = [...textOptions.tools ?? [], ...mcpTools];
const engine = new TextEngine({
adapter,
params: {
...textOptions,
model,
logger
},
middleware,
context,
finalStructuredOutput: {
jsonSchema,
yieldChunks: true,
normalize,
...nativeCombined ? { nativeCombined: true } : {},
source
}
}, logger);
engineRef.current = engine;
try {
for await (const chunk of engine.run()) yield chunk;
} finally {
await mcpManager.dispose();
}
}
//#endregion
export { chat, createChatOptions, kind };
//# sourceMappingURL=index.js.map