UNPKG

agents

Version:

A home for your AI agents

386 lines (385 loc) 14.4 kB
//#region src/chat/message-builder.ts /** Whether a value is a plain (non-array, non-null) object. */ function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } /** * Coerce a tool part's `input` into a provider-acceptable object. * * The Anthropic Messages API requires `tool_use.input` to be a JSON **object** — * `null`, `undefined`, `""`, a raw string, **or an array** are all rejected with * `tool_use.input: Input should be an object` (verified empirically against the * live API: `{}` → 200, but `""`, `[]`, and `[{...}]` all → 400). A streamed * tool call that finishes with no `input_json_delta` events (the model called * the tool with no args), or whose input surfaces as a stringified JSON blob, * can persist one of these shapes — and because it lives in durable storage, the * session is then wedged across reconnects, redeploys, and DO evictions. * Enforcing the invariant at the write boundary (and as a read-side repair * backstop) keeps the transcript valid. * * - A plain (non-array) object is returned untouched (`changed: false`). * - A string that parses to a plain object is parsed. * - Everything else (`null`, `undefined`, `""`, arrays, primitives, non-object * or unparseable JSON) collapses to `{}`. */ function normalizeToolInput(raw) { if (isPlainObject(raw)) return { input: raw, changed: false }; if (typeof raw === "string" && raw.trim().startsWith("{")) try { const parsed = JSON.parse(raw); if (isPlainObject(parsed)) return { input: parsed, changed: true }; } catch {} return { input: {}, changed: true }; } /** * Applies a stream chunk to a mutable parts array, building up the message * incrementally. Returns true if the chunk was handled, false if it was * an unrecognized type (caller may handle it with additional logic). * * Handles all common chunk types that both server and client need: * - text-start / text-delta / text-end * - reasoning-start / reasoning-delta / reasoning-end * - file * - source-url / source-document * - tool-input-start / tool-input-delta / tool-input-available / tool-input-error * - tool-output-available / tool-output-error * - step-start (aliased from start-step) * - data-* (developer-defined typed JSON blobs) * * @param parts - The mutable parts array to update * @param chunk - The parsed stream chunk data * @returns true if handled, false if the chunk type is not recognized */ function applyChunkToParts(parts, chunk) { switch (chunk.type) { case "text-start": parts.push({ type: "text", text: "", state: "streaming" }); return true; case "text-delta": { const lastTextPart = findLastPartByType(parts, "text"); if (lastTextPart && lastTextPart.type === "text") lastTextPart.text += chunk.delta ?? ""; else parts.push({ type: "text", text: chunk.delta ?? "", state: "streaming" }); return true; } case "text-end": { const lastTextPart = findLastPartByType(parts, "text"); if (lastTextPart && "state" in lastTextPart) lastTextPart.state = "done"; return true; } case "reasoning-start": parts.push({ type: "reasoning", text: "", state: "streaming" }); return true; case "reasoning-delta": { const lastReasoningPart = findLastPartByType(parts, "reasoning"); if (lastReasoningPart && lastReasoningPart.type === "reasoning") { lastReasoningPart.text += chunk.delta ?? ""; mergeProviderMetadata(lastReasoningPart, chunk.providerMetadata); } else parts.push({ type: "reasoning", text: chunk.delta ?? "", state: "streaming", ...chunk.providerMetadata != null ? { providerMetadata: chunk.providerMetadata } : {} }); return true; } case "reasoning-end": { const lastReasoningPart = findLastPartByType(parts, "reasoning"); if (lastReasoningPart && "state" in lastReasoningPart) { lastReasoningPart.state = "done"; mergeProviderMetadata(lastReasoningPart, chunk.providerMetadata); } return true; } case "file": parts.push({ type: "file", mediaType: chunk.mediaType, url: chunk.url }); return true; case "source-url": parts.push({ type: "source-url", sourceId: chunk.sourceId, url: chunk.url, title: chunk.title, providerMetadata: chunk.providerMetadata }); return true; case "source-document": parts.push({ type: "source-document", sourceId: chunk.sourceId, mediaType: chunk.mediaType, title: chunk.title, filename: chunk.filename, providerMetadata: chunk.providerMetadata }); return true; case "tool-input-start": if (findToolPartByCallId(parts, chunk.toolCallId)) return true; parts.push({ type: `tool-${chunk.toolName}`, toolCallId: chunk.toolCallId, toolName: chunk.toolName, state: "input-streaming", input: void 0, ...chunk.providerExecuted != null ? { providerExecuted: chunk.providerExecuted } : {}, ...chunk.providerMetadata != null ? { callProviderMetadata: chunk.providerMetadata } : {}, ...chunk.title != null ? { title: chunk.title } : {} }); return true; case "tool-input-delta": { const toolPart = findToolPartByCallId(parts, chunk.toolCallId); if (toolPart && toolPart.state === "input-streaming") toolPart.input = chunk.input; return true; } case "tool-input-available": { const existing = findToolPartByCallId(parts, chunk.toolCallId); if (existing) { const p = existing; if (p.state === "input-streaming") { p.state = "input-available"; p.input = normalizeToolInput(chunk.input).input; if (chunk.providerExecuted != null) p.providerExecuted = chunk.providerExecuted; if (chunk.providerMetadata != null) p.callProviderMetadata = chunk.providerMetadata; if (chunk.title != null) p.title = chunk.title; } return true; } parts.push({ type: `tool-${chunk.toolName}`, toolCallId: chunk.toolCallId, toolName: chunk.toolName, state: "input-available", input: normalizeToolInput(chunk.input).input, ...chunk.providerExecuted != null ? { providerExecuted: chunk.providerExecuted } : {}, ...chunk.providerMetadata != null ? { callProviderMetadata: chunk.providerMetadata } : {}, ...chunk.title != null ? { title: chunk.title } : {} }); return true; } case "tool-input-error": { const existing = findToolPartByCallId(parts, chunk.toolCallId); if (existing) { const p = existing; if (p.state === "output-available" || p.state === "output-error" || p.state === "output-denied") return true; p.state = "output-error"; p.errorText = chunk.errorText; p.input = normalizeToolInput(chunk.input).input; if (chunk.providerExecuted != null) p.providerExecuted = chunk.providerExecuted; if (chunk.providerMetadata != null) p.callProviderMetadata = chunk.providerMetadata; } else parts.push({ type: `tool-${chunk.toolName}`, toolCallId: chunk.toolCallId, toolName: chunk.toolName, state: "output-error", input: normalizeToolInput(chunk.input).input, errorText: chunk.errorText, ...chunk.providerExecuted != null ? { providerExecuted: chunk.providerExecuted } : {}, ...chunk.providerMetadata != null ? { callProviderMetadata: chunk.providerMetadata } : {} }); return true; } case "tool-approval-request": { const toolPart = findToolPartByCallId(parts, chunk.toolCallId); if (toolPart) { const p = toolPart; if (p.state === "approval-responded" || p.state === "output-available" || p.state === "output-error" || p.state === "output-denied") return true; p.state = "approval-requested"; p.approval = { id: chunk.approvalId, ...chunk.approvalDescriptor !== void 0 && { descriptor: chunk.approvalDescriptor } }; } return true; } case "tool-output-denied": { const toolPart = findToolPartByCallId(parts, chunk.toolCallId); if (toolPart) { const p = toolPart; if (p.state === "output-available" || p.state === "output-error" || p.state === "output-denied" || p.state === "approval-responded") return true; p.state = "output-denied"; } return true; } case "tool-output-available": { const toolPart = findToolPartByCallId(parts, chunk.toolCallId); if (toolPart) { const p = toolPart; p.state = "output-available"; p.output = chunk.output; if (chunk.preliminary !== void 0) p.preliminary = chunk.preliminary; } return true; } case "tool-output-error": { const toolPart = findToolPartByCallId(parts, chunk.toolCallId); if (toolPart) { const p = toolPart; p.state = "output-error"; p.errorText = chunk.errorText; } return true; } case "step-start": case "start-step": parts.push({ type: "step-start" }); return true; default: if (chunk.type.startsWith("data-")) { if (chunk.transient) return true; if (chunk.id != null) { const existing = findDataPartByTypeAndId(parts, chunk.type, chunk.id); if (existing) { existing.data = chunk.data; return true; } } parts.push({ type: chunk.type, ...chunk.id != null && { id: chunk.id }, data: chunk.data }); return true; } return false; } } /** * Returns true if `chunk` would be a no-op replay against the already-known * `parts` — i.e. some upstream is re-emitting events for a tool call that * the message has already advanced past. * * Used by stream broadcasters to suppress re-broadcasting these chunks to * connected clients. AI SDK v6's `updateToolPart` mutates an existing tool * part in place when a chunk arrives with a matching `toolCallId`, so a * replayed `tool-input-start` would clobber an `output-available` part back * to `input-streaming` on the client (issue #1404). * * Only returns true when re-broadcasting would *visibly regress* state on * a v6 client. Safe-by-construction chunk types (e.g. `tool-output-available` * carrying the same output the part already has) return false. * * Conditions: * - `tool-input-start` for a `toolCallId` that already exists in `parts`. * - `tool-input-delta` for a `toolCallId` whose existing part is no longer * `input-streaming`. * - `tool-input-available` for a `toolCallId` whose existing part is no * longer `input-streaming` (i.e. has already advanced to `input-available` * or any terminal state). * - `tool-output-denied` for a `toolCallId` whose existing part is already * settled (`output-available` / `output-error` / `output-denied`) or * user-approved (`approval-responded`). A continuation that re-validates * the transcript can re-emit a denial for an approval the SDK now deems * unneeded; `applyChunkToParts` already drops it server-side, and this stops * it reaching the client (where the in-place `updateToolPart` would flip the * part to `output-denied`) and the replay buffer. Mirrors the * first-write-wins guard in `applyChunkToParts`. * - `tool-approval-request` for a `toolCallId` whose existing part is already * `approval-responded` or settled. A continuation replaying a prior tool * round-trip can re-emit the approval request; left unfiltered it would * revert an already-approved tool back to `approval-requested` on the client * (re-showing Approve/Reject) and replay that regression on reconnect. Same * pattern and rationale as `tool-output-denied`. */ function isReplayChunk(parts, chunk) { if (chunk.type === "tool-output-denied" || chunk.type === "tool-approval-request") { if (!chunk.toolCallId) return false; const existing = findToolPartByCallId(parts, chunk.toolCallId); if (!existing) return false; const state = existing.state; return state === "output-available" || state === "output-error" || state === "output-denied" || state === "approval-responded"; } if (chunk.type !== "tool-input-start" && chunk.type !== "tool-input-delta" && chunk.type !== "tool-input-available") return false; if (!chunk.toolCallId) return false; const existing = findToolPartByCallId(parts, chunk.toolCallId); if (!existing) return false; if (chunk.type === "tool-input-start") return true; return existing.state !== "input-streaming"; } /** * Finds the last part in the array matching the given type. * Searches from the end for efficiency (the part we want is usually recent). */ function findLastPartByType(parts, type) { for (let i = parts.length - 1; i >= 0; i--) if (parts[i].type === type) return parts[i]; } /** * Finds a tool part by its toolCallId. * Searches from the end since the tool part is usually recent. */ function findToolPartByCallId(parts, toolCallId) { if (!toolCallId) return void 0; for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; if ("toolCallId" in p && p.toolCallId === toolCallId) return p; } } /** * Shallow-merges providerMetadata from a chunk onto an existing part. * Preserves any metadata already on the part (e.g. from earlier deltas) * while adding new keys from the chunk. This is critical for providers * like Anthropic that emit the thinking block signature on reasoning-end. */ function mergeProviderMetadata(part, metadata) { if (metadata == null) return; const p = part; p.providerMetadata = { ...p.providerMetadata, ...metadata }; } /** * Finds a data part by its type and id for reconciliation. * Data parts use type+id as a composite key so when the same combination * is seen again, the existing part's data is updated in-place. */ function findDataPartByTypeAndId(parts, type, id) { for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; if (p.type === type && "id" in p && p.id === id) return p; } } /** * Rebuild the partial text + parts of an interrupted assistant turn from its * stored resumable-stream chunks. Replays each chunk body through * {@link applyChunkToParts}; malformed bodies are skipped. Shared by * `AIChatAgent` and `Think`, which read the chunks from their * `ResumableStream` (`getStreamChunks`) and pass them in. * * `@internal` */ function getPartialStreamText(chunks) { const parts = []; for (const chunk of chunks) try { applyChunkToParts(parts, JSON.parse(chunk.body)); } catch {} return { text: parts.filter((p) => p.type === "text" && "text" in p).map((p) => p.text).join(""), parts }; } //#endregion export { normalizeToolInput as i, getPartialStreamText as n, isReplayChunk as r, applyChunkToParts as t }; //# sourceMappingURL=message-builder-BymO4N_D.js.map