UNPKG

@tanstack/ai

Version:

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

539 lines (538 loc) 18.3 kB
import { isStandardSchema, parseWithStandardSchema } from "./schema-converter.js"; import { normalizeToolResult } from "../../../utilities/tool-result.js"; //#region src/activities/chat/tools/tool-calls.ts function safeJsonParse(value) { try { return JSON.parse(value); } catch { return value; } } function readMcpAppMeta(tool) { return tool.metadata?.mcp; } /** * Eagerly read a tool's linked `ui://` resource (MCP Apps) and emit a * `ui-resource` CUSTOM event so the client can render the widget. The model * still receives the normal text tool-result; the widget rides alongside and * never enters model input. * * Fail-soft: any read error logs a warning and emits nothing — it never throws, * so the normal tool-result still flows and a broken widget cannot break the run. */ async function emitUiResourceIfLinked(tool, context) { const mcp = readMcpAppMeta(tool); const uiUri = mcp?.uiResourceUri; if (!uiUri || !mcp.readResource) return; let matched; try { matched = (await mcp.readResource(uiUri)).contents.find((c) => c.uri === uiUri); } catch (err) { console.warn(`[mcp-apps] failed to read ui resource ${uiUri}:`, err); return; } if (!matched) { console.warn(`[mcp-apps] ui resource ${uiUri} returned no content matching that uri; not emitting`); return; } context.emitCustomEvent("ui-resource", { resource: { uri: matched.uri, mimeType: matched.mimeType ?? "text/html", text: matched.text, blob: matched.blob }, serverId: mcp.serverId, toolName: mcp.serverToolName ?? tool.name, meta: void 0 }); } /** * Error thrown when middleware decides to abort the chat run during tool execution. */ var MiddlewareAbortError = class extends Error { constructor(reason) { super(reason); this.name = "MiddlewareAbortError"; } }; /** * Manages tool call accumulation and execution for the chat() method's automatic tool execution loop. * * Responsibilities: * - Accumulates streaming tool call events (ID, name, arguments) * - Validates tool calls (filters out incomplete ones) * - Executes tool `execute` functions with parsed arguments * - Emits `TOOL_CALL_END` events for client visibility * - Returns tool result messages for conversation history * * This class is used internally by the AI.chat() method to handle the automatic * tool execution loop. It can also be used independently for custom tool execution logic. * * @example * ```typescript * const manager = new ToolCallManager(tools); * * // During streaming, accumulate tool calls * for await (const chunk of stream) { * if (chunk.type === 'TOOL_CALL_START') { * manager.addToolCallStartEvent(chunk); * } else if (chunk.type === 'TOOL_CALL_ARGS') { * manager.addToolCallArgsEvent(chunk); * } * } * * // After stream completes, execute tools * if (manager.hasToolCalls()) { * const toolResults = yield* manager.executeTools(finishEvent); * messages = [...messages, ...toolResults]; * manager.clear(); * } * ``` */ var ToolCallManager = class { toolCallsMap = /* @__PURE__ */ new Map(); tools; constructor(tools) { this.tools = tools; } /** * Add a TOOL_CALL_START event to begin tracking a tool call (AG-UI) */ addToolCallStartEvent(event) { const index = event.index ?? this.toolCallsMap.size; const runtimeEvent = event; const name = runtimeEvent.toolCallName ?? runtimeEvent.toolName; this.toolCallsMap.set(index, { id: event.toolCallId, type: "function", function: { name, arguments: "" }, ...event.metadata !== void 0 && { metadata: event.metadata } }); } /** * Add a TOOL_CALL_ARGS event to accumulate arguments (AG-UI) */ addToolCallArgsEvent(event) { for (const [, toolCall] of this.toolCallsMap.entries()) if (toolCall.id === event.toolCallId) { toolCall.function.arguments += event.delta; break; } } /** * Complete a tool call with its final input * Called when TOOL_CALL_END is received */ completeToolCall(event) { for (const [, toolCall] of this.toolCallsMap.entries()) if (toolCall.id === event.toolCallId) { if (event.input !== void 0) { const normalized = event.input && typeof event.input === "object" ? event.input : {}; toolCall.function.arguments = JSON.stringify(normalized); } break; } } /** * Check if there are any complete tool calls to execute */ hasToolCalls() { return this.getToolCalls().length > 0; } /** * Get all complete tool calls (filtered for valid ID and name) */ getToolCalls() { return Array.from(this.toolCallsMap.values()).filter((tc) => tc.id && tc.function.name && tc.function.name.trim().length > 0); } /** * Execute all tool calls and return tool result messages * Yields TOOL_CALL_END events for streaming * @param finishEvent - RUN_FINISHED event from the stream */ async *executeTools(finishEvent, ...contextArgs) { const toolCallsArray = this.getToolCalls(); const toolResults = []; const hasRuntimeContext = contextArgs.length > 0; const userContext = contextArgs[0]; for (const toolCall of toolCallsArray) { const tool = this.tools.find((t) => t.name === toolCall.function.name); let toolResultContent; let toolResultState; let toolOutput; if (tool?.execute) try { let args; try { const argsString = toolCall.function.arguments.trim() || "{}"; const parsed = JSON.parse(argsString); args = parsed && typeof parsed === "object" ? parsed : {}; } catch (parseError) { throw new Error(`Failed to parse tool arguments as JSON: ${toolCall.function.arguments}`); } if (tool.inputSchema && isStandardSchema(tool.inputSchema)) try { args = parseWithStandardSchema(tool.inputSchema, args); } catch (validationError) { const message = validationError instanceof Error ? validationError.message : "Validation failed"; throw new Error(`Input validation failed for tool ${tool.name}: ${message}`); } const executionContext = { toolCallId: toolCall.id, context: userContext, emitCustomEvent: () => {} }; let result = hasRuntimeContext ? await tool.execute(args, executionContext) : await tool.execute(args); if (tool.outputSchema && isStandardSchema(tool.outputSchema)) try { result = parseWithStandardSchema(tool.outputSchema, result); } catch (validationError) { const message = validationError instanceof Error ? validationError.message : "Validation failed"; throw new Error(`Output validation failed for tool ${tool.name}: ${message}`); } toolOutput = result; toolResultContent = normalizeToolResult(result); } catch (error) { toolResultContent = `Error executing tool: ${error instanceof Error ? error.message : "Unknown error"}`; toolResultState = "output-error"; } else toolResultContent = `Tool ${toolCall.function.name} does not have an execute function`; yield { type: "TOOL_CALL_END", toolCallId: toolCall.id, toolCallName: toolCall.function.name, toolName: toolCall.function.name, model: finishEvent.model, timestamp: Date.now(), ...toolOutput !== void 0 ? { output: toolOutput } : {}, result: toolResultContent, ...toolResultState !== void 0 && { state: toolResultState } }; toolResults.push({ role: "tool", content: toolResultContent, toolCallId: toolCall.id }); } return toolResults; } /** * Clear the tool calls map for the next iteration */ clear() { this.toolCallsMap.clear(); } }; function approvalResolution(approvals, toolCallId) { return approvals.get(toolCallId) ?? approvals.get(`approval_${toolCallId}`); } function isApproved(resolution) { return typeof resolution === "boolean" ? resolution : resolution.approved; } function editedApprovalArgs(resolution) { return typeof resolution === "object" && resolution.approved ? resolution.editedArgs : void 0; } function deniedApprovalResult(resolution) { return typeof resolution === "object" && !resolution.approved ? resolution.payload ?? { error: "User declined tool execution" } : { error: "User declined tool execution" }; } /** * Helper that runs a tool execution promise while polling for pending custom events. * Yields any custom events that are emitted during execution, then returns the * execution result. */ async function* executeWithEventPolling(executionPromise, pendingEvents) { const state = { done: false, result: void 0 }; const executionWithFlag = executionPromise.then((r) => { state.done = true; state.result = r; return r; }); while (!state.done) { await Promise.race([executionWithFlag, new Promise((resolve) => setTimeout(resolve, 10))]); let event; while ((event = pendingEvents.shift()) !== void 0) yield event; } let event; while ((event = pendingEvents.shift()) !== void 0) yield event; return state.result; } /** * Apply a middleware onBeforeToolCall decision. * Returns the (possibly transformed) input if execution should proceed, * or undefined if the tool call was skipped (result already pushed). * Throws MiddlewareAbortError if the decision is 'abort'. */ async function applyBeforeToolCallDecision(toolCall, tool, input, toolName, middlewareHooks, results) { if (!middlewareHooks.onBeforeToolCall) return { proceed: true, input }; const decision = await middlewareHooks.onBeforeToolCall(toolCall, tool, input); if (!decision) return { proceed: true, input }; if (decision.type === "abort") throw new MiddlewareAbortError(decision.reason || "Aborted by middleware"); if (decision.type === "skip") { const skipResult = decision.result; results.push({ toolCallId: toolCall.id, toolName, result: typeof skipResult === "string" ? safeJsonParse(skipResult) : skipResult ?? null, duration: 0 }); if (middlewareHooks.onAfterToolCall) await middlewareHooks.onAfterToolCall({ toolCall, tool, toolName, toolCallId: toolCall.id, ok: true, duration: 0, result: skipResult }); return { proceed: false }; } return { proceed: true, input: decision.args }; } /** * Execute a server-side tool with event polling, output validation, and middleware hooks. * Yields CustomEvent chunks during execution and pushes the result to the results array. */ async function* executeServerTool(toolCall, tool, toolName, input, context, pendingEvents, results, middlewareHooks) { const startTime = Date.now(); try { if (!tool.execute) throw new Error(`Tool ${toolName} has no execute() implementation`); let result = yield* executeWithEventPolling(Promise.resolve(tool.execute(input, context)), pendingEvents); const duration = Date.now() - startTime; await emitUiResourceIfLinked(tool, context); let pendingEvent; while ((pendingEvent = pendingEvents.shift()) !== void 0) yield pendingEvent; if (tool.outputSchema && isStandardSchema(tool.outputSchema)) result = parseWithStandardSchema(tool.outputSchema, result); const finalResult = typeof result === "string" ? safeJsonParse(result) : result ?? null; results.push({ toolCallId: toolCall.id, toolName, result: finalResult, input, output: finalResult, duration }); if (middlewareHooks?.onAfterToolCall) await middlewareHooks.onAfterToolCall({ toolCall, tool, toolName, toolCallId: toolCall.id, ok: true, duration, result: finalResult }); } catch (error) { const duration = Date.now() - startTime; let pendingEvent; while ((pendingEvent = pendingEvents.shift()) !== void 0) yield pendingEvent; if (error instanceof MiddlewareAbortError) throw error; const message = error instanceof Error ? error.message : "Unknown error"; results.push({ toolCallId: toolCall.id, toolName, result: { error: message }, input, state: "output-error", duration }); if (middlewareHooks?.onAfterToolCall) await middlewareHooks.onAfterToolCall({ toolCall, tool, toolName, toolCallId: toolCall.id, ok: false, duration, error }); } } function buildClientToolResult(toolCallId, toolName, tool, rawResult, input) { try { let result = rawResult; if (tool.outputSchema && isStandardSchema(tool.outputSchema)) result = parseWithStandardSchema(tool.outputSchema, result); const parsed = typeof result === "string" ? safeJsonParse(result) : result ?? null; return { toolCallId, toolName, result: parsed, input, output: parsed }; } catch (error) { return { toolCallId, toolName, result: { error: error instanceof Error ? error.message : "Validation failed" }, input, state: "output-error" }; } } /** * Execute tool calls based on their configuration. * Yields CustomEvent chunks during tool execution for real-time progress updates. * * Handles three cases: * 1. Client tools (no execute) - request client to execute * 2. Server tools with approval - check approval before executing * 3. Normal server tools - execute immediately * * @param toolCalls - Tool calls from the LLM * @param tools - Available tools with their configurations * @param approvals - Map keyed by toolCallId (or `approval_${toolCallId}`) → ToolApprovalResolution * @param clientResults - Map of client-side execution results (toolCallId -> result) * @param createCustomEventChunk - Factory to create CustomEvent chunks (optional) */ async function* executeToolCalls(toolCalls, tools, approvals = /* @__PURE__ */ new Map(), clientResults = /* @__PURE__ */ new Map(), createCustomEventChunk, middlewareHooks, userContext, abortSignal, resumeState) { const results = []; const needsApproval = []; const needsClientExecution = []; const toolMap = /* @__PURE__ */ new Map(); for (const tool of tools) toolMap.set(tool.name, tool); const hasPendingApprovals = toolCalls.some((tc) => { return toolMap.get(tc.function.name)?.needsApproval && approvalResolution(approvals, tc.id) === void 0 && !resumeState?.cancelledToolCallIds?.has(tc.id); }); for (const toolCall of toolCalls) { const tool = toolMap.get(toolCall.function.name); const toolName = toolCall.function.name; if (!tool) { results.push({ toolCallId: toolCall.id, toolName, result: { error: `Unknown tool: ${toolName}` }, state: "output-error" }); continue; } if (hasPendingApprovals) { const isPendingApproval = tool.needsApproval && approvalResolution(approvals, toolCall.id) === void 0; const isPlainClientRequest = !tool.needsApproval && !tool.execute; if (!isPendingApproval && !isPlainClientRequest) continue; } if (resumeState?.cancelledToolCallIds?.has(toolCall.id)) { results.push({ toolCallId: toolCall.id, toolName, result: { error: "Tool execution cancelled" }, state: "output-error" }); continue; } let input = {}; const argsStr = toolCall.function.arguments.trim() || "{}"; if (argsStr) try { const parsed = JSON.parse(argsStr); input = parsed && typeof parsed === "object" ? parsed : {}; } catch (parseError) { throw new Error(`Failed to parse tool arguments as JSON: ${argsStr}`); } if (tool.inputSchema && isStandardSchema(tool.inputSchema)) try { input = parseWithStandardSchema(tool.inputSchema, input); } catch (validationError) { const message = validationError instanceof Error ? validationError.message : "Validation failed"; results.push({ toolCallId: toolCall.id, toolName, result: { error: `Input validation failed for tool ${tool.name}: ${message}` }, input, state: "output-error" }); continue; } const pendingEvents = []; const context = { toolCallId: toolCall.id, context: userContext, abortSignal, emitCustomEvent: (eventName, value) => { if (createCustomEventChunk) pendingEvents.push(createCustomEventChunk(eventName, { ...value, toolCallId: toolCall.id })); } }; if (!tool.execute) { if (tool.needsApproval) { const approvalId = `approval_${toolCall.id}`; const resolution = approvalResolution(approvals, toolCall.id); if (resolution !== void 0) if (isApproved(resolution)) { input = editedApprovalArgs(resolution) ?? input; if (clientResults.has(toolCall.id)) results.push(buildClientToolResult(toolCall.id, toolName, tool, clientResults.get(toolCall.id), input)); else needsClientExecution.push({ toolCallId: toolCall.id, toolName, input }); } else results.push({ toolCallId: toolCall.id, toolName, result: resumeState?.deniedToolResults?.get(toolCall.id) ?? deniedApprovalResult(resolution), input, state: "output-error" }); else needsApproval.push({ toolCallId: toolCall.id, toolName: toolCall.function.name, input, approvalId }); } else if (clientResults.has(toolCall.id)) results.push(buildClientToolResult(toolCall.id, toolName, tool, clientResults.get(toolCall.id), input)); else needsClientExecution.push({ toolCallId: toolCall.id, toolName, input }); continue; } if (tool.needsApproval) { const approvalId = `approval_${toolCall.id}`; const resolution = approvalResolution(approvals, toolCall.id); if (resolution !== void 0) if (isApproved(resolution)) { input = editedApprovalArgs(resolution) ?? input; if (middlewareHooks) { const decision = await applyBeforeToolCallDecision(toolCall, tool, input, toolName, middlewareHooks, results); if (!decision.proceed) continue; input = decision.input; } yield* executeServerTool(toolCall, tool, toolName, input, context, pendingEvents, results, middlewareHooks); } else results.push({ toolCallId: toolCall.id, toolName, result: resumeState?.deniedToolResults?.get(toolCall.id) ?? deniedApprovalResult(resolution), input, state: "output-error" }); else needsApproval.push({ toolCallId: toolCall.id, toolName, input, approvalId }); continue; } if (middlewareHooks) { const decision = await applyBeforeToolCallDecision(toolCall, tool, input, toolName, middlewareHooks, results); if (!decision.proceed) continue; input = decision.input; } yield* executeServerTool(toolCall, tool, toolName, input, context, pendingEvents, results, middlewareHooks); } return { results, needsApproval, needsClientExecution }; } //#endregion export { MiddlewareAbortError, ToolCallManager, executeServerTool, executeToolCalls }; //# sourceMappingURL=tool-calls.js.map