assistant-stream
Version:
Streaming utilities for AI assistants
463 lines (408 loc) • 13.8 kB
text/typescript
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();
// }
// }