UNPKG

agentfootprint

Version:

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

686 lines (685 loc) • 36 kB
/** * 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 type { LLMMessage, LLMToolSchema } from '../../../provider-contract/types.js'; import type { ContextRole, ContextSlot, ContextSource } from '../../../events/types.js'; import { type RequestMeasurement } from './requestMeasurement.js'; import type { ReceiptDigests } from './receiptDigests.js'; export type { RequestMeasurement, RequestJsonSize } from './requestMeasurement.js'; /** What {@link receiptPieces} needs from one committed injection record — the * three fields a receipt keeps of a system piece, and none of the rest. */ export interface SystemPieceRecord { readonly rawContent?: string; readonly slot: ContextSlot; readonly source: ContextSource; } /** * 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 declare 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 declare const RECEIPT_BOUNDARY: string; /** 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 declare 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 declare const RECEIPT_HASH_CHARS = 16; /** One piece of the composed system string. */ export interface ReceiptPiece { readonly hash: string; readonly slot: ContextSlot; readonly source: ContextSource; } /** One message as it went out. */ export interface ReceiptMessage { readonly role: ContextRole; readonly hash: string; /** The message's own join key when it has one — a tool result's * `toolCallId`. Absent for every other message. */ readonly key?: string; } /** A line that existed on the request only and was never written to history. */ export interface ReceiptRequestOnlyMessage { readonly role: ContextRole; readonly hash: string; /** Which library mechanism composed it. `'staged-refs-nudge'` or `'time-window-line'` (step T6b). */ readonly reason: string; } /** * One `cache_control` breakpoint the cache strategy actually APPLIED — three * scalars, no bytes. * * `cache.transformHash` collapses a whole rewrite into one bit ("something * changed"), and a digest over the whole prepared request is not comparable * across epochs. The question that decides an Anthropic bill is narrower and * concrete: *did the breakpoints move between call 3 and call 4?* Two receipts' * `markersApplied` answer it by inspection. * * APPLIED, not offered: `scope.cacheMarkers` holds the CANDIDATES the strategy * was given, and a strategy clamps them to what the provider allows. The * candidates are on the record; which survived is not, which is why they ride * here. * * @example did the breakpoints move between two turns? * ```ts * import { receiptAt } from 'agentfootprint'; * * const at = (k: number) => * JSON.stringify(receiptAt(agent.getSnapshot()!, k)?.cache.markersApplied); * at(3) === at(4); // false ⇒ the cached prefix moved, and the bill with it * ``` */ export interface ReceiptCacheMarker { readonly field: 'system' | 'tools' | 'messages'; readonly boundaryIndex: number; readonly ttl: 'short' | 'long'; } /** * The sampling knobs the call went out with — scalars and short strings, no * bytes, no privacy change. * * They decide the answer as surely as the prompt does: the same context at * `temperature: 0` and at `1.2` is a different call, and "why did this turn * ramble?" is unanswerable from a record that kept the prompt and dropped the * dial. * * READ OFF THE PREPARED REQUEST — the object the port is handed, after the * cache strategy has had it. This is the one part of a receipt that describes * the post-strategy request rather than the pre-strategy one, and deliberately * so: a strategy holds the whole composed request and can move a dial, and * `params` claims in this very docstring to be what the PORT got. * `time-travel/servedView.ts` · `SERVED_GAPS` says the same from the other side — * `cache-transform` names every field the log rebuilds pre-strategy, and * `params` is not among them. * * PORT VALUES ONLY — see {@link RECEIPT_BOUNDARY}. An unset `maxTokens` may * still become the model's own maximum inside a vendor SDK, and the receipt * records that the library sent nothing, never what the vendor decided. * * @example compare the dial two turns went out on * ```ts * import { receiptAt } from 'agentfootprint'; * * const snapshot = agent.getSnapshot()!; * receiptAt(snapshot, 1)?.params.temperature; // 0.25 * receiptAt(snapshot, 2)?.params.temperature; // 0.25 — the dial did not move * receiptAt(snapshot, 1)?.params.stop; // undefined: none was sent * ``` */ export interface ReceiptParams { readonly temperature?: number; readonly maxTokens?: number; /** `LLMRequest.thinking.budget` — the reasoning-token ceiling asked for. */ readonly thinkingBudget?: number; readonly stop?: readonly string[]; /** The forced tool choice, as the port carried it. */ readonly toolChoice?: { readonly type: string; readonly name?: string; }; /** * The structured-output format the port carried (`LLMRequest.outputFormat`, * `.outputSchema(s, { strategy: 'native' })`): its `type`, and the * run-salted hash of its schema as sorted-key JSON — so a reader can tell * the answer was decoded under a schema, and which one, without the bytes. * Absent when the request carried none. */ readonly outputFormat?: { readonly type: string; readonly schemaHash: string; }; } /** What a budget dropped before the request was composed — the window's * evictions at this iteration's head. */ export interface ReceiptAttentionOmission { readonly count: number; /** One hash per dropped turn — the turn's own `messages.entries[].hash`, so * it pairs with the receipt that last served it. See the first law for why * not the text. */ readonly hashes: readonly string[]; } /** * THE RECEIPT. One per composed request, committed at the call-llm stop. * * @example * ```ts * import { receiptAt } from 'agentfootprint'; * * // Epochs are the run's OWN iteration numbers and start at 1. * const receipt = receiptAt(agent.getSnapshot()!, 1); * receipt?.tools.withheld; // 'wrap-up' on the out-of-budget call * receipt?.messages.count; // how many turns went out * receipt?.params.temperature; // the dial this turn went out on * ``` */ export interface Receipt { /** * Actual leaf-provider preparation omitted thinking after this port receipt * was minted. Original hashes/measurement/cache verdict remain port-level; * this is an explicit downstream gap, not proof of what a server accepted. */ readonly replayPreparations?: readonly { readonly boundary: 'provider-adapter'; readonly phase: 'prepared'; readonly omittedBlocks: number; }[]; /** Counts of the initial prepared canonical request, including full schemas. * Older recordings have no measurement. No payload, tokens or retry totals. */ readonly requestMeasurement?: RequestMeasurement; readonly system: { readonly hash: string; readonly chars: number; readonly pieces: readonly ReceiptPiece[]; }; readonly messages: { readonly count: number; readonly entries: readonly ReceiptMessage[]; readonly requestOnly: readonly ReceiptRequestOnlyMessage[]; }; readonly tools: { readonly names: readonly string[]; readonly schemaHashes: Readonly<Record<string, string>>; /** The tool the model was forced to answer through, or `null`. */ readonly forced: string | null; /** Why the tool list is empty when it would not otherwise be. */ readonly withheld: 'wrap-up' | 'evidence-revision' | null; }; readonly cache: { /** * What the comparison between the request handed TO the cache strategy and * the one it handed back could establish. BRANCH ON THIS, never on * `transformHash === null`: * * - `'unchanged'` — the two serialize identically. * - `'rewritten'` — they do not, and `transformHash` fingerprints the * result. * - `'unknown'` — one of them could not be serialized at all, so the * receipt refuses to claim either. * * It scopes to the CACHE STRATEGY and to nothing else — see * {@link RECEIPT_BOUNDARY}. */ readonly transform: 'unchanged' | 'rewritten' | 'unknown'; /** * Fingerprint of the request the cache strategy handed back, when it * differed from the one it was given; `null` otherwise. Recompute it with * {@link transformHashOf}. The value names its scheme: `'chain-v1:…'` * (see {@link TRANSFORM_HASH_PREFIX}), or a bare 16-hex value on a receipt * minted before the prefix existed, which verifies as * `receiptHash(runId, stableJson(request))`. */ readonly transformHash: string | null; /** The breakpoints the strategy actually applied, in the order it applied * them — see {@link ReceiptCacheMarker}. Empty when it applied none. */ readonly markersApplied: readonly ReceiptCacheMarker[]; /** * WHICH strategy stood between assembly and the port, or `null` when none * did (9.93.0). The value is the strategy's `name`: for the built-ins, * the caching the provider declared (`'breakpoints'`, `'automatic'`, * `'none'`); older receipts carry the old registry key (`'anthropic'`, * `'openai'`, `'*'`). * * `null` is a FACT, not an absence: `LLMCall` and the two message-API * charts hand the port the request assembly built, with nothing in * between, and say so here. `transform` alone could not — `'unchanged'` is * the honest verdict both when a strategy returned what it was given and * when there was no strategy to return anything — which is why the served * view raised `cache-transform` on every view until this field existed. * Now it raises that gap only where a strategy could have rewritten the * request: where this is not `null`, or where no receipt can say. * * A receipt minted before 9.93.0 has no key here; a reader treats that as * "cannot say", never as `null` — {@link StoredReceipt} is the shape that * says so, and the one every reader is handed. */ readonly strategy: string | null; }; /** The sampling knobs the call went out with — see {@link ReceiptParams}. */ readonly params: ReceiptParams; /** * What left the window for budget at this iteration's head, before this * request was composed — one hash per evicted turn, each the turn's own * `messages.entries[].hash` as an earlier receipt served it. * * Absent when nothing was dropped before this call. Since 9.93.0 that is a * claim and not a shrug: the agent chart's window stage is the one shipped * mechanism that drops for attention and it files every eviction here; the * built-in slots drop nothing; `LLMCall` and the message-API charts have no * window. It is named by the `no-receipt-on-chart` entry of `time-travel/servedView.ts` · * `SERVED_GAPS` — the one gap that can lose it, by losing the receipt — and it sat in * `UNGAPPED_FIELDS` until this release, when its absence was universal and * meant only that nobody had recorded a drop. */ readonly omittedForAttention?: ReceiptAttentionOmission; readonly basis: { readonly epoch: number; readonly runId: string; readonly model: string; readonly provider: string; }; } /** * A receipt as a READER meets it — what `receiptAt` hands back, and the shape * every rebuild reads through (9.94.1). * * {@link Receipt} is what a MINT writes, and a mint writes every container it * knows. But a recording is older than the reader that opens it: what a * reader is handed is what the MINTING release knew to write, and that has * grown — `cache` came with the receipt in 9.88.0 and gained `strategy` in * 9.93.0; `omittedForAttention` has always been written only when something * was dropped. The narrowing that admits a stored value (`time-travel/servedView.ts` · * `readReceipt`) checks the basis and nothing past it, so this type says what * that check leaves open, and the compiler — not a throw on an older * recording — is what meets a reader that forgets. * * THE VINTAGE LAW: a reader reads the receipt it is handed; a missing * container is a fact about the vintage, never a throw. Absence reads as * "cannot say" — `servedAt` raises `cache-transform` on a receipt with no * `cache.strategy` exactly as it does with no receipt at all — never as * `null`, and never as a fabricated `cache: {}`. The record is handed back as * it was stored. * * @example a recording minted by 9.92, read by this release * ```ts * const receipt = receiptAt(olderRecording, 1)!; * receipt.cache?.strategy; // undefined — the mint predates the key * servedAt(olderRecording, 1)!.gaps.map((g) => g.gap); // includes 'cache-transform': it cannot say * ``` */ export type StoredReceipt = Omit<Receipt, 'cache'> & { readonly cache?: Omit<Receipt['cache'], 'strategy'> & { readonly strategy?: Receipt['cache']['strategy']; }; }; /** * 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 declare function receiptHash(runId: string, content: string): string; /** * 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 declare function toolDigestInput(tool: LLMToolSchema): string; /** * 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 declare function messageDigestInput(message: LLMMessage): string; /** What `buildReceipt` needs from the stage that composed the request. */ export interface BuildReceiptInput { readonly runId: string; readonly epoch: number; readonly model: string; readonly provider: string; /** The joined system string, exactly as sent. */ readonly systemText: string; /** The pieces it was joined from, in order. */ readonly systemPieces: readonly { readonly text: string; readonly slot: ContextSlot; readonly source: ContextSource; }[]; /** The messages that came from history, post-strip, in wire order. */ readonly messages: readonly LLMMessage[]; /** Lines appended to this request only, each with the mechanism that * composed it. */ readonly requestOnly: readonly { readonly message: LLMMessage; readonly reason: string; }[]; /** The tool list as sent — including a forced output tool, if there is one. */ readonly tools: readonly LLMToolSchema[]; readonly forced: string | null; readonly withheld: 'wrap-up' | 'evidence-revision' | null; /** The request handed TO the cache strategy — one half of the cache * comparison, and nothing else is read off it. */ readonly baseRequest: unknown; /** * The request it handed back: the object the provider port is given. * * The other half of the cache comparison, AND where {@link ReceiptParams} is * read from. A strategy is handed the whole composed request, so it can move * a dial as easily as a marker; reading the knobs off `baseRequest` recorded * what assembly PROPOSED and called it what the port was handed, which is * the one thing `RECEIPT_BOUNDARY` promises a receipt never does. */ readonly preparedRequest: unknown; /** The breakpoints the strategy reported applying (`prepareRequest`'s * `markersApplied`). Absent ⇒ recorded as none. */ readonly markersApplied?: readonly { readonly field: 'system' | 'tools' | 'messages'; readonly boundaryIndex: number; readonly ttl: 'short' | 'long'; }[]; /** The `name` of the strategy `preparedRequest` came back from, or * `null` when no strategy stood between assembly and the port — see * {@link Receipt.cache.strategy}. A mint must say which; it may not omit. */ readonly strategy: string | null; /** The turns a budget dropped before this request was composed — the * window's evictions at this iteration's head. Absent or empty ⇒ no key on * the receipt. */ readonly omittedForAttention?: readonly LLMMessage[]; } /** * 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 declare function receiptPieces(records: readonly SystemPieceRecord[]): BuildReceiptInput['systemPieces']; /** * 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 declare const TRANSFORM_HASH_PREFIX = "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 declare function transformHashOf(runId: string, request: unknown): string; /** * 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 declare function buildReceipt(input: BuildReceiptInput, digests?: ReceiptDigests): Receipt;