UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

393 lines • 19.2 kB
"use strict"; /** * anthropicMessagesWire — the Anthropic Messages body, spoken by more than one * transport. * * Pattern: shared pure helpers (no I/O). Role: outer ring, below the adapters. * * Two adapters put the SAME body on different wires: * - `browserAnthropic()` — POST api.anthropic.com/v1/messages, with a `model` * field and an `anthropic-version` header; * - `invokeModelGateway()` — POST {gateway}/model/{id}/invoke, with NO `model` * field (the id is in the path) and `anthropic_version` in the body. * * Everything else — `system` as a top-level field, content blocks, `tool_use` / * `tool_result`, thinking blocks, cache markers, the SSE event sequence — is one * shape, so it has ONE owner here instead of a copy per adapter. The functions * were moved out of `BrowserAnthropicProvider.ts` unchanged; its tests pin them. * * Deliberately NOT here: the transport (URL, headers, fetch, status handling) * and the SSE framing (the browser adapter reads `event:` lines; a gateway that * decoded AWS's binary framing may send bare `data:` lines). Each adapter turns * its bytes into {@link AnthropicStreamEvent}s and hands them to * {@link assembleAnthropicStream}. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.assembleAnthropicStream = exports.normalizeStopReason = exports.fromAnthropicResponse = exports.buildMessagesBody = exports.toAnthropicTool = exports.toAnthropicMessages = void 0; const anthropicCacheWire_js_1 = require("./anthropicCacheWire.js"); const anthropicModels_js_1 = require("./anthropicModels.js"); const forcedToolChoice_js_1 = require("../../provider-contract/forcedToolChoice.js"); const nativeOutputSchema_js_1 = require("../../provider-contract/nativeOutputSchema.js"); const anthropicThinkingWire_js_1 = require("./anthropicThinkingWire.js"); const anthropicReplayLayout_js_1 = require("./anthropicReplayLayout.js"); const anthropicMessageProjection_js_1 = require("./anthropicMessageProjection.js"); const anthropicToolArguments_js_1 = require("./anthropicToolArguments.js"); var anthropicMessageProjection_js_2 = require("./anthropicMessageProjection.js"); Object.defineProperty(exports, "toAnthropicMessages", { enumerable: true, get: function () { return anthropicMessageProjection_js_2.toAnthropicMessages; } }); Object.defineProperty(exports, "toAnthropicTool", { enumerable: true, get: function () { return anthropicMessageProjection_js_2.toAnthropicTool; } }); /** * Build the Messages body (minus `model`) from a framework request. * * ONE owner: `anthropic()`, `browserAnthropic()` and `invokeModelGateway()` * all build their body here, so a rule about the body — like which thinking * shape a model takes — is written once. * * @throws UnsupportedThinkingError before anything is sent, when the request * asks to think in a way the model cannot take (see `anthropicThinkingPlan`). */ function buildMessagesBody(req, options) { // The thinking shape THIS model takes, and the `max_tokens` it needs (kept // above the budget). Undefined when the request does not ask to think. const thinking = (0, anthropicThinkingWire_js_1.anthropicThinkingPlan)(req, options); // This wire sends a forced choice only when tools are present. Resolve the // same model facts the adapter declares, before any request reaches transport. if (req.toolChoice !== undefined && (req.tools?.length ?? 0) > 0 && !(0, anthropicModels_js_1.anthropicTakesForcedToolChoice)(options.model)) { throw new forcedToolChoice_js_1.UnsupportedToolChoiceError({ provider: options.provider, model: options.model }); } // Same rule for the structured-output format: refused before transport, // never dropped — a format that quietly vanishes is an unconstrained answer // the config says was constrained. if (req.outputFormat !== undefined && options.nativeOutputSchema?.(options.model) !== true) { throw new nativeOutputSchema_js_1.UnsupportedNativeOutputError({ provider: options.provider, model: options.model }); } // The map is only needed when a messages marker has to be placed; building // it always keeps the transform single-pass and costs one number per message. const messageIndexMap = []; const body = { max_tokens: thinking?.maxTokens ?? req.maxTokens ?? options.maxTokensDefault, messages: (0, anthropicMessageProjection_js_1.toAnthropicMessages)(req.messages, messageIndexMap), }; if (req.systemPrompt) body.system = req.systemPrompt; if (req.tools && req.tools.length > 0) body.tools = req.tools.map(anthropicMessageProjection_js_1.toAnthropicTool); if (req.temperature !== undefined) body.temperature = req.temperature; if (req.stop && req.stop.length > 0) body.stop_sequences = [...req.stop]; if (thinking !== undefined) body.thinking = thinking.thinking; // One tool per reply, enforced by the API rather than asked for in prose. // `auto` leaves the CHOICE of tool (and of calling one at all) with the // model — only the COUNT is capped. Guarded on `body.tools`: Anthropic // rejects `tool_choice` on a request that carries no tools, and an agent's // final answer call often has none. if (options.parallelToolCalls === false && body.tools !== undefined && body.tools.length > 0) { body.tool_choice = { type: 'auto', disable_parallel_tool_use: true }; } // Forced choice of one named tool (`.outputSchema(s, { strategy: // 'tool-forced' })`). Written LAST so it wins over the parallel cap: // capping how many tools a reply may use is a preference, constraining // WHICH tool answers is the contract the run is built on. Same guard. if (req.toolChoice && body.tools !== undefined && body.tools.length > 0) { body.tool_choice = { type: 'tool', name: req.toolChoice.name }; } // The structured-output format (`strategy: 'native'`), as the agent built // it (`core/agent/nativeOutputFormat.ts`): sent as-is, never re-shaped here. // `output_config.format` — the GA field; the deprecated `output_format` // needs a beta header and is never sent. if (req.outputFormat !== undefined) { body.output_config = { format: { type: 'json_schema', schema: req.outputFormat.schema, }, }; } // Cache markers — applied AFTER body construction so the materialized // fields (system / tools / messages) exist to mark. Clamped to the declared // `maxBreakpoints` (four) by BreakpointCacheStrategy before we get here. if (req.cacheMarkers && req.cacheMarkers.length > 0) { (0, anthropicCacheWire_js_1.applyCacheMarkers)(body, req.cacheMarkers, messageIndexMap); } return body; } exports.buildMessagesBody = buildMessagesBody; // ─── Response ─────────────────────────────────────────────────────── function fromAnthropicResponse(message, binding, argumentFailures) { const textParts = []; const toolCalls = []; // v2.14 — detect thinking presence so we can pass the full content // array through as `rawThinking` for the framework's thinking subflow // (handler filters thinking + redacted_thinking blocks; ignores rest). let hasThinking = false; for (const [index, block] of message.content.entries()) { if (block.type === 'text') textParts.push(block.text); else if (block.type === 'tool_use') { const argumentsFailure = argumentFailures?.get(index); toolCalls.push({ id: block.id, name: block.name, args: block.input, ...(argumentsFailure !== undefined && { argumentsFailure }), }); } else if (block.type === 'thinking' || block.type === 'redacted_thinking') { hasThinking = true; } } // A server-side router can serve a different model. The request hashes were // captured before I/O, but only the returned model identifies their origin. const actualModel = typeof message.model === 'string' ? message.model : ''; const originBinding = binding === undefined || binding.model === actualModel ? binding : { ...binding, model: actualModel }; const replay = argumentFailures?.size ? (0, anthropicReplayLayout_js_1.lossyAnthropicReplay)(message.content, message.id, message.stop_reason, originBinding) : (0, anthropicReplayLayout_js_1.captureAnthropicResponseReplay)(message.content, message.id, message.stop_reason, originBinding); return { content: textParts.join(''), toolCalls, usage: { input: message.usage.input_tokens, output: message.usage.output_tokens, // Cache traffic, when Anthropic reported it. Absent stays absent — // see anthropicCacheWire.readCacheUsage. ...(0, anthropicCacheWire_js_1.readCacheUsage)(message.usage), }, stopReason: normalizeStopReason(message.stop_reason), ...(message.id && { providerRef: message.id }), // Pass the FULL content array — handler filters by type. Undefined // when no thinking present so the subflow's early-return kicks in. ...(hasThinking && { rawThinking: message.content }), ...(replay !== undefined && { replay }), }; } exports.fromAnthropicResponse = fromAnthropicResponse; function normalizeStopReason(raw) { switch (raw) { case 'end_turn': return 'stop'; case 'tool_use': return 'tool_use'; case 'max_tokens': return 'max_tokens'; default: return raw; } } exports.normalizeStopReason = normalizeStopReason; function isBlockIndex(index) { return typeof index === 'number' && Number.isSafeInteger(index) && index >= 0; } /** * Anthropic's streaming event sequence → `LLMChunk`s. * * message_start (input usage) → content_block_start (tool_use id + name, or a * thinking block) → content_block_delta (`text_delta` yielded as a chunk; * `input_json_delta` / `thinking_delta` / `signature_delta` accumulated per * content-block INDEX, since blocks can interleave) → content_block_stop (the * block is finished and parsed) → message_delta (stop reason, output usage). * * The TERMINAL chunk (`done: true`) carries the assembled `response` — always, * because consumers read `chunk.response` for tool calls and usage. */ async function* assembleAnthropicStream(events, options) { let tokenIndex = 0; // ONE source of response order, shared across text, thinking and tool blocks. // A block completing early never moves it before an earlier response index. const contentByIndex = new Map(); const openBlocks = new Set(); const toolArguments = new anthropicToolArguments_js_1.AnthropicToolArguments(); const textParts = new Map(); const thinkingParts = new Map(); const signatureParts = new Map(); const receivedText = []; let hasUnindexedText = false; let messageId = ''; let model = ''; let stopReason; let sawMessageStop = false; let usage = { input_tokens: 0, output_tokens: 0 }; let completeLayout = true; let observedThinking = false; for await (const event of events) { // The fetch framing owner already selected the canonical event name. toolArguments.observe({ ...(typeof event.data === 'object' && event.data !== null ? event.data : {}), type: event.event, }); if (event.event === 'message_start') { const msg = event.data.message; if (msg) { messageId = msg.id ?? ''; model = msg.model ?? ''; usage = { ...usage, ...msg.usage }; } } else if (event.event === 'message_delta') { const data = event.data; if (data.delta?.stop_reason) stopReason = data.delta.stop_reason; usage = { ...usage, ...data.usage }; } else if (event.event === 'content_block_start') { const data = event.data; const block = data.content_block; const index = data.index; if (block?.type === 'thinking' || block?.type === 'redacted_thinking') observedThinking = true; if (!isBlockIndex(index) || !block) { completeLayout = false; continue; } if (contentByIndex.has(index)) completeLayout = false; contentByIndex.set(index, { ...block }); openBlocks.add(index); if (block.type === 'text') textParts.set(index, [block.text]); if (block.type === 'thinking') { thinkingParts.set(index, [block.thinking]); signatureParts.set(index, block.signature === undefined ? [] : [block.signature]); } } else if (event.event === 'content_block_delta') { const data = event.data; const delta = data.delta; if (delta?.type === 'thinking_delta' || delta?.type === 'signature_delta') observedThinking = true; const index = data.index; const validIndex = isBlockIndex(index); if (!validIndex) completeLayout = false; const block = validIndex ? contentByIndex.get(index) : undefined; if (delta?.type === 'text_delta' && typeof delta.text === 'string') { receivedText.push(delta.text); if (!validIndex) hasUnindexedText = true; if (validIndex) { if (block?.type === 'text') { textParts.get(index)?.push(delta.text); } else if (block === undefined) { // Legacy event fixtures omit the start. Keep their text, but never // claim that an incomplete event sequence proved full fidelity. contentByIndex.set(index, { type: 'text', text: delta.text }); textParts.set(index, [delta.text]); completeLayout = false; } else completeLayout = false; } if (delta.text.length > 0) { yield { tokenIndex, content: delta.text, done: false }; tokenIndex++; } } else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') { // The shared argument owner retains and parses this text at completion. if (block?.type !== 'tool_use') completeLayout = false; } else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') { if (validIndex && block?.type === 'thinking') { thinkingParts.get(index)?.push(delta.thinking); } else completeLayout = false; } else if (delta?.type === 'signature_delta' && typeof delta.signature === 'string') { if (validIndex && block?.type === 'thinking') { signatureParts.get(index)?.push(delta.signature); } else completeLayout = false; } else completeLayout = false; } else if (event.event === 'content_block_stop') { const data = event.data; const index = data.index; if (!isBlockIndex(index)) { completeLayout = false; continue; } if (!openBlocks.delete(index)) completeLayout = false; } else if (event.event === 'message_stop') { sawMessageStop = true; } } toolArguments.assertTerminal(); const entries = [...contentByIndex.entries()].sort(([a], [b]) => a - b); const argumentFailures = new Map(); if (openBlocks.size > 0 || stopReason === undefined || !sawMessageStop || entries.some(([index], position) => index !== position)) completeLayout = false; const binding = messageId ? options.bindingFactory?.(messageId) : undefined; const response = fromAnthropicResponse({ id: messageId, model, role: 'assistant', content: entries.map(([index, block], position) => { if (block.type === 'tool_use') { const checked = toolArguments.finish(index, block); if (checked.failure) { completeLayout = false; argumentFailures.set(position, checked.failure); return { ...block, input: options.onMalformedToolArgs({ id: block.id, name: block.name, index, raw: checked.raw, }), }; } return { ...block, input: checked.input }; } if (block.type === 'text') return { ...block, text: textParts.get(index)?.join('') ?? block.text }; if (block.type === 'thinking') { const signatures = signatureParts.get(index) ?? []; return { ...block, thinking: thinkingParts.get(index)?.join('') ?? block.thinking, ...(signatures.length > 0 && { signature: signatures.join('') }), }; } return block; }), stop_reason: stopReason ?? 'stop', usage, }, binding, argumentFailures); // Interrupted/malformed content is durable negative evidence, not unknown // caller history. Only a witnessed terminal event may certify completion. if (!completeLayout) response.replay = (0, anthropicReplayLayout_js_1.lossyAnthropicReplay)(entries.map(([, block]) => block), messageId, sawMessageStop ? stopReason ?? '' : '', response.replay?.binding, observedThinking); // Preserve the old text-only compatibility behavior for incomplete custom // stream transports, without certifying any order they did not provide. if (hasUnindexedText) response.content = receivedText.join(''); yield { tokenIndex, content: '', done: true, response: { ...response, wireManifest: options.wireManifest, ...(options.replayOmissions?.length && { replayOmissions: options.replayOmissions }), }, }; } exports.assembleAnthropicStream = assembleAnthropicStream; //# sourceMappingURL=anthropicMessagesWire.js.map