UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

482 lines (481 loc) • 15.3 kB
import { generateId } from "../utils/generateId.js"; import { GorpStreamAccumulator } from "../gorp/GorpStreamAccumulator.js"; import { IncrementalJsonObjectParser } from "../../utils/json/incremental-json-object-parser.js"; import { TimingTracker } from "./TimingTracker.js"; // src/core/accumulators/assistant-message-accumulator.ts /** * Object spread materializes the deprecated `content` alias into a data * property, so it is redefined after `parts` to keep tracking the replacement. */ const withParts = (message, parts) => ({ ...message, parts, get content() { return this.parts; } }); const appendPart = (message, part) => withParts(message, [...message.parts, part]); const createInitialMessage = ({ unstable_state = null } = {}) => ({ role: "assistant", status: { type: "running" }, parts: [], get content() { return this.parts; }, metadata: { unstable_state, unstable_data: [], unstable_annotations: [], steps: [], custom: {} } }); const MAX_WARNED_KEYS = 16; const updatePartForPath = (message, chunk, warnOnce, updater) => { const part = chunk.path.length === 1 ? message.parts[chunk.path[0]] : void 0; if (part === void 0) { warnOnce(`no-part:${chunk.type}`, `Dropped ${chunk.type} chunk: no part at path [${chunk.path.join(", ")}]`); return message; } const partIndex = chunk.path[0]; const updatedPart = updater(part); if (updatedPart === part) return message; return withParts(message, [ ...message.parts.slice(0, partIndex), updatedPart, ...message.parts.slice(partIndex + 1) ]); }; const handlePartStart = (message, chunk, warnOnce) => { const partInit = chunk.part; if (partInit.type === "text" || partInit.type === "reasoning") { const newTextPart = { type: partInit.type, text: "", status: { type: "running" }, ...partInit.providerMetadata != null && { providerMetadata: partInit.providerMetadata }, ...partInit.type === "reasoning" && partInit.unstable_summary !== void 0 ? { unstable_summary: partInit.unstable_summary } : void 0, ...partInit.type === "reasoning" ? { timing: { startedAt: Date.now() } } : void 0, ...partInit.parentId && { parentId: partInit.parentId } }; return appendPart(message, newTextPart); } else if (partInit.type === "tool-call") { const newToolCallPart = { type: "tool-call", state: "partial-call", status: { type: "running", isArgsComplete: false }, toolCallId: partInit.toolCallId, toolName: partInit.toolName, argsText: "", args: {}, timing: { startedAt: Date.now() }, ...partInit.parentId && { parentId: partInit.parentId } }; return appendPart(message, newToolCallPart); } else if (partInit.type === "source") { const newSourcePart = partInit.sourceType === "document" ? { type: "source", sourceType: "document", id: partInit.id, title: partInit.title, mediaType: partInit.mediaType ?? "application/octet-stream", ...partInit.filename ? { filename: partInit.filename } : void 0, ...partInit.parentId && { parentId: partInit.parentId } } : { type: "source", sourceType: partInit.sourceType, id: partInit.id, url: partInit.url, ...partInit.title ? { title: partInit.title } : void 0, ...partInit.parentId && { parentId: partInit.parentId } }; return appendPart(message, newSourcePart); } else if (partInit.type === "file") { const newFilePart = { type: "file", mimeType: partInit.mimeType, data: partInit.data, ...partInit.parentId && { parentId: partInit.parentId } }; return appendPart(message, newFilePart); } else if (partInit.type === "data") { const newDataPart = { type: "data", name: partInit.name, data: partInit.data, ...partInit.parentId && { parentId: partInit.parentId } }; return appendPart(message, newDataPart); } else { const unsupportedType = String(partInit.type); warnOnce(`unsupported-part-start:${unsupportedType}`, `Unsupported part-start type ${unsupportedType}: inserting an empty reasoning part to preserve part indices`); return appendPart(message, { type: "reasoning", text: "", status: { type: "running" } }); } }; const handleToolCallArgsTextFinish = (message, chunk, warnOnce) => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type !== "tool-call") { warnOnce("wrong-part:tool-call-args-text-finish", "Dropped tool-call-args-text-finish chunk: part is not a tool-call"); return part; } if (part.state !== "partial-call") return part; return { ...part, state: "call", status: part.status.type === "running" ? { type: "running", isArgsComplete: true } : part.status }; }); }; const handleToolCallApproval = (message, chunk, warnOnce) => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type !== "tool-call") { warnOnce("wrong-part:tool-call-approval", "Dropped tool-call-approval chunk: part is not a tool-call"); return part; } return { ...part, approval: chunk.approval }; }); }; const handlePartFinish = (message, chunk, warnOnce) => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type === "tool-call" && part.isPreliminary) return part; const providerMetadata = (part.type === "text" || part.type === "reasoning") && chunk.providerMetadata != null ? { providerMetadata: chunk.providerMetadata } : {}; if (part.type === "reasoning" && part.timing !== void 0) return { ...part, ...providerMetadata, status: { type: "complete", reason: "unknown" }, timing: { ...part.timing, completedAt: part.timing.completedAt ?? Date.now() } }; return { ...part, ...providerMetadata, status: { type: "complete", reason: "unknown" } }; }); }; const handleTextDelta = (message, chunk, warnOnce, parserByPart) => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type === "text" || part.type === "reasoning") return { ...part, text: part.text + chunk.textDelta, ...chunk.providerMetadata != null && { providerMetadata: chunk.providerMetadata } }; else if (part.type === "tool-call") { const newArgsText = part.argsText + chunk.textDelta; const existingParser = parserByPart.get(part); const parser = existingParser ? existingParser.append(chunk.textDelta) : newArgsText.length === 0 ? IncrementalJsonObjectParser.from("") : IncrementalJsonObjectParser.from(newArgsText, part.args); const newArgs = parser.currentArgs; const updatedPart = { ...part, argsText: newArgsText, args: newArgs }; parserByPart.set(updatedPart, parser); return updatedPart; } else { warnOnce("wrong-part:text-delta", "Dropped text-delta chunk: part is neither text nor tool-call"); return part; } }); }; const handleResult = (message, chunk, warnOnce) => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type === "tool-call") { const isPreliminary = chunk.isPreliminary === true; if (isPreliminary && part.state === "result") return part; const { isPreliminary: _isPreliminary, modelContent: _modelContent, artifact: _artifact, messages: _messages, ...basePart } = part; const runningStatus = part.status.type === "running" ? part.status : { type: "running", isArgsComplete: part.state !== "partial-call" }; if (isPreliminary) return { ...basePart, state: part.state === "partial-call" ? "partial-call" : "call", ...chunk.artifact !== void 0 ? { artifact: chunk.artifact } : {}, result: chunk.result, isError: chunk.isError ?? false, isPreliminary: true, ...chunk.modelContent !== void 0 ? { modelContent: chunk.modelContent } : {}, ...chunk.messages !== void 0 ? { messages: chunk.messages } : {}, status: runningStatus }; return { ...basePart, state: "result", ...part.timing !== void 0 ? { timing: { ...part.timing, completedAt: part.timing.completedAt ?? Date.now() } } : {}, ...chunk.artifact !== void 0 ? { artifact: chunk.artifact } : {}, result: chunk.result, isError: chunk.isError ?? false, ...chunk.modelContent !== void 0 ? { modelContent: chunk.modelContent } : {}, ...chunk.messages !== void 0 ? { messages: chunk.messages } : {}, status: { type: "complete", reason: "stop" } }; } else { warnOnce("wrong-part:result", "Dropped result chunk: part is not a tool-call"); return part; } }); }; const handleMessageFinish = (message, chunk) => { if (message.status?.type === "incomplete" && message.status?.reason === "error") return message; const newStatus = getStatus(chunk); return { ...message, status: newStatus }; }; const getStatus = (chunk) => { if (chunk.finishReason === "tool-calls") return { type: "requires-action", reason: "tool-calls" }; else if (chunk.finishReason === "stop" || chunk.finishReason === "unknown") return { type: "complete", reason: chunk.finishReason }; else return { type: "incomplete", reason: chunk.finishReason }; }; const handleAnnotations = (message, chunk) => { return { ...message, metadata: { ...message.metadata, unstable_annotations: [...message.metadata.unstable_annotations, ...chunk.annotations] } }; }; const handleData = (message, chunk) => { return { ...message, metadata: { ...message.metadata, unstable_data: [...message.metadata.unstable_data, ...chunk.data] } }; }; const handleStepStart = (message, chunk) => { return { ...message, metadata: { ...message.metadata, steps: [...message.metadata.steps, { state: "started", messageId: chunk.messageId }] } }; }; const handleStepFinish = (message, chunk) => { const steps = message.metadata.steps.slice(); const lastIndex = steps.length - 1; if (steps.length > 0 && steps[lastIndex]?.state === "started") steps[lastIndex] = { ...steps[lastIndex], state: "finished", finishReason: chunk.finishReason, usage: chunk.usage, isContinued: chunk.isContinued }; else steps.push({ state: "finished", messageId: generateId(), finishReason: chunk.finishReason, usage: chunk.usage, isContinued: chunk.isContinued }); return { ...message, metadata: { ...message.metadata, steps } }; }; const handleErrorChunk = (message, chunk) => { const severity = chunk.severity === "critical" || chunk.severity === "warning" || chunk.severity === "info" ? chunk.severity : void 0; return { ...message, status: { type: "incomplete", reason: "error", error: { code: chunk.code ?? "unknown", message: chunk.error ?? "unknown error", ...severity !== void 0 && { severity } } } }; }; const handleUpdateState = (message, chunk, acc) => { acc.append(chunk.operations); return { ...message, metadata: { ...message.metadata, unstable_state: acc.state } }; }; const sumFinishedStepOutputTokens = (message) => { let outputTokens = 0; for (const step of message.metadata.steps) if (step.state === "finished" && step.usage) outputTokens += step.usage.outputTokens; return outputTokens; }; const computeTiming = (tracker, message, finalOutputTokens = 0) => { const outputTokens = finalOutputTokens > 0 ? finalOutputTokens : sumFinishedStepOutputTokens(message); if (outputTokens > 0) return tracker.getTiming(outputTokens); let totalText = ""; for (const part of message.parts) if (part.type === "text" || part.type === "reasoning") totalText += part.text; return tracker.getTiming(void 0, totalText || void 0); }; const throttleCallback = (callback) => { let hasScheduled = false; return () => { if (hasScheduled) return; hasScheduled = true; queueMicrotask(() => { hasScheduled = false; callback(); }); }; }; var AssistantMessageAccumulator = class extends TransformStream { constructor({ initialMessage, throttle, onError, strict = true } = {}) { let message = initialMessage ?? createInitialMessage(); let stateAccumulator; let finalOutputTokens; const tracker = new TimingTracker(); const parserByPart = /* @__PURE__ */ new WeakMap(); const warnedKeys = /* @__PURE__ */ new Set(); const warnOnce = (key, warning) => { if (warnedKeys.has(key) || warnedKeys.size >= MAX_WARNED_KEYS) return; warnedKeys.add(key); console.warn(warning); }; let controller; const emitChunk = throttle ? throttleCallback(() => { controller?.enqueue(message); }) : () => { controller?.enqueue(message); }; super({ start(c) { controller = c; }, transform(chunk) { tracker.recordChunk(); const type = chunk.type; switch (type) { case "part-start": message = handlePartStart(message, chunk, warnOnce); if (chunk.part.type === "tool-call") tracker.recordToolCallStart(chunk.part.toolCallId); break; case "tool-call-args-text-finish": message = handleToolCallArgsTextFinish(message, chunk, warnOnce); break; case "tool-call-approval": message = handleToolCallApproval(message, chunk, warnOnce); break; case "part-finish": message = handlePartFinish(message, chunk, warnOnce); break; case "text-delta": { const next = handleTextDelta(message, chunk, warnOnce, parserByPart); if (next !== message) tracker.recordFirstToken(); message = next; break; } case "result": message = handleResult(message, chunk, warnOnce); break; case "message-finish": finalOutputTokens = chunk.usage?.outputTokens; message = handleMessageFinish(message, chunk); break; case "annotations": message = handleAnnotations(message, chunk); break; case "data": message = handleData(message, chunk); break; case "step-start": message = handleStepStart(message, chunk); break; case "step-finish": message = handleStepFinish(message, chunk); break; case "error": message = handleErrorChunk(message, chunk); onError?.(chunk.error); break; case "update-state": stateAccumulator ??= new GorpStreamAccumulator(message.metadata.unstable_state, { strict }); message = handleUpdateState(message, chunk, stateAccumulator); break; default: throw new Error(`Unsupported chunk type: ${type}`); } if (message.status.type !== "running") message = { ...message, metadata: { ...message.metadata, timing: computeTiming(tracker, message, finalOutputTokens) } }; emitChunk(); }, flush(controller) { if (message.status?.type === "running") { const requiresAction = message.parts?.some((part) => part.type === "tool-call" && (part.state === "call" || part.state === "partial-call") && (part.result === void 0 || part.isPreliminary)) ?? false; message = handleMessageFinish(message, { type: "message-finish", path: [], finishReason: requiresAction ? "tool-calls" : "unknown", usage: { inputTokens: 0, outputTokens: 0 } }); message = { ...message, metadata: { ...message.metadata, timing: computeTiming(tracker, message, finalOutputTokens) } }; controller.enqueue(message); } } }); } }; export { AssistantMessageAccumulator, createInitialMessage }; //# sourceMappingURL=assistant-message-accumulator.js.map