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