assistant-stream
Version:
Streaming utilities for AI assistants
349 lines (320 loc) • 10.9 kB
text/typescript
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],
);
}