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
JavaScript
;
/**
* 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