agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
643 lines • 32.8 kB
JavaScript
/**
* receipt — what the model was actually handed, recorded at the stop where it
* was handed it.
*
* Role: Fold. This module owns the receipt's SHAPE and the one function that
* mints one (`buildReceipt`); every chart's request assembly calls it
* and commits the result under the scope key `receipt`, so the record
* lands in the call-llm bundle that already exists. Nothing here
* executes, reads scope, or emits.
* Reads: its arguments.
* Emits: N/A.
*
* ── THE FIRST LAW: NO AUTHORITY OMISSIONS ──────────────────────────────────
* A receipt never names — and never counts — what a caller's ROLE was not
* allowed to see. Committed state is readable by the trace toolpack's
* debugging tools, so a receipt carrying `hiddenSkillIds`, or even "3 skills
* withheld", would turn a permission decision into a leak path: the thing the
* check exists to hide, restated one layer down where nobody is checking. A
* reader that needs those ids reads them from the fold (`hiddenSkillIds` is
* committed state, and reading it is governed where it is read), not from
* here.
*
* ATTENTION omissions are a different fact and MAY ride the receipt:
* `omittedForAttention` says a budget dropped content before this request was
* composed. Nobody was refused anything — the request simply did not fit — and
* a reader chasing "why did it not know that?" needs to see it. Even there the
* turns are hashed, never quoted, for the same reason everything else on a
* receipt is.
*
* WHO SUPPLIES IT (9.93.0). Measured on 9.88.0 and again on 9.91.0, no chart
* supplied it, and the reason recorded was the slots: a slot writes its drops
* to `slotCompositions` inside its own subflow, no boundary bubbles that record
* out — and, underneath that, the built-in slots never DROP anything
* (`slots/helpers.ts` · `slotOverflow` answers over-budget with
* `planAction: 'none'`). That was true and it was not the whole measurement.
* The agent chart's WINDOW STAGE (`stages/window.ts`) evicts turns for budget
* on every run whose strategy engages — `context.evicted` fires with
* `reason: 'budget'` — and it never wrote the field. Since 9.93.0 it does: the
* window files what left at this iteration's head on an in-memory handle
* (`window/evictedTurns.ts`), the same seam the compaction meter already
* crosses, and the call-llm mint hands those turns here. NOT a scope read:
* a tracked read of `compactions` — absent on every run without a window —
* would put a phantom context source on every call-llm stage, the defect
* measured when `slotCompositions` was read for one release.
*
* Each hash is the evicted turn's OWN digest — `messageDigestInput`, the same
* formula `messages.entries[].hash` uses — so a dropped turn on epoch k's
* receipt pairs by hash with the turn as it was served on an earlier epoch's.
* That pairing is a law a test can check, and `gap-sentences.test.ts` does.
*
* ── THE SECOND LAW: HASHES AND REFERENCES, NEVER BYTES ─────────────────────
* A receipt is a fingerprint, not a copy. It records that a piece of a given
* shape and size was in a given position, and nothing about what it said. The
* bytes are already governed — `recordSystemPrompt` is opt-in for exactly this
* reason, redaction patterns scrub the committed mirror, and a window strategy
* decides what survives. A receipt that carried content would quietly reopen
* all three.
*
* ── THE THIRD LAW: HASHES ARE SALTED WITH THE RUN ID ───────────────────────
* `hash = sha256(runId + '\u001f' + content)`, first 16 hex characters. The
* salt is what stops a short piece from being fingerprinted ACROSS runs: an
* unsalted hash of a one-line system prompt, a tool schema, or a two-word user
* turn is a dictionary lookup away from being read back, and the receipt is
* committed state that travels in recordings. Salted per run, the same
* sentence in two runs has two hashes, so the fingerprint answers "is this the
* same as THAT piece of THIS run?" — which is the only question the
* conformance law asks — and cannot be matched across runs.
*
* Hashes are NOT redacted, and the salt is what limits THEM — not an
* assumption that something scrubbed them. It does not keep a GUESSABLE value
* secret from someone holding the recording, which carries the `runId`: a
* short value can be confirmed by hashing guesses (a named limit of an
* agent's `redact`, `src/redaction/README.md`). It says nothing about the rest
* of the recording, and this law must not be read as if it did.
*
* WHAT A RECORDING ACTUALLY CONTAINS. An agent run is redacted only when it
* was covered by a policy — `Agent.create({ redact })`, a composed member's,
* or one a calling tool handed down. Without one, a secret in a system
* prompt is in the commit log and in `servedAt(k).system.text`, verbatim.
* With one, footprintjs scrubs at COMMIT time, so a selected key never enters
* the commit log, and `getLastSnapshot()` serves the redacted mirror
* (`servableSnapshot`) — the rebuilt view reads a redacted piece as ABSENT
* (`time-travel/servedView.ts` never prints a placeholder as if the request had carried
* it), so the view's hash no longer matches this receipt's (the receipt hashed
* what the model was served). The same holds for `flowchartAsTool({ redact })` /
* `runbookAsTool({ redact })` on an inner run. A snapshot taken with
* `redact: true` also omits `initialState`, so folding it reports
* `basis: 'log-only'` and `time-travel/servedView.ts` raises `no-fold-base` rather than
* rebuilding a short view in silence. Treat an unredacted recording as the
* plaintext it is; the salt protects the fingerprints, and only the
* fingerprints.
*
* ── THE FOURTH LAW: THE RECEIPT IS MINTED AT THE PROVIDER PORT ─────────────
* Everything on a receipt describes the request AS HANDED TO
* `LLMProvider.complete` — the port, not the wire. Anything downstream of that
* boundary is outside what this record can witness: a decorated provider, a
* vendor adapter's own serializer, a consumer's hand-written `complete`, or the
* vendor's server-side defaults. `RECEIPT_BOUNDARY` states it in one sentence
* for a renderer to print, and `time-travel/servedView.ts` · `SERVED_GAPS` names the two
* places a reader is most likely to mistake it for wire-level proof.
*/
import { contributingPieces } from '../composeRequest.js';
import { sha256Hex } from '../../../lib/sha256.js';
import { stableJson } from '../../../lib/stableJson.js';
import { measureRequest } from './requestMeasurement.js';
/**
* The separator between two fields inside one digest input — ASCII UNIT
* SEPARATOR, which no prompt, tool name or JSON payload can contain (`JSON.
* stringify` escapes it). Written as an escape rather than a literal control
* character so a reader can see it and a formatter cannot eat it.
*
* Without it a digest is a bare concatenation, and a bare concatenation is
* ambiguous: `role + content` cannot tell `'user' + 'x'` from `'use' + 'rx'`.
*/
const SEP = '\u001F';
/** The separator between two tool calls inside one message's digest. */
const CALL_SEP = '\u001C';
/** The separator between the fields of ONE tool call. */
const CALL_FIELD_SEP = '\u001D';
/** The separator between the top-level parts of a message's digest. */
const FIELD_SEP = '\u001E';
/**
* What a digest input records in place of a value JSON cannot express — a
* cycle, a `BigInt`, anything `stableJson` refuses. It is a MARK, not an empty
* string: two unserializable values are recorded as unserializable rather than
* as identical, which is what stops the cache comparison claiming "the strategy
* changed nothing" about two requests it could not read.
*/
export const UNSERIALIZABLE = '\u001F<unserializable>';
/**
* The boundary every receipt field is true at, in one sentence — exported so a
* renderer prints the library's own wording instead of inferring a stronger
* claim from a `null`.
*
* WHY IT IS A CONSTANT AND NOT A COMMENT. A reader who sees
* `cache.transform: 'unchanged'` will conclude "this request was not rewritten"
* unless something on the screen says otherwise, and the person who reads the
* screen is rarely the person who read the source. Print it beside the record.
*
* ── THE MECHANISM, WHICH THE PRINTED SENTENCE NO LONGER NAMES (9.88.0) ─────
* `buildReceipt` is called with the request about to be passed to
* `LLMProvider.complete` — the PORT, this library's last sight of it. Four
* stages call it since 9.91.0: the agent charts' (`stages/callLLM.ts`),
* `LLMCall.ts` · `callLLM`, and the two message-API charts' through
* `messageApiReceipt.ts`. Three things sit downstream of that call and none of them is on the
* record: a provider decorated by the consumer (`complete()` wrapping
* `complete()`), a vendor adapter's own serializer, and the vendor's
* server-side defaults. `test/time-travel/receipt-conformance.test.ts`
* reproduces the first of those — a decorator that appends a system suffix and
* a ghost tool after the mint, with `cache.transform` still reading
* `'unchanged'`, correctly.
*
* The sentence below used to say all of that, and it was PRINTED beside a
* trace by renderers that append it to a gap. A printed sentence names no
* module, function or call: it says which fields, what they mean here, and
* what to do — the rule in `test/helpers/gapProseClaims.ts`. So the mechanism
* lives in this comment, where a maintainer reads it and review catches its
* rot, and the constant says only the thing a reader must not get wrong.
*
* @example
* ```ts
* import { RECEIPT_BOUNDARY, receiptAt } from 'agentfootprint';
*
* const receipt = receiptAt(agent.getSnapshot()!, 1)!;
* if (receipt.cache.transform === 'unchanged') {
* console.log(`the cache strategy changed nothing. ${RECEIPT_BOUNDARY}`);
* }
* ```
*/
export const RECEIPT_BOUNDARY = 'A receipt describes the request as this library last saw it. Whatever ' +
'handled it after that could have changed it; notes about later preparation ' +
'do not prove what was sent or accepted.';
/** The scope key holding the name of the tool a `'tool-forced'` output
* strategy put on the wire. `seed` commits it; `time-travel/servedView.ts` · `servedAt`
* reads it — so a rebuild can NAME the forced tool without reading the
* receipt it is being checked against. */
export const FORCED_OUTPUT_TOOL_KEY = 'forcedOutputToolName';
/** How many hex characters of the digest a receipt keeps. 16 — 64 bits, which
* is far past collision range for the few dozen pieces of one run, and short
* enough to read in a terminal. */
export const RECEIPT_HASH_CHARS = 16;
/**
* The receipt's hash: run-salted SHA-256, first {@link RECEIPT_HASH_CHARS} hex
* characters. Exported because it is half of the conformance law — a reader
* that rebuilds a served view proves the rebuild by hashing it the same way.
*
* @example
* ```ts
* import { receiptAt, receiptHash, servedAt } from 'agentfootprint';
*
* const view = servedAt(agent.getSnapshot()!, 1)!;
* const receipt = receiptAt(agent.getSnapshot()!, 1)!;
* receiptHash(receipt.basis.runId, view.system.text) === receipt.system.hash; // true
* ```
*/
export function receiptHash(runId, content) {
return sha256Hex(`${runId}${SEP}${content}`).slice(0, RECEIPT_HASH_CHARS);
}
/** `stableJson`, with the unreadable marked rather than dropped — the form a
* digest input takes. */
function digestJson(value) {
return stableJson(value) ?? UNSERIALIZABLE;
}
/**
* The bytes a tool schema's hash covers: the whole `LLMToolSchema` — `name`,
* `description`, `inputSchema` — as sorted-key JSON, so two schemas that are
* structurally the same hash the same however they were built, and with a
* schema JSON cannot express (a cycle, a `BigInt`) marked {@link UNSERIALIZABLE}
* rather than dropped or thrown on.
*
* WHAT TO PASS (9.89.0). The receipt hashes each tool AS HANDED TO THE PORT:
* `buildReceipt` is given the request's tool list, and that list is the
* `dynamicToolSchemas` the run committed, plus a forced answer tool when an
* output strategy adds one. `servedAt(k).tools.schemas[i]` is that committed
* list read back — the same shape, the same bytes — so a consumer holding a
* served view already holds the object this function takes. It does not take
* the served view, a `Tool`, or a `defineTool` definition: those carry an
* `execute`, `wants` and other fields the model never saw.
*
* WHY IT IS EXPORTED. A reader that rebuilds a served view proves the rebuild
* by hashing it the way the receipt did. `receiptHash` and `messageDigestInput`
* were exported in 9.88.0 for the system text, the pieces and the messages,
* and a consumer could verify all of them — and NOT the tools, because the
* serializer behind `schemaHashes` was internal. Its only options were to copy
* `stableJson` (a second owner of the rule, which drifts the day the digest
* gains a field, as the message digest did in 9.88.0) or to leave the schema
* rows unverified. This is the third digest half of the law, beside its two
* siblings, and the ONLY spelling of the schema rule: `buildReceipt` calls it
* too. `stableJson` stays off the root barrel for the same reason — a consumer
* composing `hash(stableJson(tool))` would be writing the rule a second time.
*
* @example verify every schema row of a receipt from outside
* ```ts
* import { receiptAt, receiptHash, servedAt, toolDigestInput } from 'agentfootprint';
*
* const snapshot = agent.getSnapshot()!;
* const view = servedAt(snapshot, 1)!;
* const receipt = receiptAt(snapshot, 1)!;
* for (const tool of view.tools.schemas) {
* receiptHash(receipt.basis.runId, toolDigestInput(tool)) ===
* receipt.tools.schemaHashes[tool.name]; // true, for every tool of every epoch
* }
* // A forced answer tool is in `schemaHashes` and NOT in `schemas` — its body
* // is a declared gap (`forced-tool-schema`), so there is no row to check.
* ```
*/
export function toolDigestInput(tool) {
return digestJson(tool);
}
/**
* The bytes a message's hash covers, each field behind a separator: its role,
* its text, its `toolCallId`, its `toolName`, the calls it asked for (id, name,
* arguments and `providerMeta`) and its `thinkingBlocks`.
*
* WHY `toolCallId` (9.88.0). It is the JOIN KEY — `tool_use_id` on Anthropic's
* wire, `tool_call_id` on OpenAI's — that pairs a tool result to the call that
* asked for it. Two parallel calls whose results happen to have identical text
* hashed the SAME without it, so a mis-pairing (the answer to `c1` filed under
* `c2`) was invisible to the law: exactly the defect a receipt exists to catch.
*
* WHY `toolName` TOO (9.88.0). On two shipped providers the id is not the join
* key at all. `adapters/llm/GeminiProvider.ts` · `toGeminiContents` pairs a
* `functionResponse` to its call BY NAME and says so in its own comment; the
* id is optional there and dropped when it is not a real one.
* `adapters/llm/OllamaProvider.ts` · `toOllamaMessages` puts `tool_name` on
* the wire and only falls back to the id. So on those providers a receipt that
* covered `toolCallId` alone still could not see the very mis-pairing the
* field was added for: two `role:'tool'` messages with identical text and
* SWAPPED names fingerprinted identically. It rides as its own field behind
* the separator, beside the id, so a name moving between two results changes
* both hashes and neither can absorb the other's bytes.
*
* WHY `thinkingBlocks` and `providerMeta` (9.88.0). `provider-contract/types.ts` is
* explicit that a signed thinking block MUST be echoed byte-exact or the API
* rejects the turn, and `providerMeta` round-trips vendor state (Gemini's
* `thoughtSignature`, without which the turn after a tool call is refused).
* Both are things the model receives; a fingerprint that omitted them said two
* requests were the same when one of them would be rejected. They enter as a
* `stableJson` FINGERPRINT, so nothing quotable is added — a signature is an
* opaque token, not content.
*
* Still deliberately NOT covered: `injectedBy` and `notDispatched` (both
* stripped before the request exists) and `ephemeral` (a persistence flag,
* invisible to the model). Two messages that hash the same are the same thing
* said to the model.
*/
export function messageDigestInput(message) {
const calls = (message.toolCalls ?? []).map((call) => [
call.id,
call.name,
digestJson(call.args),
call.providerMeta === undefined ? '' : digestJson(call.providerMeta),
...(call.argumentsFailure === undefined ? [] : [digestJson(call.argumentsFailure)]),
].join(CALL_FIELD_SEP));
return [
message.role,
message.content,
message.toolCallId ?? '',
message.toolName ?? '',
calls.join(CALL_SEP),
message.thinkingBlocks === undefined || message.thinkingBlocks.length === 0
? ''
: digestJson(message.thinkingBlocks),
...(message.toolArgumentsFailure === undefined
? []
: [digestJson(message.toolArgumentsFailure)]),
].join(FIELD_SEP);
}
/**
* The system pieces a receipt records, off the injection records a slot
* committed — the SURVIVORS of the join, in the order they were joined.
*
* ONE OWNER, because there are now four mints (9.91.0): the agent's `call-llm`
* stage, `LLMCall`'s, and the two message-API charts'. Written out at each of
* them, this five-line map is four chances to disagree about which record was
* piece 2 — and `servedAt` proves piece 2 against the receipt's piece 2, so a
* disagreement here reads as a defect in the record.
*
* It calls `contributingPieces`, which is also what the join itself calls, so
* an empty record cannot be a piece on the receipt and a blank line in the
* string, or the other way round.
*
* @example
* ```ts
* receiptPieces([{ rawContent: 'You are a bot.', slot: 'systemPrompt', source: 'static' }]);
* // [{ text: 'You are a bot.', slot: 'systemPrompt', source: 'static' }]
* ```
*/
export function receiptPieces(records) {
return contributingPieces(records).map((record) => ({
text: record.rawContent,
slot: record.slot,
source: record.source,
}));
}
/**
* The sampling knobs off the request the port was handed.
*
* Duck-typed and value-conditional: an agent that set no dial records no key,
* so `params` is `{}` rather than a row of `undefined`s claiming defaults the
* library never sent.
*/
function paramsOf(request, runId) {
if (request === null || typeof request !== 'object')
return {};
const req = request;
const stop = Array.isArray(req.stop) ? req.stop.filter((s) => typeof s === 'string') : undefined;
return {
...(typeof req.temperature === 'number' && { temperature: req.temperature }),
...(typeof req.maxTokens === 'number' && { maxTokens: req.maxTokens }),
...(typeof req.thinking?.budget === 'number' && { thinkingBudget: req.thinking.budget }),
...(stop !== undefined && stop.length > 0 && { stop }),
...(typeof req.toolChoice?.type === 'string' && {
toolChoice: {
type: req.toolChoice.type,
...(typeof req.toolChoice.name === 'string' && { name: req.toolChoice.name }),
},
}),
...(typeof req.outputFormat?.type === 'string' && {
outputFormat: {
type: req.outputFormat.type,
schemaHash: receiptHash(runId, digestJson(req.outputFormat.schema)),
},
}),
};
}
/**
* The prefix a chained {@link Receipt.cache.transformHash} carries — the NAME of
* the scheme that produced it, inside the value, so a verifier can tell the two
* schemes apart without a second field:
*
* - `'chain-v1:<16 hex>'` — {@link transformHashOf}: each message of the
* returned request digested on its own and chained in order, then the rest
* of the request. Minted since this prefix existed.
* - a bare `<16 hex>` — the older WHOLE-REQUEST scheme,
* `receiptHash(runId, stableJson(request))`. Every receipt minted before the
* prefix carries this form, and it still verifies with that formula.
*
* WHY THE SCHEME CHANGED. The whole-request digest could not be extended: the
* strategy's markers sort to the front of the canonical JSON and move on every
* call, so call k's preimage shared no prefix with call k−1's and every byte of
* the history was hashed again — measured, the larger half of the receipt's
* SHA-256 input under a caching provider. A chain over per-message digests is
* extended by the messages a call added and nothing else.
*/
export const TRANSFORM_HASH_PREFIX = 'chain-v1:';
/** The first link of the transform chain — a constant, salted like every digest. */
const TRANSFORM_CHAIN_SEED = 'agentfootprint.transform.chain.v1';
/**
* The fingerprint a receipt records as `cache.transformHash` for the request a
* cache strategy handed back — recompute it from that request to verify a
* receipt's claim.
*
* The scheme, so a verifier in any language can follow it (every `H` below is
* {@link receiptHash} with the receipt's `basis.runId`):
*
* ```text
* link₀ = H("agentfootprint.transform.chain.v1")
* dᵢ = H(stableJson([messageᵢ])) — each message, as one array element
* linkᵢ = H(linkᵢ₋₁ + "\u001F" + dᵢ) — in request order
* head = stableJson(request without "messages") (the whole request when it
* carries no messages array)
* hash = "chain-v1:" + H(linkₙ + "\u001F" + head)
* ```
*
* A value `stableJson` cannot produce is recorded as {@link UNSERIALIZABLE} in
* its place, never as an empty string. A request that cannot be read at all (a
* property that throws when read) is `"chain-v1:" + H(link₀ + "\u001F" +
* UNSERIALIZABLE)` — the function never throws. A receipt minted before the
* prefix existed carries a bare 16-hex value: verify that one with
* `receiptHash(runId, stableJson(request))`.
*
* @example
* ```ts
* import { receiptAt, transformHashOf } from 'agentfootprint';
*
* const receipt = receiptAt(snapshot, 3)!;
* // `prepared` — the request your provider decorator saw at call 3
* transformHashOf(receipt.basis.runId, prepared) === receipt.cache.transformHash; // true
* ```
*/
export function transformHashOf(runId, request) {
return chainedTransformHash(runId, request, undefined);
}
/** {@link transformHashOf}, reading and filling a run's memo when one is given. */
function chainedTransformHash(runId, request, memo) {
const seed = hasherFor(runId, memo)(TRANSFORM_CHAIN_SEED);
let link = seed;
let head;
try {
for (const message of messagesOf(request)) {
const digest = elementDigestOf(message, runId, memo);
const previous = link;
const next = () => receiptHash(runId, `${previous}${SEP}${digest}`);
link = memo === undefined ? next() : memo.link(previous, digest, next);
}
head = stableJson(hasMessagesArray(request) ? headOf(request) : request);
}
catch {
// Unreadable (a property that throws when read): the seed and the mark,
// never a throw — a receipt is bookkeeping and must never stop a run.
link = seed;
head = undefined;
}
// The last hash is taken directly, never through the memo: its preimage
// holds the whole head and changes every call, so keeping it would only hold
// memory.
return `${TRANSFORM_HASH_PREFIX}${receiptHash(runId, `${link}${SEP}${head ?? UNSERIALIZABLE}`)}`;
}
/** A salted hash, read from the run's memo when there is one. */
function hasherFor(runId, memo) {
if (memo === undefined)
return (content) => receiptHash(runId, content);
return (content) => memo.hash(content, () => receiptHash(runId, content));
}
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function hasMessagesArray(request) {
return isRecord(request) && Array.isArray(request.messages);
}
function messagesOf(request) {
return hasMessagesArray(request) ? request.messages : [];
}
/** The request without its `messages` key — what the chain's last link covers. */
function headOf(request) {
if (!isRecord(request))
return request;
const { messages: _messages, ...head } = request;
void _messages;
return head;
}
/** `H(stableJson([value]) ?? UNSERIALIZABLE)` — one message's link in the
* transform chain — read from the run's memo for a message OBJECT it has
* already digested. */
function elementDigestOf(value, runId, memo) {
const compute = () => receiptHash(runId, stableJson([value]) ?? UNSERIALIZABLE);
if (memo === undefined || value === null || typeof value !== 'object')
return compute();
const digests = memo.message(value);
if (digests.elementDigest === undefined)
digests.elementDigest = compute();
return digests.elementDigest;
}
/** Does `stableJson([value])` produce a value? Read from the run's memo for a
* message OBJECT it has already checked. */
function serializableElement(value, memo) {
if (memo === undefined || value === null || typeof value !== 'object') {
return stableJson([value]) !== undefined;
}
const digests = memo.message(value);
if (digests.serializable === undefined) {
digests.serializable = stableJson([value]) !== undefined;
}
return digests.serializable;
}
/**
* The cache verdict, three-valued, exactly as comparing `stableJson(base)` with
* `stableJson(prepared)` decides it — `'unknown'` when either cannot be
* serialized, never `'unchanged'`.
*
* The SHORT PATH, taken when both requests carry the SAME messages array (what
* every built-in strategy returns: a spread of the request with a marker list
* added, or the request itself): the two canonical strings then share their
* `"messages"` part byte for byte, so they are equal exactly when the rest of
* the two requests is — and that rest is the small part (system prompt, tools,
* dials, markers). The messages still decide `'unknown'`: each one's
* serializability is read from the run's memo, computed once per message.
* Any other pair — a strategy that rebuilt the messages, a mint with no memo, a
* request the short path cannot read — takes the whole comparison, as before.
*/
function transformVerdict(base, prepared, memo) {
try {
if (memo !== undefined &&
hasMessagesArray(base) &&
hasMessagesArray(prepared) &&
base.messages === prepared.messages) {
const restOfBase = stableJson(headOf(base));
const restOfPrepared = base === prepared ? restOfBase : stableJson(headOf(prepared));
if (restOfBase === undefined || restOfPrepared === undefined)
return 'unknown';
for (const message of base.messages) {
if (!serializableElement(message, memo))
return 'unknown';
}
return restOfBase === restOfPrepared ? 'unchanged' : 'rewritten';
}
}
catch {
// A request the short path cannot read (a property that throws when read)
// takes the whole comparison, which `stableJson` keeps total.
}
const whole = stableJson(base);
const returned = stableJson(prepared);
if (whole === undefined || returned === undefined)
return 'unknown';
return whole === returned ? 'unchanged' : 'rewritten';
}
/** `hash(messageDigestInput(message))`, read from the run's memo for a message
* object it has already seen. */
function entryHashOf(message, runId, memo) {
const compute = () => receiptHash(runId, messageDigestInput(message));
if (memo === undefined || message === null || typeof message !== 'object')
return compute();
const digests = memo.message(message);
if (digests.entryHash === undefined)
digests.entryHash = compute();
return digests.entryHash;
}
/**
* Mint the receipt for one composed request.
*
* Pure and total: every branch here is arithmetic over what it was handed, so
* a receipt cannot fail a call. The one judgement it makes is the cache
* comparison, and it makes it in three values rather than two: `'unchanged'`
* exactly when the strategy handed back a request that serializes identically
* to the one it was given, `'rewritten'` when it did not, and `'unknown'` when
* either request could not be serialized at all — because "I could not read
* them" is not the same claim as "they were the same".
*
* Every value it produces is true AT THE PROVIDER PORT and nowhere past it —
* see {@link RECEIPT_BOUNDARY}.
*
* `digests` is the run's memo (`receiptDigests.ts`): a loop that mints a
* receipt per call passes the same one to every call, and each message is then
* hashed and measured once per run instead of once per call. It changes no
* byte of the receipt — only how much work produces it.
*/
export function buildReceipt(input, digests) {
const memo = digests?.forRun(input.runId);
const hash = hasherFor(input.runId, memo);
const schemaHashes = {};
for (const tool of input.tools)
schemaHashes[tool.name] = hash(toolDigestInput(tool));
// The cache verdict, three-valued. A pair the receipt could not read is
// 'unknown' — never 'unchanged', which is what `''` used to make it.
const transform = transformVerdict(input.baseRequest, input.preparedRequest, memo);
return {
requestMeasurement: measureRequest(input.preparedRequest, memo),
system: {
hash: hash(input.systemText),
chars: input.systemText.length,
pieces: input.systemPieces.map((piece) => ({
hash: hash(piece.text),
slot: piece.slot,
source: piece.source,
})),
},
messages: {
count: input.messages.length + input.requestOnly.length,
entries: input.messages.map((message) => ({
role: message.role,
hash: entryHashOf(message, input.runId, memo),
...(message.toolCallId !== undefined && { key: message.toolCallId }),
})),
requestOnly: input.requestOnly.map((line) => ({
role: line.message.role,
hash: entryHashOf(line.message, input.runId, memo),
reason: line.reason,
})),
},
tools: {
names: input.tools.map((tool) => tool.name),
schemaHashes,
forced: input.forced,
withheld: input.withheld,
},
cache: {
transform,
transformHash: transform === 'rewritten'
? chainedTransformHash(input.runId, input.preparedRequest, memo)
: null,
markersApplied: (input.markersApplied ?? []).map((marker) => ({
field: marker.field,
boundaryIndex: marker.boundaryIndex,
ttl: marker.ttl,
})),
strategy: input.strategy,
},
// The PREPARED request, not the base one: the port is handed what the cache
// strategy returned, and a strategy that rewrote maxTokens must not leave
// five receipt fields describing a request nobody sent. See
// {@link RECEIPT_BOUNDARY} — this is the boundary those fields are true at.
params: paramsOf(input.preparedRequest, input.runId),
// Value-conditional, like a dial nobody set: a call nothing was dropped
// before writes no key. Each hash is the turn's own digest, so it pairs
// with `messages.entries[].hash` on the receipt that last served it.
...(input.omittedForAttention !== undefined &&
input.omittedForAttention.length > 0 && {
omittedForAttention: {
count: input.omittedForAttention.length,
hashes: input.omittedForAttention.map((turn) => entryHashOf(turn, input.runId, memo)),
},
}),
basis: {
epoch: input.epoch,
runId: input.runId,
model: input.model,
provider: input.provider,
},
};
}
//# sourceMappingURL=receipt.js.map