UNPKG

agentfootprint

Version:

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

496 lines • 23.1 kB
"use strict"; /** * BedrockProvider — wraps AWS Bedrock's Converse API as an `LLMProvider`. * * Pattern: Adapter (GoF) + Ports-and-Adapters (Cockburn 2005). * Role: Outer ring — translates `LLMRequest`/`LLMResponse` to/from * AWS Bedrock's model-agnostic Converse / ConverseStream APIs. * Works with ANY Bedrock-hosted model (Claude, Llama, Mistral, * Titan, Mixtral, ...) without format-specific code. * Emits: N/A. * * Requires: `npm install @aws-sdk/client-bedrock-runtime` * * The Converse API is model-agnostic — one adapter covers every * Bedrock-hosted model (Claude, Llama, Mistral, Titan, Mixtral, ...). * * Tool calling works on BOTH paths: `complete()` reads `toolUse` blocks * off the Converse response, and `stream()` accumulates ConverseStream * `contentBlockStart` / `contentBlockDelta` / `contentBlockStop` events * (keyed by `contentBlockIndex`, so parallel tool calls that interleave * are parsed correctly) and delivers them on the terminal chunk's * `response.toolCalls`. A parity test pins the two paths together. * * ─── Limitations ──────────────────────────────────────────────────── * * • Multi-modal NOT supported (text content only). * • Guardrail integration NOT exposed yet — pass via the SDK client * directly if needed. * • Thinking is NOT implemented: Converse reasoning blocks and their signed * replay are not carried by this adapter. A request to think is refused * before sending, never silently dropped. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.BedrockProvider = exports.bedrock = void 0; const lazyRequire_js_1 = require("../../lib/lazyRequire.js"); const abort_js_1 = require("../../lib/abort.js"); const contextWindow_js_1 = require("../../provider-contract/contextWindow.js"); const retryAfter_js_1 = require("./retryAfter.js"); const anthropicModels_js_1 = require("./anthropicModels.js"); const forcedToolChoice_js_1 = require("../../provider-contract/forcedToolChoice.js"); const errors_js_1 = require("../../thinking/errors.js"); const bedrockCacheWire_js_1 = require("./bedrockCacheWire.js"); /** `client.send(cmd)`, with the request's signal when it has one. */ function sendUnder(client, cmd, signal) { return signal ? client.send(cmd, { abortSignal: signal }) : client.send(cmd); } /** * Which roles this wire carries inside `messages`. * * `'system'` is ABSENT and that is the wire's own rule, not a policy choice: * `toAnthropicMessages` drops a `role: 'system'` message because Anthropic * takes the system prompt as a separate top-level field. Declaring the truth * here is what lets a `slot: 'messages'` injection be refused at run start * rather than silently vanish between the recording and the request. */ const CARRIES_IN_MESSAGES = Object.freeze(['user', 'assistant']); function bedrock(options = {}) { const { client, Commands } = resolveClient(options); const defaultModel = options.defaultModel ?? 'anthropic.claude-sonnet-4-5-20250929-v1:0'; const defaultMaxTokens = options.defaultMaxTokens ?? 4096; const modelOf = (model) => (model === 'bedrock' ? defaultModel : model); const provider = { name: 'bedrock', promptCaching: (model) => (0, bedrockCacheWire_js_1.bedrockPromptCaching)(modelOf(model)), carriesInMessages: CARRIES_IN_MESSAGES, // The existing boolean describes the wire. The optional model predicate // narrows it where the shared Claude table knows a model rejects it. carriesForcedToolChoice: true, supportsForcedToolChoice: (model) => (0, anthropicModels_js_1.anthropicTakesForcedToolChoice)(modelOf(model)), thinkingMode: () => 'none', async complete(req) { // A cancelled call sends nothing (lib/abort.ts, law 1). (0, abort_js_1.throwIfAborted)(req.signal); const input = buildInput(req, modelOf(req.model), defaultMaxTokens); try { const cmd = new Commands.Converse(input); // The signal rides the SDK's HTTP options, so an abort cancels the // request itself; the race refuses an answer that lands after it. const response = (await (0, abort_js_1.untilAborted)(sendUnder(client, cmd, req.signal), req.signal)); return fromBedrockResponse(response); } catch (err) { throw wrapError(err, req.signal); } }, async *stream(req) { (0, abort_js_1.throwIfAborted)(req.signal); const input = buildInput(req, modelOf(req.model), defaultMaxTokens); let response; try { const cmd = new Commands.ConverseStream(input); response = (await (0, abort_js_1.untilAborted)(sendUnder(client, cmd, req.signal), req.signal)); } catch (err) { throw wrapError(err, req.signal); } const stream = response.stream; if (!stream) { // Some Bedrock models / regions don't support streaming — // fall back to a synthesized terminal chunk via complete(). const final = await provider.complete(req); yield { tokenIndex: 0, content: '', done: true, response: final }; return; } const textParts = []; let stopReason = 'stop'; let usage = { input: 0, output: 0 }; let tokenIndex = 0; // Tool-use blocks under construction, keyed by `contentBlockIndex`. // Keying by index (not arrival order) is what makes PARALLEL tool // calls work: Bedrock interleaves their argument fragments. const toolUseByIndex = new Map(); const completedToolCalls = []; try { // An abort ends this loop at once, even when a read is still pending, // and closes the event stream (lib/abort.ts, laws 2–3). for await (const event of (0, abort_js_1.abortableIterable)(stream, req.signal)) { // ── Block start: id + name arrive here; arguments follow as // JSON string fragments on deltas at the same index. ── const startIndex = event.contentBlockStart?.contentBlockIndex; const startToolUse = event.contentBlockStart?.start?.toolUse; if (startToolUse && typeof startIndex === 'number') { toolUseByIndex.set(startIndex, { id: startToolUse.toolUseId ?? '', name: startToolUse.name ?? '', fragments: [], }); } if (event.contentBlockDelta?.delta?.text) { const text = event.contentBlockDelta.delta.text; textParts.push(text); yield { tokenIndex, content: text, done: false }; tokenIndex++; } // ── Argument fragment. `LLMChunk` has no tool-delta field, so // these are NOT yielded as chunks (same as both Browser // providers) — they surface on the terminal `response`. ── const inputFragment = event.contentBlockDelta?.delta?.toolUse?.input; if (typeof inputFragment === 'string') { const deltaIndex = event.contentBlockDelta?.contentBlockIndex; const open = typeof deltaIndex === 'number' ? toolUseByIndex.get(deltaIndex) : undefined; // Bedrock sends `input: ''` fragments for no-arg tools; push // them anyway — the join below collapses them to ''. if (open) open.fragments.push(inputFragment); } // ── Block complete: join the fragments and parse. ── const stopIndex = event.contentBlockStop?.contentBlockIndex; if (typeof stopIndex === 'number') { const tu = toolUseByIndex.get(stopIndex); if (tu) { toolUseByIndex.delete(stopIndex); completedToolCalls.push({ id: tu.id, name: tu.name, args: parseToolArgs(tu.fragments.join(''), tu.name, tu.id, stopIndex), }); } } if (event.messageStop?.stopReason) { stopReason = normalizeStopReason(event.messageStop.stopReason); } if (event.metadata?.usage) { usage = { input: event.metadata.usage.inputTokens, output: event.metadata.usage.outputTokens, ...(0, bedrockCacheWire_js_1.readCacheUsage)(event.metadata.usage), }; } } // A block still open here never received its `contentBlockStop` — // truncated wire data. Emitting a half-parsed call would run a // tool with incomplete arguments, so we drop it and let the // honesty invariant below catch the resulting contradiction. const finalResponse = ensureHonestToolUse({ content: textParts.join(''), toolCalls: completedToolCalls, usage, stopReason, }); yield { tokenIndex, content: '', done: true, response: finalResponse }; } catch (err) { throw wrapError(err, req.signal); } }, }; return provider; } exports.bedrock = bedrock; class BedrockProvider { name = 'bedrock'; carriesInMessages = CARRIES_IN_MESSAGES; carriesForcedToolChoice = true; thinkingMode = () => 'none'; supportsForcedToolChoice; promptCaching; inner; constructor(options = {}) { this.inner = bedrock(options); this.promptCaching = this.inner.promptCaching; this.supportsForcedToolChoice = (model) => (0, forcedToolChoice_js_1.forcedToolChoiceFor)(this.inner, model); } // `hooks` is FORWARDED, not dropped — see LLMCallHooks in provider-contract/types.ts. complete(req, hooks) { return this.inner.complete(req, hooks); } stream(req, hooks) { if (!this.inner.stream) throw new Error('stream() unavailable'); return this.inner.stream(req, hooks); } } exports.BedrockProvider = BedrockProvider; // ─── Internals ────────────────────────────────────────────────────── function resolveClient(options) { let mod; if (options._sdk) { mod = options._sdk; } else if (options._client && options._commands) { return { client: options._client, Commands: options._commands }; } else { try { mod = (0, lazyRequire_js_1.lazyRequire)('@aws-sdk/client-bedrock-runtime'); } catch { throw new Error('BedrockProvider requires `@aws-sdk/client-bedrock-runtime`.\n' + ' Install: npm install @aws-sdk/client-bedrock-runtime\n' + ' Or pass `_client` + `_commands` for test injection.'); } } // Named one at a time so a missing one is reported BY NAME. `Converse` and // `ConverseStream` are the two operations this provider has; an SDK missing // either is a different problem to an SDK that is not installed. for (const name of ['ConverseCommand', 'ConverseStreamCommand']) { if (typeof mod[name] !== 'function') { throw new Error(`BedrockProvider: \`@aws-sdk/client-bedrock-runtime\` is missing ${name}. ` + 'Upgrade the SDK, or pass `_client` + `_commands` with your own mapping.'); } } const client = options._client ?? (() => { if (typeof mod.BedrockRuntimeClient !== 'function') { throw new Error('BedrockProvider: `@aws-sdk/client-bedrock-runtime` is installed but ' + '`BedrockRuntimeClient` was not found. Update the SDK.'); } return new mod.BedrockRuntimeClient({ region: options.region }); })(); return { client, // Both checked by name directly above. /* eslint-disable @typescript-eslint/no-non-null-assertion */ Commands: { Converse: mod.ConverseCommand, ConverseStream: mod.ConverseStreamCommand }, /* eslint-enable @typescript-eslint/no-non-null-assertion */ }; } function buildInput(req, model, defaultMaxTokens) { // Converse accepts provider-specific thinking parameters, but enabling one // without carrying its signed reasoning through response/tool-result replay // would still break the conversation. Refuse this adapter's unsupported // intent before constructing a command on either transport path. if (req.thinking) { throw new errors_js_1.UnsupportedThinkingError({ provider: 'bedrock', model, reason: 'no-thinking', detail: 'this Converse adapter does not implement thinking response and signed replay, so ' + '.thinking() cannot be honoured. Drop .thinking(), or use invokeModelGateway for ' + 'a supported Claude Messages route through your gateway.', }); } if (req.toolChoice !== undefined && (req.tools?.length ?? 0) > 0 && !(0, anthropicModels_js_1.anthropicTakesForcedToolChoice)(model)) { throw new forcedToolChoice_js_1.UnsupportedToolChoiceError({ provider: 'bedrock', model }); } const positions = []; const input = { modelId: model, messages: toBedrockMessages(req.messages, positions), }; if (req.systemPrompt) input.system = [{ text: req.systemPrompt }]; if (req.tools && req.tools.length > 0) { input.toolConfig = { tools: req.tools.map(toBedrockTool), // v7.26 — forced choice of one named tool. Nested inside the same guard // because `toolChoice` lives ON `toolConfig`: there is no place to put // it on a request that carries no tools. ...(req.toolChoice && { toolChoice: { tool: { name: req.toolChoice.name } } }), }; } const inference = { maxTokens: req.maxTokens ?? defaultMaxTokens, }; if (req.temperature !== undefined) inference.temperature = req.temperature; if (req.stop && req.stop.length > 0) inference.stopSequences = [...req.stop]; input.inferenceConfig = inference; if (req.cacheMarkers !== undefined) (0, bedrockCacheWire_js_1.applyCacheMarkers)(input, req.cacheMarkers, model, positions); return input; } function toBedrockMessages(messages, positions) { const result = []; for (const [messageIndex, m] of messages.entries()) { if (m.role === 'system') continue; // system goes in `system` field if (m.role === 'user') { result.push({ role: 'user', content: [{ text: m.content }] }); positions[messageIndex] = { message: result.length - 1, content: 0 }; continue; } if (m.role === 'assistant') { const blocks = []; if (m.content) blocks.push({ text: m.content }); if (m.toolCalls) { for (const tc of m.toolCalls) { blocks.push({ toolUse: { toolUseId: tc.id, name: tc.name, input: { ...tc.args } }, }); } } // An assistant turn with no text and no tool calls carries nothing, and // Converse refuses a blank text block — drop it, the same rule the // Anthropic wire keeps (`anthropicMessagesWire.ts` · `toAnthropicMessages`). if (blocks.length === 0) continue; result.push({ role: 'assistant', content: blocks }); positions[messageIndex] = { message: result.length - 1, content: blocks.length - 1 }; continue; } if (m.role === 'tool') { const block = { toolResult: { toolUseId: m.toolCallId ?? '', content: [{ text: m.content }], }, }; const last = result[result.length - 1]; if (last && last.role === 'user') { last.content.push(block); } else { result.push({ role: 'user', content: [block] }); } positions[messageIndex] = { message: result.length - 1, content: result[result.length - 1].content.length - 1, }; continue; } } return result; } function toBedrockTool(schema) { return { toolSpec: { name: schema.name, description: schema.description, inputSchema: { json: { ...schema.inputSchema } }, }, }; } function fromBedrockResponse(response) { const message = response.output?.message; const textParts = []; const toolCalls = []; if (message) { for (const block of message.content) { if ('text' in block && block.text) textParts.push(block.text); else if ('toolUse' in block && block.toolUse) { toolCalls.push({ id: block.toolUse.toolUseId, name: block.toolUse.name, args: block.toolUse.input, }); } } } return ensureHonestToolUse({ content: textParts.join(''), toolCalls, usage: { input: response.usage?.inputTokens ?? 0, output: response.usage?.outputTokens ?? 0, ...(0, bedrockCacheWire_js_1.readCacheUsage)(response.usage), }, stopReason: normalizeStopReason(response.stopReason ?? 'end_turn'), providerRef: response.ResponseMetadata?.RequestId, }); } /** * Honesty invariant: a response claiming `tool_use` with zero parsed tool * calls is self-contradictory — the model asked for tools and the adapter * lost them. Never return it quietly; throw so a configured `reliability` * rule (retry / fallback / fail-fast) can act instead of the agent * silently answering without running its tools. * * Post-7.6.1 this cannot happen on today's wire shapes — it is a tripwire * for future ConverseStream shape drift. Applied on BOTH the streaming and * non-streaming exits so the two paths can never drift apart again. */ function ensureHonestToolUse(response) { if (response.stopReason === 'tool_use' && response.toolCalls.length === 0) { throw Object.assign(new Error("[bedrock] stopReason 'tool_use' with zero parsed tool calls — " + 'tool-use blocks were lost (stream-shape drift?). ' + 'Refusing to return a self-contradictory response.'), { name: 'BedrockProviderError', code: 'BEDROCK_STREAM_TOOLUSE_LOST' }); } return response; } /** * Parse the joined JSON fragments of one streamed tool-use block. * * Empty (no-arg tools stream zero fragments, or a single `''`) → `{}`. * Malformed → a TYPED throw, never a silent `{}`: swallowing the failure * would execute a side-effecting tool with its arguments dropped, which is * the worst failure mode available. A thrown provider error reaches the * `reliability` / `withRetry` machinery, and a retry usually streams clean * JSON. * * The raw fragment bytes are deliberately NOT put in the message — tool * arguments can carry user data and provider error messages land in audit * logs unredacted. `rawLength` is reported instead. */ function parseToolArgs(joined, toolName, toolUseId, contentBlockIndex) { if (joined.trim().length === 0) return {}; try { return JSON.parse(joined); } catch (parseErr) { throw Object.assign(new Error(`[bedrock] malformed tool-use JSON for tool '${toolName}' (toolUseId ${toolUseId}): ` + `${parseErr.message}. Refusing to run the tool with dropped arguments.`), { name: 'BedrockProviderError', code: 'BEDROCK_MALFORMED_TOOL_ARGS', toolName, toolUseId, contentBlockIndex, rawLength: joined.length, cause: parseErr, }); } } function normalizeStopReason(raw) { switch (raw) { case 'end_turn': return 'stop'; case 'tool_use': return 'tool_use'; case 'max_tokens': return 'max_tokens'; case 'stop_sequence': return 'stop_sequence'; case 'guardrail_intervened': case 'content_filtered': return 'content_filter'; default: return raw; } } function wrapError(err, signal) { // A cancelled call fails AS the cancellation, whatever the SDK threw for it // — never as a provider error that a retry would re-send (lib/abort.ts). if (signal?.aborted) return (0, abort_js_1.abortErrorOf)(signal); // Already ours (the typed tool-use conditions above) — re-wrapping would // drop `code` / `toolName` / `toolUseId` and double-prefix the message. if (err instanceof Error && err.name === 'BedrockProviderError') return err; // A `ValidationException` reading "Input is too long for requested model" // is the same failure OpenAI spells `context_length_exceeded`; it leaves // here typed, with the fixes, rather than as a generic validation error. const tooBig = (0, contextWindow_js_1.asContextWindowExceeded)(err, { provider: 'bedrock' }); if (tooBig) return tooBig; if (err instanceof Error) { // A Retry-After on the raw response (`$response.headers`), declared for // withRetry — absent when none, so the error shape is unchanged. const retryAfterMs = (0, retryAfter_js_1.retryAfterMsFromError)(err); return Object.assign(new Error(`[bedrock] ${err.message}`), { name: 'BedrockProviderError', cause: err, status: err.$metadata?.httpStatusCode ?? err.status, ...(retryAfterMs !== undefined && { retryAfterMs }), }); } return new Error(`[bedrock] ${String(err)}`); } //# sourceMappingURL=BedrockProvider.js.map