UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

567 lines (487 loc) • 14.6 kB
import { promiseWithResolvers } from "../../utils/promiseWithResolvers"; import { getPartialJsonObjectFieldState } from "../../utils/json/parse-partial-json-object"; import { IncrementalJsonObjectParser } from "../../utils/json/incremental-json-object-parser"; import type { ToolCallArgsReader, ToolCallReader, ToolCallResponseReader, } from "./tool-types"; import type { DeepPartial, TypeAtPath, TypePath } from "./type-path-utils"; import type { ToolResponse } from "./ToolResponse"; import { asAsyncIterableStream } from "../../utils/AsyncIterableStream"; import type { AsyncIterableStream, ReadonlyJSONObject, ReadonlyJSONValue, } from "../../utils"; // TODO: remove dispose function getField<T>(obj: T, fieldPath: (string | number)[]): unknown { let current: unknown = obj; for (const key of fieldPath) { if ( current === undefined || current === null || !Object.hasOwn(current, key) ) { return undefined; } current = current[key as keyof typeof current]; } return current; } interface Handle { readonly isDisposed: boolean; update(args: unknown): void; end(args: unknown): void; error(reason: unknown): void; dispose(): void; } class GetHandle<T, TValue> implements Handle { private resolve: (value: TValue) => void; private reject: (reason: unknown) => void; private disposed = false; private fieldPath: (string | number)[]; get isDisposed() { return this.disposed; } constructor( resolve: (value: TValue) => void, reject: (reason: unknown) => void, fieldPath: (string | number)[], ) { this.resolve = resolve; this.reject = reject; this.fieldPath = fieldPath; } update(args: unknown): void { if (this.disposed) return; try { // Check if the field is complete if ( getPartialJsonObjectFieldState( args as Record<string, unknown>, this.fieldPath, ) === "complete" ) { const value = getField(args as T, this.fieldPath); if (value !== undefined) { this.resolve(value as TValue); this.dispose(); } } } catch (e) { this.reject(e); this.dispose(); } } end(args: unknown): void { if (this.disposed) return; try { const value = getField(args as T, this.fieldPath); this.resolve(value as TValue); } catch (e) { this.reject(e); } finally { this.dispose(); } } error(reason: unknown): void { if (this.disposed) return; this.reject(reason); this.dispose(); } dispose(): void { this.disposed = true; } } class StreamValuesHandle<T> implements Handle { private controller: ReadableStreamDefaultController<unknown>; private disposed = false; private fieldPath: (string | number)[]; get isDisposed() { return this.disposed; } constructor( controller: ReadableStreamDefaultController<unknown>, fieldPath: (string | number)[], ) { this.controller = controller; this.fieldPath = fieldPath; } update(args: unknown): void { if (this.disposed) return; try { const value = getField(args as T, this.fieldPath); if (value !== undefined) { this.controller.enqueue(value); } // Check if the field is complete, if so close the stream if ( getPartialJsonObjectFieldState( args as Record<string, unknown>, this.fieldPath, ) === "complete" ) { this.controller.close(); this.dispose(); } } catch (e) { this.controller.error(e); this.dispose(); } } end(): void { if (this.disposed) return; this.controller.close(); this.dispose(); } error(reason: unknown): void { if (this.disposed) return; this.controller.error(reason); this.dispose(); } dispose(): void { this.disposed = true; } } class StreamTextHandle<T> implements Handle { private controller: ReadableStreamDefaultController<unknown>; private disposed = false; private fieldPath: (string | number)[]; private lastValue: string | undefined = undefined; get isDisposed() { return this.disposed; } constructor( controller: ReadableStreamDefaultController<unknown>, fieldPath: (string | number)[], ) { this.controller = controller; this.fieldPath = fieldPath; } update(args: unknown): void { if (this.disposed) return; try { const value = getField(args as T, this.fieldPath); if (typeof value === "string" && value !== this.lastValue) { const delta = value.substring(this.lastValue?.length || 0); this.lastValue = value; this.controller.enqueue(delta); } // Check if the field is complete, if so close the stream if ( getPartialJsonObjectFieldState( args as Record<string, unknown>, this.fieldPath, ) === "complete" ) { this.controller.close(); this.dispose(); } } catch (e) { this.controller.error(e); this.dispose(); } } end(): void { if (this.disposed) return; this.controller.close(); this.dispose(); } error(reason: unknown): void { if (this.disposed) return; this.controller.error(reason); this.dispose(); } dispose(): void { this.disposed = true; } } class ForEachHandle<T> implements Handle { private controller: ReadableStreamDefaultController<unknown>; private disposed = false; private fieldPath: (string | number)[]; private nextIndex = 0; get isDisposed() { return this.disposed; } constructor( controller: ReadableStreamDefaultController<unknown>, fieldPath: (string | number)[], ) { this.controller = controller; this.fieldPath = fieldPath; } update(args: unknown): void { if (this.disposed) return; try { const array = getField(args as T, this.fieldPath); if (!Array.isArray(array)) { return; } // The parser's single partial path can only leave the trailing array element incomplete. for (; this.nextIndex < array.length; this.nextIndex++) { const elementPath = [...this.fieldPath, this.nextIndex]; if ( getPartialJsonObjectFieldState( args as Record<string, unknown>, elementPath, ) !== "complete" ) break; this.controller.enqueue(array[this.nextIndex]); } // Check if the entire array is complete if ( getPartialJsonObjectFieldState( args as Record<string, unknown>, this.fieldPath, ) === "complete" ) { this.controller.close(); this.dispose(); } } catch (e) { this.controller.error(e); this.dispose(); } } end(): void { if (this.disposed) return; this.controller.close(); this.dispose(); } error(reason: unknown): void { if (this.disposed) return; this.controller.error(reason); this.dispose(); } dispose(): void { this.disposed = true; } } // Implementation of ToolCallReader that uses stream of partial JSON export class ToolCallArgsReaderImpl< T extends ReadonlyJSONObject, > implements ToolCallArgsReader<T> { private argTextDeltas: ReadableStream<string>; private handles: Set<Handle> = new Set(); private accumulatedText = ""; private parser: IncrementalJsonObjectParser | undefined; private args: unknown = undefined; private finished = false; private failure: { reason: unknown } | undefined = undefined; constructor(argTextDeltas: ReadableStream<string>) { this.argTextDeltas = argTextDeltas; this.processStream(); } private async processStream(): Promise<void> { try { const reader = this.argTextDeltas.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; this.accumulatedText += value; if (this.handles.size === 0) continue; if (this.parseCurrentArgs(value)) this.updateHandles(); } } catch (error) { this.failure = { reason: error }; } finally { this.finished = true; for (const handle of this.handles) { this.settleHandle(handle); } this.handles.clear(); } } private settleHandle(handle: Handle): void { if (this.failure) { handle.error(this.failure.reason); } else { handle.end(this.args); } } private parseCurrentArgs(delta?: string): boolean { if (this.parser?.currentTextLength === this.accumulatedText.length) { return false; } const previousArgs = this.args; if (!this.parser) { this.parser = IncrementalJsonObjectParser.from( this.accumulatedText, this.args as ReadonlyJSONObject | undefined, ); } else if ( delta !== undefined && this.parser.currentTextLength + delta.length === this.accumulatedText.length ) { this.parser = this.parser.append(delta); } else { this.parser = IncrementalJsonObjectParser.from( this.accumulatedText, this.args as ReadonlyJSONObject | undefined, ); } this.args = this.parser.currentArgs; return this.args !== previousArgs; } private updateHandles(): void { for (const handle of this.handles) { handle.update(this.args); if (handle.isDisposed) this.handles.delete(handle); } } private activateHandle(handle: Handle): void { this.parseCurrentArgs(); handle.update(this.args); if (handle.isDisposed) return; if (this.finished) { this.settleHandle(handle); return; } this.handles.add(handle); } get<PathT extends TypePath<T>>( ...fieldPath: PathT ): Promise<TypeAtPath<T, PathT>> { return new Promise<TypeAtPath<T, PathT>>((resolve, reject) => { const handle = new GetHandle<T, TypeAtPath<T, PathT>>( resolve, reject, fieldPath, ); this.activateHandle(handle); }); } streamValues<PathT extends TypePath<T>>( ...fieldPath: PathT ): AsyncIterableStream<DeepPartial<TypeAtPath<T, PathT>>> { // Use a type assertion to convert the complex TypePath to a simple array const simplePath = fieldPath as unknown as (string | number)[]; let handle: StreamValuesHandle<T> | undefined; const stream = new ReadableStream<DeepPartial<TypeAtPath<T, PathT>>>({ start: (controller) => { handle = new StreamValuesHandle<T>(controller, simplePath); this.activateHandle(handle); }, cancel: () => { // Dispose this stream's own handle (captured above) — scanning for the // first match would dispose a concurrent streamValues()'s handle. if (handle) { handle.dispose(); this.handles.delete(handle); } }, }); return asAsyncIterableStream(stream) as any; } streamText<PathT extends TypePath<T>>( ...fieldPath: PathT ): TypeAtPath<T, PathT> extends string & (infer U) ? AsyncIterableStream<U> : never { // Use a type assertion to convert the complex TypePath to a simple array const simplePath = fieldPath as unknown as (string | number)[]; let handle: StreamTextHandle<T> | undefined; const stream = new ReadableStream<unknown>({ start: (controller) => { handle = new StreamTextHandle<T>(controller, simplePath); this.activateHandle(handle); }, cancel: () => { // Dispose this stream's own handle (captured above) — scanning for the // first match would dispose a concurrent streamText()'s handle. if (handle) { handle.dispose(); this.handles.delete(handle); } }, }); return asAsyncIterableStream(stream) as any; } forEach<PathT extends TypePath<T>>( ...fieldPath: PathT ): NonNullable<TypeAtPath<T, PathT>> extends Array<infer U> ? AsyncIterableStream<U> : never { // Use a type assertion to convert the complex TypePath to a simple array const simplePath = fieldPath as unknown as (string | number)[]; let handle: ForEachHandle<T> | undefined; const stream = new ReadableStream<unknown>({ start: (controller) => { handle = new ForEachHandle<T>(controller, simplePath); this.activateHandle(handle); }, cancel: () => { // Dispose this stream's own handle (captured above) — scanning for the // first match would dispose a concurrent forEach()'s handle. if (handle) { handle.dispose(); this.handles.delete(handle); } }, }); return asAsyncIterableStream(stream) as any; } } export class ToolCallResponseReaderImpl< TResult extends ReadonlyJSONValue, > implements ToolCallResponseReader<TResult> { private readonly promise: Promise<ToolResponse<TResult>>; constructor(promise: Promise<ToolResponse<TResult>>) { this.promise = promise; } public get() { return this.promise; } } export class ToolCallReaderImpl< TArgs extends ReadonlyJSONObject, TResult extends ReadonlyJSONValue, > implements ToolCallReader<TArgs, TResult> { public readonly args: ToolCallArgsReaderImpl<TArgs>; public readonly response: ToolCallResponseReaderImpl<TResult>; private readonly writable: WritableStream<string>; private readonly resolve: (value: ToolResponse<TResult>) => void; public argsText: string = ""; constructor() { const stream = new TransformStream<string, string>(); this.writable = stream.writable; this.args = new ToolCallArgsReaderImpl<TArgs>(stream.readable); const { promise, resolve } = promiseWithResolvers<ToolResponse<TResult>>(); this.resolve = resolve; this.response = new ToolCallResponseReaderImpl<TResult>(promise); } async appendArgsTextDelta(text: string): Promise<void> { const writer = this.writable.getWriter(); try { await writer.write(text); } catch (err) { console.warn(err); } finally { writer.releaseLock(); } this.argsText += text; } async finishArgsText(): Promise<void> { const writer = this.writable.getWriter(); try { await writer.close(); } catch (err) { console.warn(err); } finally { writer.releaseLock(); } } setResponse(value: ToolResponse<TResult>): void { this.resolve(value); } result = { get: async () => { const response = await this.response.get(); return response.result; }, }; }