UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

509 lines (465 loc) • 15.2 kB
import { AssistantStream } from "../AssistantStream"; import type { AssistantStreamChunk, PartInit } from "../AssistantStreamChunk"; import { createMergeStream, type MergeStreamFinishOrder, } from "../utils/stream/merge"; import { TextStreamControllerImpl, type TextStreamController } from "./text"; import { ToolCallStreamControllerImpl, type ToolCallStreamController, } from "./tool-call"; import { PathAppendEncoder } from "../utils/stream/path-utils"; import type { DataPart, FilePart, PartProviderMetadata, SourcePart, } from "../utils/types"; import { generateId } from "../utils/generateId"; import type { ReadonlyJSONObject, ReadonlyJSONValue, } from "../../utils/json/json-value"; import type { ToolResponseLike } from "../tool/ToolResponse"; import { promiseWithResolvers } from "../../utils/promiseWithResolvers"; import { createControllerStreamPair } from "../utils/stream/createControllerStream"; type ToolCallPartInit = { toolCallId?: string; toolName: string; argsText?: string; args?: ReadonlyJSONObject; response?: ToolResponseLike<ReadonlyJSONValue>; }; type ReasoningPartInit = { /** @deprecated Experimental since 2026-08-07. Not scheduled for removal; the API may change in any release. */ unstable_summary?: string; providerMetadata?: PartProviderMetadata; }; type TextPartInit = { providerMetadata?: PartProviderMetadata; }; /** * Imperative writer for constructing an {@link AssistantStream}. * * The controller handles part boundaries for common streaming operations. Use * `appendText` and `appendReasoning` for simple token streams, or open explicit * parts with `addTextPart` and `addToolCallPart` when you need direct control. */ export type AssistantStreamController = { /** Appends text to the current text part, opening one if needed. */ appendText(textDelta: string): void; /** * Appends reasoning text to the current reasoning part, opening one if * needed. Supplying options opens a new part and applies them to it, so a * summary always lands on a part of its own. */ appendReasoning(reasoningDelta: string, options?: ReasoningPartInit): void; /** Appends a source citation part to the stream. */ appendSource(options: SourcePart): void; /** Appends a file part to the stream. */ appendFile(options: FilePart): void; /** Appends a named data part to the stream. */ appendData(options: DataPart): void; /** * Opens a text part and returns its writer. * * Close the returned controller when the text part is complete. Opening a new * part through this controller closes any implicit text or reasoning append * part first. */ addTextPart(options?: TextPartInit): TextStreamController; /** * Opens a reasoning part and returns its writer. * * Use the options object to provide an app-authored summary for the part. * A summary makes the data stream emit a reasoning part-start frame, which * decoders older than that frame reject, so a stream only requires a current * client once it uses the field. */ addReasoningPart(options?: ReasoningPartInit): TextStreamController; /** * Opens a tool-call part by tool name and returns its writer. * * A tool call id is generated automatically. Use the object overload when the * caller already has an id, initial args, args text, or response. */ addToolCallPart(options: string): ToolCallStreamController; /** * Opens a tool-call part and returns its writer. * * Use this overload to provide a stable `toolCallId`, initial arguments, * streamed argument text, or an immediate {@link ToolResponseLike}. */ addToolCallPart(options: ToolCallPartInit): ToolCallStreamController; /** Enqueues a raw protocol chunk. Prefer higher-level helpers when possible. */ enqueue(chunk: AssistantStreamChunk): void; /** * Merges another assistant stream into this stream. * * Paths from the merged stream are remapped so its parts appear at the next * available position in this controller's output. */ merge(stream: AssistantStream): void; /** Closes any active part and finishes the stream. */ close(): void; /** * Returns a controller that writes child parts with `parentId` attached. * * Use this for nested or related parts that should be associated with an * existing message or part in downstream renderers. */ withParentId(parentId: string): AssistantStreamController; }; type AssistantStreamOptions = { strict?: boolean | undefined; }; const createCountedPartStream = <TController>( makeController: ( controller: ReadableStreamDefaultController<AssistantStreamChunk>, ) => TController, ) => { let written = 0; const [stream, controller] = createControllerStreamPair< AssistantStreamChunk, TController >((c) => makeController({ get desiredSize() { return c.desiredSize; }, enqueue(chunk) { c.enqueue(chunk); written++; }, close() { c.close(); }, error(reason) { c.error(reason); }, }), ); return [stream, controller, () => written] as const; }; // Shared state between controller instances type AssistantStreamControllerState = { strict: boolean; merger: ReturnType<typeof createMergeStream>; append?: | { controller: TextStreamController; kind: "text" | "reasoning"; parentId: string | undefined; } | undefined; openInputs: Set<() => void>; lastToolCallFinish: Promise<void>; closeSubscriber?: () => void; }; class AssistantStreamControllerImpl implements AssistantStreamController { private readonly _state: AssistantStreamControllerState; private _parentId?: string; constructor( state?: AssistantStreamControllerState, options: AssistantStreamOptions = {}, ) { this._state = state || { strict: options.strict ?? true, merger: createMergeStream(), openInputs: new Set(), lastToolCallFinish: Promise.resolve(), }; } get __internal_isClosed() { return ( this._state.merger.isSealed() || this._state.merger.isCancelled() || this._state.merger.isErrored() ); } get __internal_isCancelled() { return this._state.merger.isCancelled(); } __internal_getReadable() { return this._state.merger.readable; } __internal_endOpenInputs() { this._state.append = undefined; for (const end of this._state.openInputs) end(); this._state.openInputs.clear(); } __internal_subscribeToClose(callback: () => void) { this._state.closeSubscriber = callback; } private _addTransformedStream( stream: AssistantStream, transformer: ReadableWritablePair< AssistantStreamChunk, AssistantStreamChunk >, options?: { orderedFinish?: MergeStreamFinishOrder | undefined; written?: () => number; pathTransform?: "merge" | undefined; }, ) { if (stream.locked) { throw new TypeError( "Cannot merge a stream that is already locked to a reader.", ); } const pipeTask = stream .pipeTo(transformer.writable) .catch(async (error) => { await transformer.writable.abort(error).catch(() => undefined); throw error; }); this._state.merger.addStream(transformer.readable, pipeTask, options); return pipeTask; } private _trackOpenInput(pipeTask: Promise<void>, end: () => void) { const { openInputs } = this._state; openInputs.add(end); const forget = () => openInputs.delete(end); pipeTask.then(forget, forget); } private _addPart( part: PartInit, stream: AssistantStream, written: () => number, end?: () => void, orderedFinish?: MergeStreamFinishOrder, ) { if (this._state.append) { this._state.append.controller.close(); this._state.append = undefined; } const partIndex = this._state.merger.enqueuePartStart({ type: "part-start", part, path: [], }); const pipeTask = this._addTransformedStream( stream, new PathAppendEncoder(partIndex), { orderedFinish, written }, ); if (end) this._trackOpenInput(pipeTask, end); } merge(stream: AssistantStream) { // The identity stage delays the merged stream's buffered chunks, so a raw // chunk enqueued before they are delivered lands ahead of the merged part, // never between its part-start and deltas. this._addTransformedStream( stream, new TransformStream<AssistantStreamChunk, AssistantStreamChunk>(), { pathTransform: "merge" }, ); } appendText(textDelta: string) { if ( this._state.append?.kind !== "text" || this._state.append.parentId !== this._parentId ) { this._state.append = { kind: "text", parentId: this._parentId, controller: this.addTextPart(), }; } this._state.append.controller.append(textDelta); } appendReasoning(textDelta: string, options?: ReasoningPartInit) { // An init describes a part, so supplying one opens a part rather than // extending whichever one happens to be open. if ( options !== undefined || this._state.append?.kind !== "reasoning" || this._state.append.parentId !== this._parentId ) { this._state.append = { kind: "reasoning", parentId: this._parentId, controller: this.addReasoningPart(options), }; } // Opening a part from an init with no text is how a summary-only part is // created, and appending there would put a chunk on the stream that no // producer emitted. An empty delta from an ordinary caller still appends. if (options !== undefined && textDelta.length === 0) return; this._state.append.controller.append(textDelta); } addTextPart(options?: TextPartInit) { const [stream, controller, written] = createCountedPartStream( (c) => new TextStreamControllerImpl(c, { strict: this._state.strict }), ); this._addPart( this._withParentIdOption({ type: "text", ...options }), stream, written, () => controller.__internal_close(), ); return controller; } addReasoningPart(options?: ReasoningPartInit) { const [stream, controller, written] = createCountedPartStream( (c) => new TextStreamControllerImpl(c, { strict: this._state.strict }), ); this._addPart( this._withParentIdOption({ type: "reasoning", ...options }), stream, written, () => controller.__internal_close(), ); return controller; } addToolCallPart( options: string | ToolCallPartInit, ): ToolCallStreamController { const opt = typeof options === "string" ? { toolName: options } : options; const toolName = opt.toolName; const toolCallId = opt.toolCallId ?? generateId(); const delivered = promiseWithResolvers<void>(); let previous = Promise.resolve(); const [stream, controller, written] = createCountedPartStream( (c) => new ToolCallStreamControllerImpl(c, { strict: this._state.strict, onClose: () => { previous = this._state.lastToolCallFinish; this._state.lastToolCallFinish = delivered.promise; }, }), ); this._addPart( { type: "tool-call", toolName, toolCallId, ...(this._parentId && { parentId: this._parentId }), }, stream, written, () => controller.__internal_truncate(), { previous: () => previous, delivered }, ); if (opt.argsText !== undefined) { controller.argsText.append(opt.argsText); controller.argsText.close(); } if (opt.args !== undefined) { controller.argsText.append(JSON.stringify(opt.args)); controller.argsText.close(); } if (opt.response !== undefined) { controller.setResponse(opt.response); } return controller; } private _finishedPartStream(): AssistantStream { return new ReadableStream({ start(controller) { controller.enqueue({ type: "part-finish", path: [] }); controller.close(); }, }); } private _withParentIdOption<T>(options: T): T { if (!this._parentId) return options; return { ...options, parentId: this._parentId }; } appendSource(options: SourcePart) { this._addPart( this._withParentIdOption(options), this._finishedPartStream(), () => 1, ); } appendFile(options: FilePart) { this._addPart( this._withParentIdOption(options), this._finishedPartStream(), () => 1, ); } appendData(options: DataPart) { this._addPart( this._withParentIdOption(options), this._finishedPartStream(), () => 1, ); } enqueue(chunk: AssistantStreamChunk) { this._state.merger.enqueue(chunk); } withParentId(parentId: string): AssistantStreamController { const controller = new AssistantStreamControllerImpl(this._state); controller._parentId = parentId; return controller; } close() { this._state.append?.controller?.close(); this._state.merger.seal(); this._state.closeSubscriber?.(); } } /** * Creates an {@link AssistantStream} and writes to it with an * {@link AssistantStreamController}. * * The callback may write synchronously or asynchronously. If it throws while * the stream is open, an `error` chunk is emitted. Failures after an explicit * close are logged, while failures after consumer cancellation are discarded. * When the callback settles, the stream is closed automatically unless the * controller was already closed. */ export function createAssistantStream( callback: (controller: AssistantStreamController) => PromiseLike<void> | void, options: AssistantStreamOptions = {}, ): AssistantStream { const controller = new AssistantStreamControllerImpl(undefined, options); const runTask = async () => { try { await callback(controller); } catch (e) { if (!controller.__internal_isClosed) { controller.enqueue({ type: "error", path: [], error: String(e), }); controller.__internal_endOpenInputs(); } else if (!controller.__internal_isCancelled) { console.error(e); } } finally { if (!controller.__internal_isClosed) { controller.close(); } } }; runTask(); return controller.__internal_getReadable(); } /** * Creates an {@link AssistantStream} together with the controller used to * write into it. * * Use this when the stream needs to be returned before all writers are known. * Closing the returned controller finishes the paired stream. */ export function createAssistantStreamController( options: AssistantStreamOptions = {}, ) { const { resolve, promise } = promiseWithResolvers<void>(); let controller!: AssistantStreamController; const stream = createAssistantStream((c) => { controller = c; (controller as AssistantStreamControllerImpl).__internal_subscribeToClose( resolve, ); return promise; }, options); return [stream, controller] as const; }