UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

176 lines (157 loc) • 5.66 kB
import type { AssistantStream } from "../AssistantStream"; import type { AssistantStreamChunk } from "../AssistantStreamChunk"; import { NO_RESULT, type ToolResponseLike } from "../tool/ToolResponse"; import type { ReadonlyJSONValue } from "../../utils/json/json-value"; import type { ToolCallApproval } from "../utils/types"; import type { UnderlyingReadable } from "../utils/stream/UnderlyingReadable"; import { TextStreamControllerImpl, type TextStreamController } from "./text"; import { closeIfOpen, enqueueIfOpen } from "../utils/stream/controller-guards"; import { createControllerStream, createControllerStreamPair, } from "../utils/stream/createControllerStream"; export type ToolCallStreamController = { argsText: TextStreamController; /** * Sets a tool response. Preliminary responses keep the part open; a final * response closes it automatically and subsequent calls are ignored. */ setResponse(response: ToolResponseLike<ReadonlyJSONValue>): void; /** Sets the call's approval gate, replacing any earlier one. Ignored once the part is closed. */ setApproval(approval: ToolCallApproval): void; close(): void; }; type ToolCallStreamOptions = { strict?: boolean | undefined; onClose?: (() => void) | undefined; }; export class ToolCallStreamControllerImpl implements ToolCallStreamController { private _isClosed = false; private _hasArgsText = false; private _argsTextState: "open" | "finishing" | "finished" = "open"; private _controller: ReadableStreamDefaultController<AssistantStreamChunk>; private _argsTextController: TextStreamControllerImpl; private _options: ToolCallStreamOptions; constructor( _controller: ReadableStreamDefaultController<AssistantStreamChunk>, options: ToolCallStreamOptions = {}, ) { this._controller = _controller; this._options = options; this._argsTextController = new TextStreamControllerImpl( { enqueue: (chunk) => { if (this._argsTextState !== "open") { // enqueueIfOpen reads any TypeError as a closed stream, so lenient // mode drops this delta with its warning instead of throwing. throw new TypeError("Cannot append to finished tool-call args"); } if (chunk.type === "text-delta") { this._hasArgsText = true; this._controller.enqueue(chunk); return; } // The args finish waits for the end of the tick so a result set in // the same tick goes out first; ToolExecutionStream only treats a // backend result as authoritative when it precedes the args finish. this._argsTextState = "finishing"; queueMicrotask(() => this._finishArgsText()); }, close: () => {}, }, { strict: this._options.strict }, ); } private _finishArgsText() { if (this._argsTextState !== "finishing") return; this._argsTextState = "finished"; if (!this._hasArgsText) { // if no argsText was provided, assume empty object enqueueIfOpen(this._controller, { type: "text-delta", textDelta: "{}", path: [], }); } enqueueIfOpen(this._controller, { type: "tool-call-args-text-finish", path: [], }); } get argsText() { return this._argsTextController; } async setResponse(response: ToolResponseLike<ReadonlyJSONValue>) { if (this._isClosed) return; // Wire decoders hand this a raw payload, so an omitted result is // materialized here rather than reaching the message part as a settled call // indistinguishable from one that never finished. const result = response.result; enqueueIfOpen(this._controller, { type: "result", path: [], ...(response.artifact !== undefined ? { artifact: response.artifact } : {}), result: result === undefined ? NO_RESULT : result, isError: response.isError ?? false, ...(response.isPreliminary ? { isPreliminary: true } : {}), ...(response.modelContent !== undefined ? { modelContent: response.modelContent } : {}), ...(response.messages !== undefined ? { messages: response.messages } : {}), }); if (response.isPreliminary) { this._argsTextController.close(); this._finishArgsText(); } else { await this.close(); } } setApproval(approval: ToolCallApproval) { enqueueIfOpen(this._controller, { type: "tool-call-approval", path: [], approval, }); } async close() { if (this._isClosed) return; this._isClosed = true; this._options.onClose?.(); this._argsTextController.close(); this._finishArgsText(); enqueueIfOpen(this._controller, { type: "part-finish", path: [], }); closeIfOpen(this._controller); } __internal_truncate() { if (this._isClosed) return; this._isClosed = true; this._finishArgsText(); this._argsTextController.__internal_truncate(); this._argsTextState = "finished"; closeIfOpen(this._controller); } } export const createToolCallStream = ( readable: UnderlyingReadable<ToolCallStreamController>, options: ToolCallStreamOptions = {}, ): AssistantStream => { return createControllerStream( readable, (controller) => new ToolCallStreamControllerImpl(controller, options), ); }; export const createToolCallStreamController = ( options: ToolCallStreamOptions = {}, ) => { return createControllerStreamPair< AssistantStreamChunk, ToolCallStreamControllerImpl >((controller) => new ToolCallStreamControllerImpl(controller, options)); };