assistant-stream
Version:
Streaming utilities for AI assistants
176 lines (157 loc) • 5.66 kB
text/typescript
import type { AssistantStream } from "../AssistantStream";
import type { AssistantStreamChunk } from "../AssistantStreamChunk";
import { NO_RESULT, type ToolResponseLike } from "../tool/ToolResponse";
import type { ReadonlyJSONValue } from "../../utils/json/json-value";
import type { ToolCallApproval } from "../utils/types";
import type { UnderlyingReadable } from "../utils/stream/UnderlyingReadable";
import { TextStreamControllerImpl, type TextStreamController } from "./text";
import { closeIfOpen, enqueueIfOpen } from "../utils/stream/controller-guards";
import {
createControllerStream,
createControllerStreamPair,
} from "../utils/stream/createControllerStream";
export type ToolCallStreamController = {
argsText: TextStreamController;
/**
* Sets a tool response. Preliminary responses keep the part open; a final
* response closes it automatically and subsequent calls are ignored.
*/
setResponse(response: ToolResponseLike<ReadonlyJSONValue>): void;
/** Sets the call's approval gate, replacing any earlier one. Ignored once the part is closed. */
setApproval(approval: ToolCallApproval): void;
close(): void;
};
type ToolCallStreamOptions = {
strict?: boolean | undefined;
onClose?: (() => void) | undefined;
};
export class ToolCallStreamControllerImpl implements ToolCallStreamController {
private _isClosed = false;
private _hasArgsText = false;
private _argsTextState: "open" | "finishing" | "finished" = "open";
private _controller: ReadableStreamDefaultController<AssistantStreamChunk>;
private _argsTextController: TextStreamControllerImpl;
private _options: ToolCallStreamOptions;
constructor(
_controller: ReadableStreamDefaultController<AssistantStreamChunk>,
options: ToolCallStreamOptions = {},
) {
this._controller = _controller;
this._options = options;
this._argsTextController = new TextStreamControllerImpl(
{
enqueue: (chunk) => {
if (this._argsTextState !== "open") {
// enqueueIfOpen reads any TypeError as a closed stream, so lenient
// mode drops this delta with its warning instead of throwing.
throw new TypeError("Cannot append to finished tool-call args");
}
if (chunk.type === "text-delta") {
this._hasArgsText = true;
this._controller.enqueue(chunk);
return;
}
// The args finish waits for the end of the tick so a result set in
// the same tick goes out first; ToolExecutionStream only treats a
// backend result as authoritative when it precedes the args finish.
this._argsTextState = "finishing";
queueMicrotask(() => this._finishArgsText());
},
close: () => {},
},
{ strict: this._options.strict },
);
}
private _finishArgsText() {
if (this._argsTextState !== "finishing") return;
this._argsTextState = "finished";
if (!this._hasArgsText) {
// if no argsText was provided, assume empty object
enqueueIfOpen(this._controller, {
type: "text-delta",
textDelta: "{}",
path: [],
});
}
enqueueIfOpen(this._controller, {
type: "tool-call-args-text-finish",
path: [],
});
}
get argsText() {
return this._argsTextController;
}
async setResponse(response: ToolResponseLike<ReadonlyJSONValue>) {
if (this._isClosed) return;
// Wire decoders hand this a raw payload, so an omitted result is
// materialized here rather than reaching the message part as a settled call
// indistinguishable from one that never finished.
const result = response.result;
enqueueIfOpen(this._controller, {
type: "result",
path: [],
...(response.artifact !== undefined
? { artifact: response.artifact }
: {}),
result: result === undefined ? NO_RESULT : result,
isError: response.isError ?? false,
...(response.isPreliminary ? { isPreliminary: true } : {}),
...(response.modelContent !== undefined
? { modelContent: response.modelContent }
: {}),
...(response.messages !== undefined
? { messages: response.messages }
: {}),
});
if (response.isPreliminary) {
this._argsTextController.close();
this._finishArgsText();
} else {
await this.close();
}
}
setApproval(approval: ToolCallApproval) {
enqueueIfOpen(this._controller, {
type: "tool-call-approval",
path: [],
approval,
});
}
async close() {
if (this._isClosed) return;
this._isClosed = true;
this._options.onClose?.();
this._argsTextController.close();
this._finishArgsText();
enqueueIfOpen(this._controller, {
type: "part-finish",
path: [],
});
closeIfOpen(this._controller);
}
__internal_truncate() {
if (this._isClosed) return;
this._isClosed = true;
this._finishArgsText();
this._argsTextController.__internal_truncate();
this._argsTextState = "finished";
closeIfOpen(this._controller);
}
}
export const createToolCallStream = (
readable: UnderlyingReadable<ToolCallStreamController>,
options: ToolCallStreamOptions = {},
): AssistantStream => {
return createControllerStream(
readable,
(controller) => new ToolCallStreamControllerImpl(controller, options),
);
};
export const createToolCallStreamController = (
options: ToolCallStreamOptions = {},
) => {
return createControllerStreamPair<
AssistantStreamChunk,
ToolCallStreamControllerImpl
>((controller) => new ToolCallStreamControllerImpl(controller, options));
};