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