/** * Translation helpers between the AgentEngine normalized types and * @anthropic-ai/sdk's wire types. * * AnthropicEngine does very little translation because the framework's * EngineMessage / EngineTool shapes were modeled on Anthropic's types. * The main differences are: camelCase vs snake_case, and that * Anthropic uses `input_schema` while we use `inputSchema`. * * Builder's Gemini-backed gateway requires `tool_name` and `tool_input` on * every `tool_result` block. Use `engineMessagesToBuilderGatewayAnthropic` for * that path. The native Anthropic API keeps the strict `tool_result` shape * (`engineMessagesToAnthropic`). */ import type Anthropic from "@anthropic-ai/sdk"; import type { EngineTool, EngineMessage, EngineContentPart, EngineEvent, } from "./types.js"; // --------------------------------------------------------------------------- // EngineTool → Anthropic.Tool // --------------------------------------------------------------------------- const ANTHROPIC_UNSUPPORTED_TOP_LEVEL_SCHEMA_KEYS = [ "oneOf", "anyOf", "allOf", ] as const; type JsonSchemaRecord = Record; function isRecord(value: unknown): value is JsonSchemaRecord { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function normalizeDbExecAnthropicInputSchema( schema: EngineTool["inputSchema"], ): Anthropic.Tool["input_schema"] { const sourceProperties = isRecord(schema.properties) ? schema.properties : {}; const statements = isRecord(sourceProperties.statements) ? { ...sourceProperties.statements } : { type: "string", description: "JSON array of write statements to execute.", }; const description = typeof statements.description === "string" && statements.description.trim().length > 0 ? `${statements.description} For a single write, pass a one-item JSON array.` : "JSON array of write statements to execute. For a single write, pass a one-item JSON array."; statements.description = description; const properties: JsonSchemaRecord = { statements }; if (isRecord(sourceProperties.format)) { properties.format = sourceProperties.format; } return { type: "object", properties, required: ["statements"], additionalProperties: false, }; } function normalizeAnthropicInputSchema( toolName: string, schema: EngineTool["inputSchema"], ): Anthropic.Tool["input_schema"] { if (toolName === "db-exec") { return normalizeDbExecAnthropicInputSchema(schema); } if ( !ANTHROPIC_UNSUPPORTED_TOP_LEVEL_SCHEMA_KEYS.some((key) => key in schema) ) { return schema as Anthropic.Tool["input_schema"]; } const normalized: Record = { ...schema }; for (const key of ANTHROPIC_UNSUPPORTED_TOP_LEVEL_SCHEMA_KEYS) { delete normalized[key]; } normalized.type = "object"; return normalized as Anthropic.Tool["input_schema"]; } export function engineToolToAnthropic(tool: EngineTool): Anthropic.Tool { return { name: tool.name, description: tool.description, input_schema: normalizeAnthropicInputSchema(tool.name, tool.inputSchema), }; } export function engineToolsToAnthropic(tools: EngineTool[]): Anthropic.Tool[] { return tools.map(engineToolToAnthropic); } // --------------------------------------------------------------------------- // Tool result backfill (Gemini / Builder gateway) // --------------------------------------------------------------------------- /** JSON.stringify for tool_use inputs; never throws. */ export function stringifyToolUseInputForGateway(input: unknown): string { try { if (input === undefined || input === null) return "{}"; return JSON.stringify(input); } catch { return "{}"; } } /** Same lead-in as structured-history replay when a tool_result cannot be paired. */ export const UNMATCHED_TOOL_RESULT_REPLAY_PREFIX = "(Omitted unmatched tool results from replayed history.)"; /** * Human/LLM-visible note when a tool_result cannot be matched to a tool_use * (replay from DB, or malformed engine history). Preserves tool_use_id and * a truncated payload instead of silently dropping the turn. */ export function unmatchedToolResultReplayText(part: { toolCallId: string; content: unknown; isError?: boolean; }): string { const max = 2000; let body = typeof part.content === "string" ? part.content : part.content === undefined || part.content === null ? "" : (() => { try { return JSON.stringify(part.content); } catch { return String(part.content); } })(); if (body.length > max) body = `${body.slice(0, max)}…`; const err = part.isError ? " isError=true" : ""; return `${UNMATCHED_TOOL_RESULT_REPLAY_PREFIX} [tool_use_id=${part.toolCallId}${err}] ${body}`; } function interruptedToolResultPart(part: { id: string; name: string; input: unknown; }): EngineContentPart { return { type: "tool-result", toolCallId: part.id, toolName: part.name, toolInput: stringifyToolUseInputForGateway(part.input), content: "Interrupted before this tool returned a result.", }; } /** * Ensure every `tool-result` has a non-empty `toolName` and `toolInput` string, * using the matching assistant `tool-call` in the same conversation. * Assistant `tool-call` blocks without an immediately following result get a * synthetic interrupted result so replayed history stays provider-protocol safe. * Orphan tool-results (no resolvable tool name) become `text` notes so nothing * is silently dropped from replayed history. */ export function backfillEngineMessagesToolResults( messages: EngineMessage[], ): EngineMessage[] { // Walk messages in order. User tool-result blocks are valid only when they // answer the immediately preceding assistant tool-call turn. This prevents // older tool-results from being backfilled with later, unrelated tool-calls // when ids are reused (e.g. `continuation_tc_1` reset across adapter // recreations). const toolUseById = new Map(); const out: EngineMessage[] = []; let pendingToolUses: Array<{ id: string; name: string; input: unknown }> = []; const flushInterruptedToolResults = () => { if (pendingToolUses.length === 0) return; out.push({ role: "user", content: pendingToolUses.map(interruptedToolResultPart), }); pendingToolUses = []; }; for (const msg of messages) { if (msg.role === "assistant") { flushInterruptedToolResults(); for (const part of msg.content) { if (part.type === "tool-call") { toolUseById.set(part.id, { name: part.name, input: part.input }); } } out.push(msg); pendingToolUses = msg.content .filter( (part): part is Extract => part.type === "tool-call", ) .map((part) => ({ id: part.id, name: part.name, input: part.input, })); continue; } if (msg.role !== "user") { flushInterruptedToolResults(); out.push(msg); continue; } const newContent: EngineContentPart[] = []; const pendingById = new Map( pendingToolUses.map((part) => [part.id, part] as const), ); const matchedPendingToolResults = new Map(); for (const part of msg.content) { if (part.type !== "tool-result") { newContent.push(part); continue; } const lookup = toolUseById.get(part.toolCallId); const pendingLookup = pendingById.get(part.toolCallId); const toolName = typeof part.toolName === "string" && part.toolName.trim().length > 0 ? part.toolName : pendingLookup?.name; if (!toolName?.trim()) { const id = typeof part.toolCallId === "string" ? part.toolCallId.trim() : part.toolCallId != null ? String(part.toolCallId).trim() : ""; newContent.push({ type: "text", text: unmatchedToolResultReplayText({ toolCallId: id.length > 0 ? id : "(missing)", content: part.content, isError: part.isError, }), }); continue; } if (pendingToolUses.length > 0 && !pendingLookup) { const id = typeof part.toolCallId === "string" ? part.toolCallId.trim() : part.toolCallId != null ? String(part.toolCallId).trim() : ""; newContent.push({ type: "text", text: unmatchedToolResultReplayText({ toolCallId: id.length > 0 ? id : "(missing)", content: part.content, isError: part.isError, }), }); continue; } const toolInput = typeof part.toolInput === "string" && part.toolInput.length > 0 ? part.toolInput : stringifyToolUseInputForGateway( pendingLookup?.input ?? lookup?.input, ); const filled: EngineContentPart = { type: "tool-result", toolCallId: part.toolCallId, toolName, toolInput, content: part.content, ...(part.isError ? { isError: true } : {}), ...(part.images && part.images.length > 0 ? { images: part.images } : {}), }; if (pendingLookup) { matchedPendingToolResults.set(part.toolCallId, filled); } else { newContent.push(filled); } } if (pendingToolUses.length > 0) { const pairedResults = pendingToolUses.map( (part) => matchedPendingToolResults.get(part.id) ?? interruptedToolResultPart(part), ); newContent.unshift(...pairedResults); pendingToolUses = []; } if (newContent.length === 0) { out.push({ role: "user", content: [ { type: "text", text: UNMATCHED_TOOL_RESULT_REPLAY_PREFIX, }, ], }); continue; } out.push({ role: "user", content: newContent }); } flushInterruptedToolResults(); return out; } // --------------------------------------------------------------------------- // EngineMessage → Anthropic.MessageParam // --------------------------------------------------------------------------- export function engineMessageToAnthropic( msg: EngineMessage, opts?: { builderGateway?: boolean }, ): Anthropic.MessageParam { const builderGateway = opts?.builderGateway === true; return { role: msg.role, content: msg.content.map((p) => enginePartToAnthropic(p, builderGateway)), }; } /** Messages for the Anthropic HTTP API (strict schema — no extra tool_result fields). */ export function engineMessagesToAnthropic( messages: EngineMessage[], ): Anthropic.MessageParam[] { const normalized = backfillEngineMessagesToolResults(messages); return normalized.map((m) => engineMessageToAnthropic(m)); } /** * Messages for the Builder LLM gateway (Gemini-backed). Same Anthropic-shaped * envelope, but every `tool_result` includes `tool_name` and `tool_input`. */ export function engineMessagesToBuilderGatewayAnthropic( messages: EngineMessage[], ): Anthropic.MessageParam[] { const normalized = backfillEngineMessagesToolResults(messages); return normalized.map((m) => engineMessageToAnthropic(m, { builderGateway: true }), ); } function enginePartToAnthropic( part: EngineContentPart, builderGateway: boolean, ): Anthropic.ContentBlockParam { switch (part.type) { case "text": return { type: "text", text: part.text }; case "image": return { type: "image", source: { type: "base64", media_type: part.mediaType, data: part.data, }, }; case "file": if (part.mediaType === "application/pdf") { return { type: "document", source: { type: "base64", media_type: "application/pdf", data: part.data, }, ...(part.filename ? { title: part.filename } : {}), } as any; } return { type: "text", text: `[Attached file: ${part.filename ?? "attachment"} (${part.mediaType})]`, }; case "tool-call": return { type: "tool_use", id: part.id, name: part.name, input: part.input as Record, } as any; // tool_use is a ContentBlockParam in Anthropic SDK case "tool-result": { if (builderGateway) { const tool_name = part.toolName.trim(); const tool_input = part.toolInput; // Gateway degrade: the Builder gateway multiplexes to non-Anthropic // models whose tool_result handling is string-only, so images are // dropped here. The content string already carries a `[image: …]` // note per image (appended by runToolCall), so the model still knows // an image existed and any https URL survives. return { type: "tool_result", tool_use_id: part.toolCallId, tool_name, tool_input, content: part.content, ...(part.isError ? { is_error: true } : {}), } as any; } return { type: "tool_result", tool_use_id: part.toolCallId, content: toolResultContentToAnthropic(part), ...(part.isError ? { is_error: true } : {}), } as any; } case "thinking": // Anthropic thinking blocks — pass through with signature for context window continuity return { type: "thinking", thinking: part.text, signature: part.signature ?? "", } as any; } } /** * tool_result `content` for the native Anthropic API: a plain string normally, * or a text + image block array when the result carries vision images * (https://platform.claude.com/docs — "Example of tool result with images"). * Error results stay string-only; malformed image entries are skipped. */ function toolResultContentToAnthropic( part: Extract, ): string | Anthropic.ContentBlockParam[] { if (part.isError || !part.images || part.images.length === 0) { return part.content; } const imageBlocks: Anthropic.ImageBlockParam[] = []; for (const image of part.images) { if (image.url) { imageBlocks.push({ type: "image", source: { type: "url", url: image.url }, }); } else if (image.data && image.mediaType) { imageBlocks.push({ type: "image", source: { type: "base64", media_type: image.mediaType, data: image.data, }, }); } } if (imageBlocks.length === 0) return part.content; return [{ type: "text", text: part.content }, ...imageBlocks]; } // --------------------------------------------------------------------------- // Anthropic.ContentBlock → EngineContentPart (from final message) // --------------------------------------------------------------------------- export function anthropicContentToEngine( content: Anthropic.ContentBlock[], ): EngineContentPart[] { return content .map((block) => { if (block.type === "text") { return { type: "text" as const, text: block.text }; } if (block.type === "tool_use") { return { type: "tool-call" as const, id: block.id, name: block.name, input: block.input, }; } if ((block as any).type === "thinking") { const b = block as any; return { type: "thinking" as const, text: b.thinking ?? "", signature: b.signature, }; } // Unknown block type — skip return { type: "text" as const, text: "" }; }) .filter((p) => !(p.type === "text" && p.text === "")); } // --------------------------------------------------------------------------- // Anthropic stream chunk → EngineEvent // --------------------------------------------------------------------------- /** * Mutable state threaded across `anthropicChunkToEngineEvents` calls within a * single stream. Anthropic's `content_block_delta` chunks carry only the block * `index`, not the tool-call id/name — those arrive once on the matching * `content_block_start`. We remember `index → { id, name }` here so each * `input_json_delta` can be surfaced as a `tool-input-delta` carrying the same * id/name the consumer expects (mirroring the Builder gateway shape). */ export interface AnthropicChunkStreamState { toolUseByIndex: Map; } export function createAnthropicChunkStreamState(): AnthropicChunkStreamState { return { toolUseByIndex: new Map() }; } /** * Translate an Anthropic stream chunk into zero or more EngineEvents. * Called in a loop as chunks arrive from client.messages.stream(). * * Pass a per-stream `state` (from `createAnthropicChunkStreamState`) to also * emit `tool-input-start` / `tool-input-delta` progress events while a tool * call's JSON input streams in. These are progress-only signals: the * authoritative `tool-call` blocks are still emitted from `finalMessage()` by * the engine, so omitting `state` simply drops the progress events without * changing tool dispatch. */ export function anthropicChunkToEngineEvents( chunk: any, state?: AnthropicChunkStreamState, ): EngineEvent[] { const events: EngineEvent[] = []; if (chunk.type === "content_block_start") { const block = chunk.content_block; if (block?.type === "tool_use") { const id = typeof block.id === "string" ? block.id : undefined; const name = typeof block.name === "string" ? block.name : undefined; if (state && typeof chunk.index === "number" && id && name) { state.toolUseByIndex.set(chunk.index, { id, name }); } events.push({ type: "tool-input-start", ...(id ? { id } : {}), ...(name ? { name } : {}), }); } } else if (chunk.type === "content_block_delta") { if (chunk.delta?.type === "text_delta") { events.push({ type: "text-delta", text: chunk.delta.text }); } else if (chunk.delta?.type === "thinking_delta") { events.push({ type: "thinking-delta", text: chunk.delta.thinking ?? "" }); } else if (chunk.delta?.type === "signature_delta") { // Signature arrives after thinking — emit as a thinking-delta with empty text // but carry the signature for the caller to store events.push({ type: "thinking-delta", text: "", signature: chunk.delta.signature, }); } else if (chunk.delta?.type === "input_json_delta") { // Partial JSON for a streaming tool-call input. Surface as countable // progress so long tool inputs (e.g. large extension HTML) don't look // hung to the agent loop's tool-input activity heartbeat. const active = state && typeof chunk.index === "number" ? state.toolUseByIndex.get(chunk.index) : undefined; events.push({ type: "tool-input-delta", ...(active?.id ? { id: active.id } : {}), ...(active?.name ? { name: active.name } : {}), text: typeof chunk.delta.partial_json === "string" ? chunk.delta.partial_json : "", }); } } return events; } // --------------------------------------------------------------------------- // Streamed tool-input reconciliation (shared by every engine adapter) // --------------------------------------------------------------------------- /** * Tool arguments arrive split across an arbitrary number of deltas. Every * engine announces a tool call the moment the first delta lands, so a stream * that dies mid-arguments leaves the turn advertising a call it never * delivered. Accumulating the delta text is the only way to tell "assembled * fine" from "cut off", instead of dropping the call and reporting success. */ export interface StreamedToolInputState { byId: Map; } export function createStreamedToolInputState(): StreamedToolInputState { return { byId: new Map() }; } /** * Feed an engine's own outgoing event into the accumulator. Works for every * adapter because they all emit the same normalized progress events. */ export function observeStreamedToolInput( state: StreamedToolInputState, event: EngineEvent, ): void { if (event.type === "tool-input-start" || event.type === "tool-input-delta") { const id = event.id; if (!id) return; const existing = state.byId.get(id); const text = (event.type === "tool-input-delta" && event.text) || ""; if (existing) { if (!existing.name && event.name) existing.name = event.name; existing.text += text; return; } state.byId.set(id, { name: event.name ?? "", text, delivered: false }); return; } if (event.type === "tool-call" || event.type === "tool-call-error") { markStreamedToolInputDelivered(state, event.id); } } export function markStreamedToolInputDelivered( state: StreamedToolInputState, id: string, ): void { const existing = state.byId.get(id); if (existing) existing.delivered = true; else state.byId.set(id, { name: "", text: "", delivered: true }); } const TRUNCATED_TOOL_INPUT_ERROR = "The arguments never finished streaming, so this call was not executed and nothing changed. Call the tool again with complete arguments."; /** * Reconcile what the stream announced against what it actually delivered. * Announced calls whose accumulated arguments parse are handed back as real * tool calls; the rest become in-band tool-call errors the model can read and * retry from. Nothing is dropped. */ export function finalizeStreamedToolInputs( state: StreamedToolInputState, deliveredIds: Iterable = [], ): EngineEvent[] { for (const id of deliveredIds) markStreamedToolInputDelivered(state, id); const events: EngineEvent[] = []; for (const [id, entry] of state.byId) { if (entry.delivered) continue; const input = parseStreamedToolInput(entry.text); if (input !== undefined) { events.push({ type: "tool-call", id, name: entry.name, input }); } else { events.push({ type: "tool-call-error", id, name: entry.name || "unknown-tool", input: entry.text, error: TRUNCATED_TOOL_INPUT_ERROR, }); } } return events; } function parseStreamedToolInput( text: string, ): Record | undefined { if (!text.trim()) return undefined; try { const parsed = JSON.parse(text); return isRecord(parsed) ? parsed : undefined; } catch { return undefined; } } // --------------------------------------------------------------------------- // Build tool_result blocks to append to messages after tool dispatch // --------------------------------------------------------------------------- export function buildToolResultPart( toolCallId: string, toolName: string, content: string, toolInput: unknown = {}, isError = false, ): EngineContentPart { return { type: "tool-result", toolCallId, toolName, toolInput: stringifyToolUseInputForGateway(toolInput), content, ...(isError ? { isError } : {}), }; }