UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

463 lines (408 loc) • 13.8 kB
import type { AssistantStreamChunk } from "../../AssistantStreamChunk"; import { Counter } from "../Counter"; import { createPathMergeMapper } from "./path-utils"; import { promiseWithResolvers } from "../../../utils/promiseWithResolvers"; export type MergeStreamFinishOrder = { previous: () => Promise<void>; delivered: ReturnType<typeof promiseWithResolvers<void>>; }; type MergeStreamHold = { writtenBefore: number; reached: ReturnType<typeof promiseWithResolvers<void>>; flushed: Promise<void>; }; type MergeStreamItem = { reader: ReadableStreamDefaultReader<AssistantStreamChunk>; pipeTask?: Promise<unknown> | undefined; promise?: Promise<unknown> | undefined; orderedFinish?: MergeStreamFinishOrder | undefined; written?: (() => number) | undefined; delivered: number; gate?: Promise<void> | undefined; holds: MergeStreamHold[]; pathMapper?: | ((chunk: AssistantStreamChunk) => AssistantStreamChunk) | undefined; }; type PartIndexReference = { value: number | undefined; promise: Promise<number>; }; type PartIndexReservation = { reference: PartIndexReference; resolve: (index: number) => void; }; type RawChunkBatchItem = { chunk: AssistantStreamChunk; partIndex?: PartIndexReservation | undefined; }; type MergeStreamOptions = { orderedFinish?: MergeStreamFinishOrder | undefined; written?: (() => number) | undefined; pathTransform?: "merge" | undefined; }; export const createMergeStream = () => { const list: MergeStreamItem[] = []; const pendingPartIndexes = new Set<PartIndexReservation>(); const contentCounter = new Counter(); let sealed = false; let cancelled = false; let errored = false; let controller: ReadableStreamDefaultController<AssistantStreamChunk>; let rawChunkBatch: RawChunkBatchItem[] | undefined; let rawBarrier: Promise<void> | undefined; let pendingRawBatches = 0; let currentPull: ReturnType<typeof promiseWithResolvers<void>> | undefined; let cleanupPromise: Promise<void> | undefined; const releaseHolds = (item: MergeStreamItem) => { for (const hold of item.holds) hold.reached.resolve(); item.holds = []; }; const cancelAllReaders = () => { // Repeated cancellation must wait for cleanup already in progress. rawChunkBatch = undefined; for (const partIndex of pendingPartIndexes) { partIndex.reference.value = -1; partIndex.resolve(-1); } pendingPartIndexes.clear(); if (!cleanupPromise) { const items = list.splice(0); for (const item of items) { item.orderedFinish?.delivered.resolve(); releaseHolds(item); } cleanupPromise = Promise.all( items.map(async (item) => { try { await item.reader.cancel().catch(() => undefined); await item.pipeTask; } finally { item.reader.releaseLock(); } }), ).then(() => undefined); } return cleanupPromise; }; const handleError = (e: unknown) => { if (cancelled || errored) return; errored = true; console.error(e); void cancelAllReaders(); controller.error(e); currentPull?.reject(e); currentPull = undefined; }; const handlePull = (item: MergeStreamItem) => { if (!item.promise) { // TODO for most streams, we can directly pipeTo to avoid the microTask queue // add an option to eagerly pipe the stream to the merge stream // ideally, using assistant-stream w sync run method + piping to a sync WritableStream runs in the same microtask // this is useful because we often use AssistantStreams internally as a serialization utility, e. g. AssistantTransformStream // idea: avoid reader.read() by instead using a WritableStream & if (!hasPendingPull) await waitForPull()? item.promise = item.reader .read() .then(async ({ done, value }) => { if (cancelled || errored) return; if (done) { item.orderedFinish?.delivered.resolve(); list.splice(list.indexOf(item), 1); item.reader.releaseLock(); releaseHolds(item); if (sealed && list.length === 0 && pendingRawBatches === 0) { controller.close(); } } else { if (item.gate) { await item.gate; item.gate = undefined; if (cancelled || errored) return; } while ( item.holds[0] && item.delivered >= item.holds[0].writtenBefore ) { await item.holds[0].flushed; item.holds.shift(); if (cancelled || errored) return; } if (value.type === "part-finish" && item.orderedFinish) { await item.orderedFinish.previous(); if (cancelled || errored) return; } controller.enqueue(item.pathMapper?.(value) ?? value); item.delivered++; for (const hold of item.holds) { if (item.delivered >= hold.writtenBefore) hold.reached.resolve(); } if (value.type === "part-finish") { item.orderedFinish?.delivered.resolve(); } } item.promise = undefined; currentPull?.resolve(); currentPull = undefined; }) .catch((error) => { item.promise = undefined; handleError(error); }); } }; const readable = new ReadableStream<AssistantStreamChunk>({ start(c) { controller = c; }, pull() { currentPull = promiseWithResolvers(); list.forEach((item) => { handlePull(item); }); return currentPull.promise; }, async cancel() { cancelled = true; const cleanup = cancelAllReaders(); currentPull?.resolve(); currentPull = undefined; await cleanup; }, }); const holdChild = (item: MergeStreamItem, flushed: Promise<void>) => { const hold: MergeStreamHold = { writtenBefore: item.written!(), reached: promiseWithResolvers<void>(), flushed, }; if (item.delivered >= hold.writtenBefore) hold.reached.resolve(); item.holds.push(hold); return hold.reached.promise; }; const enqueueRawChunk = ( chunk: AssistantStreamChunk, partIndex?: PartIndexReservation, ) => { // Part starts and message-level boundaries wait for earlier writes; later chunks wait for them. const waitForPartWrites = chunk.type === "part-start" || chunk.type === "step-finish" || chunk.type === "message-finish" || chunk.type === "error"; // Active child reads split raw batches to preserve microtask ordering. if (list.length > 0 || waitForPartWrites) { rawChunkBatch = undefined; } if (!rawChunkBatch) { const batch: RawChunkBatchItem[] = []; rawChunkBatch = batch; pendingRawBatches++; const previous = rawBarrier; const parts = waitForPartWrites ? list.filter((item) => item.written) : []; const flushed = previous || parts.length > 0 ? promiseWithResolvers<void>() : undefined; const reached = flushed ? parts.map((item) => holdChild(item, flushed.promise)) : []; if (flushed) rawBarrier = flushed.promise; // Match the readiness ordering of the one-chunk streams this replaces. void Promise.resolve() .then(async () => { if (previous) await previous; if (reached.length > 0) await Promise.all(reached); pendingRawBatches--; if (rawChunkBatch === batch) rawChunkBatch = undefined; if (cancelled || errored) return; for (const { chunk: rawChunk, partIndex } of batch) { if (rawChunk.type === "part-start" && rawChunk.path.length === 0) { const index = contentCounter.up(); if (partIndex) { pendingPartIndexes.delete(partIndex); partIndex.reference.value = index; partIndex.resolve(index); } } controller.enqueue(rawChunk); } if (sealed && list.length === 0 && pendingRawBatches === 0) { controller.close(); } currentPull?.resolve(); currentPull = undefined; }) .catch(handleError) .finally(() => { if (!flushed) return; flushed.resolve(); if (rawBarrier === flushed.promise) rawBarrier = undefined; }); } rawChunkBatch.push({ chunk, partIndex }); if (waitForPartWrites) rawChunkBatch = undefined; }; const addStream = ( stream: ReadableStream<AssistantStreamChunk>, pipeTask?: Promise<unknown>, options?: MergeStreamOptions, ) => { const handledPipeTask = pipeTask?.catch(() => undefined); if (cancelled || errored) { void stream.cancel().catch(() => undefined); return; } if (sealed) { void stream.cancel().catch(() => undefined); throw new Error("Cannot add streams after the run callback has settled."); } // A ready child must stay ahead of raw chunks enqueued after it. rawChunkBatch = undefined; const item: MergeStreamItem = { reader: stream.getReader(), pipeTask: handledPipeTask, orderedFinish: options?.orderedFinish, written: options?.written, delivered: 0, gate: rawBarrier, holds: [], pathMapper: options?.pathTransform === "merge" ? createPathMergeMapper(() => contentCounter.up()) : undefined, }; list.push(item); handlePull(item); }; return { readable, isSealed() { return sealed; }, isCancelled() { return cancelled; }, isErrored() { return errored; }, seal() { if (sealed || cancelled || errored) return; sealed = true; if (list.length === 0 && pendingRawBatches === 0) controller.close(); }, addStream, enqueuePartStart(chunk: AssistantStreamChunk) { if (cancelled || errored) return { value: -1, promise: Promise.resolve(-1) }; if (sealed) { throw new Error( "Cannot add streams after the run callback has settled.", ); } const deferred = promiseWithResolvers<number>(); const reference: PartIndexReference = { value: undefined as number | undefined, promise: deferred.promise, }; const reservation = { reference, resolve: deferred.resolve }; pendingPartIndexes.add(reservation); enqueueRawChunk(chunk, reservation); return reference; }, enqueue(chunk: AssistantStreamChunk) { if (cancelled || errored) return; if (sealed) { throw new Error( "Cannot add streams after the run callback has settled.", ); } enqueueRawChunk(chunk); }, }; }; // TODO // export class SpanContainerMerger { // public get isSealed() { // return this.mergeStream.isSealed(); // } // public get readable() { // return this.mergeStream.readable; // } // private subAllocator = new Counter(); // private mergeStream = createMergeStream(); // constructor() { // // id 0 is auto allocated // this.subAllocator.up(); // } // add(stream: ReadableStream<AssistantStreamChunk>) { // this.mergeStream.addStream( // stream.pipeThrough(new SpanParentEncoder(this.subAllocator)), // ); // } // enqueue(chunk: AssistantStreamChunk & { parentId: 0 }) { // this.mergeStream.addStream( // new ReadableStream({ // start(c) { // c.enqueue(chunk); // c.close(); // }, // }), // ); // } // seal() { // this.mergeStream.seal(); // } // } // export class SpanContainerSplitter { // public writable; // private isSealed = false; // private writers = new Map< // number, // WritableStreamDefaultWriter<AssistantStreamChunk> // >(); // private closeTasks: Promise<void>[] = []; // private allocator = new Counter(); // private subAllocator = new Counter(); // constructor() { // // id 0 is auto-allocated // this.allocator.up(); // this.writable = new WritableStream({ // write: (chunk) => { // const { type, parentId } = chunk; // const writer = this.writers.get(parentId); // if (writer === undefined) throw new Error("Parent id not found"); // writer.write(chunk); // if (type === "span") { // // allocate a new span id // this.writers.set(this.allocator.up(), writer); // } // if (type === "finish") { // this.writers.delete(parentId); // writer.close(); // if (this.writers.size === 0) { // const closeTask = this.writable.close(); // this.closeTasks.push(closeTask); // closeTask.then(() => { // this.closeTasks.splice(this.closeTasks.indexOf(closeTask), 1); // }); // } // } // }, // close: async () => { // if (this.writers.size > 0) throw new Error("Not all writers closed"); // // await and throw on any errors // await Promise.all(this.closeTasks); // }, // }); // } // add(stream: WritableStream<AssistantStreamChunk>) { // if (this.isSealed) throw new Error("Cannot add streams after sealing"); // const decoder = new SpanParentDecoder(this.subAllocator); // decoder.readable.pipeTo(stream); // this.writers.set(this.allocator.up(), decoder.writable.getWriter()); // } // seal() { // this.isSealed = true; // if (this.writers.size === 0) this.writable.close(); // } // }