agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
118 lines • 6.01 kB
JavaScript
/**
* anthropicCacheWire — the cache↔wire translation both Anthropic adapters share.
*
* Pattern: extracted helper (was module-private inside BrowserAnthropicProvider).
* Role: Outer ring. Three jobs, one file:
* 1. `ANTHROPIC_PROMPT_CACHING` — what every adapter on this wire DECLARES
* (`LLMProvider.promptCaching`): explicit breakpoints, four per request,
* usage reported. The agent chooses its cache strategy from it.
* 2. `applyCacheMarkers` — stamp `cache_control` onto an Anthropic request
* body where the framework's `CacheMarker`s point.
* 3. `readCacheUsage` — lift Anthropic's cache token counts off a response
* usage payload into the port's `cacheRead` / `cacheWrite` fields.
*
* Why it exists: the server adapter (`AnthropicProvider`) used to drop
* `LLMRequest.cacheMarkers` on the floor and report no cache tokens, so on
* the server path a byte-identical prompt prefix could neither be cached nor
* even OBSERVED as uncached — only the browser half kept its side of the
* contract. One module serving every adapter on this wire removes the gap
* and prevents byte-twin drift between them. The declaration lives here for
* the same reason: the three adapters that build this body
* (`anthropic()`, `browserAnthropic()`, `invokeModelGateway()`) make one
* promise, written once.
*
* @example
* const indexMap: number[] = [];
* const body = { model, max_tokens, messages: toAnthropicMessages(req.messages, indexMap) };
* if (req.cacheMarkers?.length) applyCacheMarkers(body, req.cacheMarkers, indexMap);
* // ...and on the way back:
* return { usage: { input, output, ...readCacheUsage(message.usage) } };
*/
// ─── The declaration ────────────────────────────────────────────────
/**
* What an adapter on the Anthropic Messages wire promises about prompt
* caching: it writes `cache_control` where the request's markers point
* (`applyCacheMarkers`), the API honours at most four such breakpoints per
* request, and the response's cache token counts reach the port
* (`readCacheUsage`).
*
* Declared by the adapter, not looked up by its name — so `withRetry(anthropic())`
* or an application's own wrapper keeps caching by forwarding this one field.
*/
export const ANTHROPIC_PROMPT_CACHING = Object.freeze({
mode: 'breakpoints',
maxBreakpoints: 4,
reportsUsage: true,
});
/**
* Apply `CacheMarker[]` to an Anthropic request body in-place.
*
* Per-field positional rules (Anthropic API):
* - `system`: convert from `string` → array of text blocks; mark the block
* with `cache_control`. The body carries the whole system prompt in ONE
* block today, so any system marker caches the whole prompt.
* - `tools`: mark the tool at `boundaryIndex` (clamped to last tool).
* - `messages`: mark the LAST content block of the LAST message in the
* cacheable prefix. Anthropic only honors `cache_control` there.
*
* `messageIndexMap` translates the marker's index — a position in
* `LLMRequest.messages` — into a position in `body.messages`. A message that
* did not survive the transform (`-1`, a system message) cannot be marked at
* all; silently marking its neighbour would claim a boundary nobody asked for.
*
* Markers arrive already clamped to the declared `maxBreakpoints` (four, see
* `ANTHROPIC_PROMPT_CACHING`) by `BreakpointCacheStrategy`, so no enforcement
* here.
*/
export function applyCacheMarkers(body, markers, messageIndexMap) {
for (const m of markers) {
const cacheControl = m.ttl === 'long' ? { type: 'ephemeral', ttl: '1h' } : { type: 'ephemeral' };
if (m.field === 'system') {
// Convert string system → array form so we can attach cache_control.
if (typeof body.system === 'string') {
body.system = [{ type: 'text', text: body.system, cache_control: cacheControl }];
}
}
else if (m.field === 'tools' && body.tools && body.tools.length > 0) {
const idx = Math.min(m.boundaryIndex, body.tools.length - 1);
const tool = body.tools[idx];
tool.cache_control = cacheControl;
}
else if (m.field === 'messages' && body.messages.length > 0) {
// Translate first: the marker names a request-message position, and a
// message that did not survive the transform cannot be marked at all.
const mapped = messageIndexMap?.[m.boundaryIndex];
if (messageIndexMap !== undefined && (mapped === undefined || mapped < 0))
continue;
const msgIdx = Math.min(mapped ?? m.boundaryIndex, body.messages.length - 1);
const msg = body.messages[msgIdx];
if (typeof msg.content === 'string') {
// String content → wrap in array so we can attach cache_control.
msg.content = [{ type: 'text', text: msg.content, cache_control: cacheControl }];
}
else if (Array.isArray(msg.content) && msg.content.length > 0) {
const last = msg.content[msg.content.length - 1];
last.cache_control = cacheControl;
}
}
}
}
/**
* Lift Anthropic's cache counts into the port's optional `cacheRead` /
* `cacheWrite` fields. Absent stays absent — an adapter must never invent a
* zero for a number the provider did not report (that is the difference
* between "no cache traffic" and "nobody measured").
*/
export function readCacheUsage(usage) {
if (!usage)
return {};
return {
...(typeof usage.cache_read_input_tokens === 'number' && {
cacheRead: usage.cache_read_input_tokens,
}),
...(typeof usage.cache_creation_input_tokens === 'number' && {
cacheWrite: usage.cache_creation_input_tokens,
}),
};
}
//# sourceMappingURL=anthropicCacheWire.js.map