UNPKG

agentfootprint

Version:

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

652 lines • 33.7 kB
"use strict"; /** * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.buildReceipt = exports.transformHashOf = exports.TRANSFORM_HASH_PREFIX = exports.receiptPieces = exports.messageDigestInput = exports.toolDigestInput = exports.receiptHash = exports.RECEIPT_HASH_CHARS = exports.FORCED_OUTPUT_TOOL_KEY = exports.RECEIPT_BOUNDARY = exports.UNSERIALIZABLE = void 0; const composeRequest_js_1 = require("../composeRequest.js"); const sha256_js_1 = require("../../../lib/sha256.js"); const stableJson_js_1 = require("../../../lib/stableJson.js"); const requestMeasurement_js_1 = require("./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. */ exports.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}`); * } * ``` */ exports.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. */ exports.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. */ exports.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 * ``` */ function receiptHash(runId, content) { return (0, sha256_js_1.sha256Hex)(`${runId}${SEP}${content}`).slice(0, exports.RECEIPT_HASH_CHARS); } exports.receiptHash = receiptHash; /** `stableJson`, with the unreadable marked rather than dropped — the form a * digest input takes. */ function digestJson(value) { return (0, stableJson_js_1.stableJson)(value) ?? exports.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. * ``` */ function toolDigestInput(tool) { return digestJson(tool); } exports.toolDigestInput = toolDigestInput; /** * 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. */ 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); } exports.messageDigestInput = messageDigestInput; /** * 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' }] * ``` */ function receiptPieces(records) { return (0, composeRequest_js_1.contributingPieces)(records).map((record) => ({ text: record.rawContent, slot: record.slot, source: record.source, })); } exports.receiptPieces = receiptPieces; /** * 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. */ exports.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 * ``` */ function transformHashOf(runId, request) { return chainedTransformHash(runId, request, undefined); } exports.transformHashOf = transformHashOf; /** {@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 = (0, stableJson_js_1.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 `${exports.TRANSFORM_HASH_PREFIX}${receiptHash(runId, `${link}${SEP}${head ?? exports.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, (0, stableJson_js_1.stableJson)([value]) ?? exports.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 (0, stableJson_js_1.stableJson)([value]) !== undefined; } const digests = memo.message(value); if (digests.serializable === undefined) { digests.serializable = (0, stableJson_js_1.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 = (0, stableJson_js_1.stableJson)(headOf(base)); const restOfPrepared = base === prepared ? restOfBase : (0, stableJson_js_1.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 = (0, stableJson_js_1.stableJson)(base); const returned = (0, stableJson_js_1.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. */ 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: (0, requestMeasurement_js_1.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, }, }; } exports.buildReceipt = buildReceipt; //# sourceMappingURL=receipt.js.map