UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

349 lines (320 loc) • 10.9 kB
import type { Tool, ToolCallReader, ToolExecuteFunction, ToolExecutionContext, } from "./tool-types"; import type { StandardSchemaV1 } from "@standard-schema/spec"; import { ToolResponse } from "./ToolResponse"; import { ToolExecutionStream } from "./ToolExecutionStream"; import type { InternalToolExecutionOptions } from "./internal-tool-execution-options"; import type { AssistantMessage, ToolCallPart } from "../utils/types"; import type { ReadonlyJSONObject, ReadonlyJSONValue } from "../../utils"; const TOOL_EXECUTION_ID = Symbol.for("assistant-stream.tool-execution-id"); const TOOL_ABORTED = Symbol("assistant-stream.tool-aborted"); type InternalHumanCallback = ( toolCallId: string, payload: unknown, executionId: symbol, ) => Promise<unknown>; type InternalToolResultStreamOptions = Omit< ToolResultStreamOptions, "onExecutionStart" | "onExecutionEnd" > & { onExecutionStart?: | ((toolCallId: string, toolName: string, executionId: symbol) => void) | undefined; onExecutionEnd?: | ((toolCallId: string, toolName: string, executionId: symbol) => void) | undefined; }; const isStandardSchemaV1 = ( schema: Tool["parameters"], ): schema is StandardSchemaV1<Record<string, unknown>> => { return ( typeof schema === "object" && schema !== null && "~standard" in schema && (schema as StandardSchemaV1<unknown>)["~standard"].version === 1 ); }; const isThenable = <T>(value: T | PromiseLike<T>): value is PromiseLike<T> => typeof (value as PromiseLike<T> | null | undefined)?.then === "function"; const raceWithAbort = async <T>( value: PromiseLike<T>, abortSignal: AbortSignal, // Tool execution gets two microtasks to settle after handling an abort. delayAbort = false, ): Promise<T | typeof TOOL_ABORTED> => { let onAbort!: () => void; const abortPromise = new Promise<typeof TOOL_ABORTED>((resolve) => { onAbort = () => { if (delayAbort) { queueMicrotask(() => queueMicrotask(() => resolve(TOOL_ABORTED))); } else { resolve(TOOL_ABORTED); } }; if (abortSignal.aborted) { onAbort(); } else { abortSignal.addEventListener("abort", onAbort, { once: true }); } }); try { return await Promise.race([value, abortPromise]); } finally { abortSignal.removeEventListener("abort", onAbort); } }; const cancelledToolResponse = (): ToolResponse<ReadonlyJSONValue> => new ToolResponse({ result: "Tool execution was cancelled.", isError: true, }); function getToolResponse( tools: Record<string, Tool> | undefined, abortSignal: AbortSignal, toolCall: { toolCallId: string; toolName: string; args: ReadonlyJSONObject; executionId: symbol; }, human: InternalHumanCallback, ) { const tool = tools?.[toolCall.toolName]; if (!tool?.execute) return undefined; const getResult = async ( toolExecute: ToolExecuteFunction<Record<string, unknown>, unknown>, ): Promise<ToolResponse<ReadonlyJSONValue>> => { if (abortSignal.aborted) { return cancelledToolResponse(); } let executeFn = toolExecute; let args: Record<string, unknown> = toolCall.args; if (isStandardSchemaV1(tool.parameters)) { const result = tool.parameters["~standard"].validate(toolCall.args); const validationResult = isThenable(result) ? await raceWithAbort(result, abortSignal) : result; if (validationResult === TOOL_ABORTED) { return cancelledToolResponse(); } if (validationResult.issues) { executeFn = tool.experimental_onSchemaValidationError ?? (() => { throw new Error( `Function parameter validation failed. ${JSON.stringify(validationResult.issues)}`, ); }); } else { args = validationResult.value; } } if (abortSignal.aborted) { return cancelledToolResponse(); } const executePromise = (async () => { const executionContext = { toolCallId: toolCall.toolCallId, abortSignal, human: (payload: unknown) => human(toolCall.toolCallId, payload, toolCall.executionId), [TOOL_EXECUTION_ID]: toolCall.executionId, } as ToolExecutionContext; const result = (await executeFn( args, executionContext, )) as unknown as ReadonlyJSONValue; const response = ToolResponse.toResponse(result); if ( tool.toModelOutput && !response.isError && response.modelContent === undefined ) { try { const modelContent = await tool.toModelOutput({ toolCallId: toolCall.toolCallId, input: args, output: response.result, }); return new ToolResponse({ result: response.result, artifact: response.artifact, isError: response.isError, messages: response.messages, modelContent, }); } catch (e) { console.warn( `[assistant-stream] tool "${toolCall.toolName}" toModelOutput threw; falling back to default projection.`, e, ); } } return response; })(); const executionResult = await raceWithAbort( executePromise, abortSignal, true, ); return executionResult === TOOL_ABORTED ? cancelledToolResponse() : executionResult; }; return getResult(tool.execute); } function getToolStreamResponse( tools: Record<string, Tool> | undefined, abortSignal: AbortSignal, reader: ToolCallReader<any, ReadonlyJSONValue>, context: { toolCallId: string; toolName: string; executionId: symbol; }, human: InternalHumanCallback, ) { const executionContext = { toolCallId: context.toolCallId, abortSignal, human: (payload: unknown) => human(context.toolCallId, payload, context.executionId), [TOOL_EXECUTION_ID]: context.executionId, } as ToolExecutionContext; return tools?.[context.toolName]?.streamCall?.(reader, executionContext); } const isPendingToolCall = ( part: AssistantMessage["parts"][number], ): part is Extract<ToolCallPart, { result?: undefined }> => part.type === "tool-call" && part.state !== "result" && part.result === undefined && part.approval === undefined; /** @deprecated Experimental since 2025-04-07. Not scheduled for removal; the API may change in any release. */ export async function unstable_runPendingTools( message: AssistantMessage, tools: Record<string, Tool> | undefined, abortSignal: AbortSignal, human: (toolCallId: string, payload: unknown) => Promise<unknown>, ) { const toolCallPromises = message.parts .filter(isPendingToolCall) .map(async (part) => { const promiseOrUndefined = getToolResponse( tools, abortSignal, { ...part, executionId: Symbol() }, (human as InternalHumanCallback) ?? (async () => { throw new Error( "Tool human input is not supported in this context", ); }), ); if (promiseOrUndefined) { const result = await promiseOrUndefined; return { part, result, }; } return null; }); const toolCallResults = (await Promise.all(toolCallPromises)).filter( (result) => result !== null, ) as { part: ToolCallPart; result: ToolResponse<ReadonlyJSONValue> }[]; if (toolCallResults.length === 0) { return message; } const toolCallResultsByPart = new Map( toolCallResults.map(({ part, result }) => [part, result]), ); const updatedParts = message.parts.map((p) => { if (isPendingToolCall(p)) { const toolResponse = toolCallResultsByPart.get(p); if (toolResponse) { return { ...p, state: "result" as const, ...(toolResponse.artifact !== undefined ? { artifact: toolResponse.artifact } : {}), ...(toolResponse.modelContent !== undefined ? { modelContent: toolResponse.modelContent } : {}), ...(toolResponse.messages !== undefined ? { messages: toolResponse.messages } : {}), result: toolResponse.result as ReadonlyJSONValue, isError: toolResponse.isError, }; } } return p; }); return { ...message, parts: updatedParts, content: updatedParts, }; } export type ToolResultStreamOptions = { /** Called after frontend tool execution starts. Callback failures are reported without interrupting the tool. */ onExecutionStart?: (toolCallId: string, toolName: string) => void; /** Called after frontend tool execution finishes or fails. Callback failures are reported without changing the result. */ onExecutionEnd?: (toolCallId: string, toolName: string) => void; }; /** * Transform stream that executes frontend tools and appends tool results. * * The transform watches streamed tool-call arguments, runs the matching * frontend tool once its arguments are complete, and emits a result chunk for * the tool call. A call streamed inside a step runs when that step finishes, * and a call that carries an approval is left to the provider. Backend and * human tools pass through according to their tool definition. * * @param tools Tool registry or function returning the current registry. * @param abortSignal Signal, or signal getter, used for the current run. * @param human Callback used to resolve human-tool requests from UI input. * @param options Optional execution lifecycle callbacks. */ export function toolResultStream( tools: | Record<string, Tool> | (() => Record<string, Tool> | undefined) | undefined, abortSignal: AbortSignal | (() => AbortSignal), human: (toolCallId: string, payload: unknown) => Promise<unknown>, options?: ToolResultStreamOptions, ) { const toolsFn = typeof tools === "function" ? tools : () => tools; const abortSignalFn = typeof abortSignal === "function" ? abortSignal : () => abortSignal; const internalOptions = options as | InternalToolResultStreamOptions | undefined; const internalHuman = human as InternalHumanCallback; const executionOptions: InternalToolExecutionOptions = { execute: (toolCall) => getToolResponse(toolsFn(), abortSignalFn(), toolCall, internalHuman), streamCall: ({ reader, ...context }) => getToolStreamResponse( toolsFn(), abortSignalFn(), reader, context, internalHuman, ), onExecutionStart: internalOptions?.onExecutionStart, onExecutionEnd: internalOptions?.onExecutionEnd, }; return new ToolExecutionStream( executionOptions as unknown as ConstructorParameters< typeof ToolExecutionStream >[0], ); }