agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
986 lines • 57.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.receiptAt = exports.servedViews = exports.servedAt = exports.UNGAPPED_FIELDS = exports.SERVED_GAPS = void 0;
const context_js_1 = require("../core/agent/findings/context.js");
const composeRequest_js_1 = require("../core/agent/composeRequest.js");
const stagedRefs_js_1 = require("../core/agent/stagedRefs.js");
const recovery_js_1 = require("../core/agent/evidence/recovery.js");
const serve_js_1 = require("../core/agent/findings/serve.js");
const serve_js_2 = require("../ontology/serve.js");
const epochs_js_1 = require("./epochs.js");
const marker_js_1 = require("../redaction/marker.js");
/**
* The commit log's redaction placeholder (footprintjs writes it where a run's
* policy selected a key — `Agent.create({ redact })`). To this rebuild it is NO
* value: never a system piece, a tool list or a tool name the view could print
* as if the request had carried it. Read as absent, which is what the record
* can show. The view states the cost through the gap a redacted snapshot always
* carries: it travels without its fold base (footprintjs omits `initialState`
* from the served view), so `no-fold-base` — "unproven and may be SHORT … reads
* as empty rather than as unknown" — is on every view of it.
*
* Read so only when the record says a policy covered the run — the snapshot's
* marker (`redaction/marker.ts` · `servedUnderPolicy`), asked once per call of
* `servedAt` / `servedViews` — so a value that merely spells the word (a tool
* NAMED `REDACTED`) is a value on a run no policy covered, as the answer
* account and `assessAnswer` read it.
*/
const LOG_PLACEHOLDER = 'REDACTED';
/** {@link foldAtCall}, with the placeholder read as absent on a `served` record. */
function readAtCall(location, key, served) {
const value = (0, epochs_js_1.readAtCall)(location, key);
return served && value === LOG_PLACEHOLDER ? undefined : value;
}
/** {@link foldRunConstant}, with the placeholder read as absent on a `served` record. */
function readRunConstant(location, key, served) {
const value = (0, epochs_js_1.readRunConstant)(location, key);
return served && value === LOG_PLACEHOLDER ? undefined : value;
}
const receipt_js_1 = require("../core/agent/receipt/receipt.js");
const conventions_js_1 = require("../lib/conventions.js");
/**
* The gap catalogue. Exported because a reader that renders a served view
* renders its gaps beside it, and a renderer should print the library's own
* sentence rather than invent one.
*/
exports.SERVED_GAPS = Object.freeze({
'thinking-replay': Object.freeze({
// The leaf reports preparation, not successful transport. Port hashes
// remain valid; they must not be presented as the downstream wire body.
fields: Object.freeze(['messages.entries', 'requestMeasurement']),
why: 'Saved thinking was omitted after the request shown here was prepared. The messages and size shown are before that omission, not proof of what was sent or accepted.',
}),
'cache-transform': Object.freeze({
// MECHANISM (not printed — see the header's rule). A `CacheStrategy`'s
// `prepareRequest` is handed the whole composed request after assembly and
// hands one back; `buildReceipt` fingerprints the one it was GIVEN. So the
// field list is everything a rewrite could have touched, not only the
// `cache.*` fields that DESCRIBE the rewrite — listing those alone named
// the report and excused nothing it reports on.
//
// THE RULE FOR THIS LIST is the request the strategy holds: `systemPrompt`
// (→ `system.*`), `messages` (→ `messages.*`), `tools` and `toolChoice`
// (→ every `tools.*` field). `tools.forced` and `tools.withheld` were
// missing until 9.93.0 (`recorded-not-built.md`, entry 7): `callLLM.ts`
// writes both from assembly's own decision — `deps.schemaTool?.name`,
// `scope.wrapUpAsked` — and never from `preparedRequest`, so a strategy
// that dropped the answer tool from `request.tools` or emptied the list
// would leave a receipt whose `forced`/`withheld` describe a request the
// port was not handed. `params` is the one field deliberately NOT here:
// it is read off `preparedRequest`, past the strategy.
//
// RAISED WHERE A STRATEGY COULD HAVE REWRITTEN (9.93.0, entry 8). Until
// this release `viewOf` pushed it unconditionally — including on charts
// that run no strategy at all — because `cache.transform: 'unchanged'`
// could not tell "a strategy returned what it was given" from "there was
// no strategy", and inferring the second from any other committed key is
// the absence-of-evidence reading this whole file refuses. The receipt
// now carries the fact (`cache.strategy`, `null` when none ran), so the
// condition is: raised when the receipt names a strategy, OR when there is
// no receipt to say — a pre-9.93.0 receipt, a receipt-less chart, a
// refused shape. Absent only where the record SAYS nothing stood between
// assembly and the port.
//
// THE DAY-ONE FALSEHOOD THIS ENTRY SHIPPED WITH, killed in 9.88.0's sixth
// round: the printed sentence said "only its INPUTS are on the record".
// Three of its own fields are OUTPUTS and are on the record —
// `cache.transform` (the verdict of comparing given against returned),
// `cache.transformHash` (the digest of what came back when it differed)
// and `cache.markersApplied` (the breakpoints the strategy actually
// applied, as against the candidates in `scope.cacheMarkers`, which are
// the inputs). A sentence describing the mechanism went false about the
// mechanism; the sentence below describes only what the fields MEAN.
//
// This entry is the weakest claim on the list wherever it appears: it says
// the rebuild stops before whatever the request met next, never that the
// rebuild got that far. Whether it did is what the other gaps on the same
// view say — see the qualifier on `ServedGap.fields`.
//
// AND BEING RAISED ON RECEIPT-LESS VIEWS IS WHY IT DOES NOT QUOTE
// `RECEIPT_BOUNDARY` (9.88.0, seventh round). That sentence opens "A
// receipt describes the request as this library last saw it" — and this
// entry is still printed on views that have no receipt at all. Measured: a
// message-API chart run with no run id carries exactly
// `['no-receipt-on-chart', 'cache-transform']`, so the reader was told what
// a receipt describes beside a view that has none. The boundary CLAIM is
// not lost — the first sentence below is that claim in the vocabulary of a
// view, and it is true whether or not a receipt exists. The quote stays on
// `provider-defaults`, which fires only where a receipt was read.
fields: Object.freeze([
'cache.transform',
'cache.transformHash',
'cache.markersApplied',
'cache.strategy',
'system.hash',
'system.chars',
'system.pieces',
'messages.count',
'messages.entries',
'messages.requestOnly',
'tools.names',
'tools.schemaHashes',
'tools.forced',
'tools.withheld',
]),
why: 'What reached the provider may differ from the fields below, and nothing on this view ' +
'would show it. Where another gap on this view covers one of them, that gap is the ' +
'stronger claim.',
}),
'forced-tool-schema': Object.freeze({
// MECHANISM (not printed). Under a `'tool-forced'` output strategy the
// synthetic answer tool is added at assembly from a build-time schema that
// no stage commits, so `tools.schemaHashes` on the receipt carries a row
// the rebuild has no counterpart for. The tool's NAME is a run constant
// (`FORCED_OUTPUT_TOOL_KEY`, written by `stages/seed.ts`), which is why
// `tools.names` and `tools.forced` rebuild and the schema body does not.
fields: Object.freeze(['tools.schemaHashes']),
why: 'The schema body behind the forced answer tool is not on this view. Its name is, so the ' +
'tool list is complete and the schemas beside it are one short.',
}),
'provider-defaults': Object.freeze({
// MECHANISM (not printed). `buildReceipt` reads `params` off the PREPARED
// request — the object `LLMProvider.complete` is handed, after any cache
// strategy has had it. Like `requestMeasurement`, it is read past the
// strategy, and `cache-transform` deliberately does not name it. What is
// still past the record is the vendor: an adapter or SDK can resolve a
// final value the port never saw. `params` is also VALUE-CONDITIONAL —
// `buildReceipt` writes no key for a dial nobody set — which is the whole
// reason the printed sentence has to say what an absent key means.
//
// THE ONE ENTRY THAT QUOTES `RECEIPT_BOUNDARY`, and it is the only one that
// honestly can: it is pushed inside `if (receipt !== undefined)`, so a view
// carrying it always has a receipt for that sentence to be about.
// `cache-transform` quoted it too until 9.88.0's seventh round, and
// `cache-transform` is raised on the receipt-less views — where the reader
// was told what a receipt describes beside a view that has none. Measured:
// a message-API chart run with no run id carries `no-receipt-on-chart` and
// `cache-transform`, and never this entry.
fields: Object.freeze(['params']),
why: 'A dial absent below was not recorded; that is not the same as the model running ' +
'without one. ' +
receipt_js_1.RECEIPT_BOUNDARY,
}),
'no-fold-base': Object.freeze({
// MECHANISM (not printed). Raised when `location.basis` or
// `location.runBasis` is `'log-only'` — the recording travelled without the
// `initialState` the fold folds against (`getSnapshot({ redact: true })`
// hands back exactly that shape: the log travels, the base does not), so
// every read can see only what the log itself wrote. A rebuild that
// recovered one turn of three reports one; a system prompt it could not
// read at all is 0 characters rather than unknown; and `EpochLocation.epoch`
// falls back to POSITION when it cannot read the run's committed
// `iteration`, which is how a view can call the second turn the first while
// that turn's own receipt still says 2.
//
// TWO bases, so every field the rebuild reads through a fold. The epoch's
// own log rebuilds the system prompt, the conversation, the tool schemas
// and the wrap-up flag; the RUN's log holds the run constants the forced
// tool name and the staged-refs nudge are composed from. Under
// 'dynamic-grouped' those are different logs with different bases, and a
// gap raised off only the first denied the second.
//
// THE RULE FOR THIS LIST is mechanical, so it does not go short again: a
// field belongs here when the rebuild derives it from `readAtCall` or
// `readRunConstant` — both fold over values the log itself may never have
// written, so both read absent when the base did not travel. `readAfterCall`
// is the THIRD reader and is deliberately NOT on the rule: the only thing
// read through it is the receipt, which the call's own bundle commits, so it
// is there whatever the base. Naming it made the rule read wider than the
// mechanism and put the receipt's own fields inside a gap that cannot touch
// them. `system.chars` and `messages.count` were missed on the first two
// passes because they are COUNTS of things the list already named, and a
// short rebuild makes a count wrong exactly as it makes the thing wrong.
//
// `epoch` — THE VIEW'S OWN NUMBER — is here for a fourth reason, in
// `EpochLocation.epoch`'s own words: a fold that cannot read `iteration`
// numbers the epoch by its POSITION instead. Measured on a resumed run with
// its base and its `iteration` writes gone, the rebuild called the second
// turn epoch 1 while that turn's own receipt still said 2. The RECEIPT's
// `basis.epoch` is NOT here and used to be: it is minted live and rides in
// the call's bundle, so no missing base can move it. That inversion — the
// list naming the number a missing base cannot touch, while the number it
// fabricates was excused as ungappable — is what 9.88.0's fourth review
// round found.
//
// THE PRINTED CLAUSE STOPPED NAMING THE RECEIPT (seventh round). It read
// "may differ from the one the receipt for this turn carries", and this
// gap is raised on views that have no receipt to carry anything — a
// base-less recording of a chart that minted none gets both gaps at once. The fact a reader
// needs is about the NUMBER, not about the witness: it may be the turn's
// place in run order rather than the count the run kept. Measured on a
// resumed run with its base and its `iteration` writes gone, the view calls
// the second turn the first while that turn's own receipt still says 2.
fields: Object.freeze([
'system.hash',
'system.chars',
'system.pieces',
'messages.count',
'messages.entries',
'messages.requestOnly',
'tools.schemaHashes',
'tools.names',
'tools.forced',
'tools.withheld',
'epoch',
]),
why: 'The fields below are unproven and may be SHORT: a count can be lower than what really ' +
'went out, and a value that could not be recovered reads as empty rather than as ' +
"unknown. The turn number below may be this turn's place in run order rather than the " +
'number the run itself gave it.',
}),
'no-conversation-on-record': Object.freeze({
// MECHANISM (not printed). `viewOf` reads the conversation from `history`
// (the agent charts) or, failing that, from `messagesInjections` (`LLMCall`
// and the message-API charts, which have no history at all). Neither
// committed ⇒ `conversation` is `undefined` and `asSent` is `[]`, which is
// indistinguishable from a call that really sent nothing — hence the gap.
//
// `messages.requestOnly` is on the list for a second reason: the
// staged-refs nudge is recomposed FROM the conversation by `findStagedRefs`,
// so a rebuild with no conversation finds no refs and reports no nudge,
// which looks exactly like a call that had none.
fields: Object.freeze(['messages.count', 'messages.entries', 'messages.requestOnly']),
why: 'The turns that went out are unknown, not empty: an empty list here is the absence of a ' +
'record, never a record of absence. The request-only lines are unproved with them.',
}),
'no-receipt-on-chart': Object.freeze({
// Exactly the fields ONLY a receipt carries — the ones no rebuild produces,
// so with no receipt they are absent rather than short. Everything else on
// the view is folded from committed pieces as usual; what it loses is not
// the value but the WITNESS, and the sentence below says so, because a
// field list cannot.
//
// MECHANISM (not printed). Raised when `readReceipt` returns no receipt —
// either nothing was committed under `RECEIPT_KEY` (a recording made before
// the receipt existed, a run with `recordReceipt: false`, a message-API
// chart handed no run id to salt with, or a consumer's own `call-llm`
// stage, which this library does not mint for) or something WAS committed
// there and was refused for carrying no basis. Every chart in this library
// that CAN salt its hashes mints since 9.91.0.
// Which of the two is {@link ServedGap.cause}, computed at that read.
// Everything else on the view is still folded from committed pieces; what
// it loses is the WITNESS, not the value.
//
// THE DAY-ONE FALSEHOOD THIS ENTRY SHIPPED WITH, killed in 9.88.0's sixth
// round: the printed sentence opened "No receipt was found for this epoch"
// and closed "absent here means unrecorded". Both are false under
// `'receipt-shape-rejected'`, where a value WAS found and WAS recorded and
// the read refused it. The sentence asserted a cause; the cause is a field.
// The sentence below says only what the fields mean, which is the same
// under either cause.
//
// TWO CHANGES in 9.88.0's fourth review round, both of them the same test:
// does this gap's MECHANISM cause this field's absence?
// • `basis.epoch` JOINED the list. It is a receipt-only field like the
// other three on `basis`, and with no receipt it is gone with them. It
// used to be named by `no-fold-base` alone, which cannot touch it.
// • `omittedForAttention` LEFT it. It was absent on EVERY view, receipt
// or not — no chart IN THIS LIBRARY supplied it — so a gap about the
// missing receipt was explaining an absence it does not cause, and on a
// view that HAS a receipt nothing explained it at all. It went to
// `UNGAPPED_FIELDS` with the reason that was true of it then.
// • …AND CAME BACK in 9.93.0, because the measurement behind that reason
// was incomplete: the agent chart's WINDOW STAGE drops turns for budget
// on every run whose strategy engages, and since 9.93.0 it hands them
// to the mint (`window/evictedTurns.ts`). The field is value-
// conditional — absent when nothing was dropped before the call — and
// the one thing that can lose a recorded drop is losing the receipt,
// which is this gap. `cache.strategy` joined for the same reason: a
// receipt-only fact, gone with the receipt.
// `requestMeasurement` is also receipt-only: its counts describe the
// initial PREPARED request, not the composition rebuilt by this view.
// Losing the whole receipt loses that witness. An older receipt may
// itself omit the optional field, and an attempted measurement may say
// `unavailable`; receiptAt preserves those states, never zero counts.
// Neither state means the receipt is missing or changes this gap's cause.
// The same applies to replayPreparations: downstream preparation was
// observed only when the enabled receipt carries its annotation.
//
// AND THE SEVENTH ROUND SCOPED THE PRINTED CLAIM TO THE LEVEL IT HOLDS AT.
// It said the absence of these fields is "a gap in the record, never a call
// made without them". True of each field AS A WHOLE — a receipt always
// carries `params` and always carries a `cache.transform` verdict, so those
// containers go missing only with the receipt. FALSE one level down, and
// that is the level a reader reads at: measured on an agent that set no
// dials, `receipt.params` is `{}` — an absence inside the container that IS
// "a call made without one", and `provider-defaults` says so on the very
// views that have a receipt. The sentence below claims nothing about what
// is inside a field it cannot see.
fields: Object.freeze([
'basis.model',
'basis.provider',
'basis.runId',
'basis.epoch',
'params',
'cache.transform',
'cache.transformHash',
'cache.markersApplied',
'cache.strategy',
'omittedForAttention',
'requestMeasurement',
'replayPreparations',
]),
why: 'Nothing on this view has been checked against what went out. Every field below is ' +
'missing as a whole, and an absence among them says nothing about the call — not even ' +
'that a dial was left unset.',
}),
'findings-layout': Object.freeze({
fields: Object.freeze([
'system.pieces',
'system.chars',
'messages.requestOnly',
'messages.count',
'messages.entries',
]),
why: 'The findings context is unproven here. Its absence does not show that none was served.',
}),
'no-run-log': Object.freeze({
// MECHANISM (not printed). Raised on `!location.hasRunLog` — a SUBTREE
// handed in on its own, with no run log to read. `readRunConstant` is the
// only reader that touches that log, and everything it fetches is a
// build-time fact `stages/seed.ts` wrote once: the forced output tool's
// NAME (`FORCED_OUTPUT_TOOL_KEY`), `toolWantsByName`, which
// `findStagedRefs` composes the nudge from, and — 9.101.0 — the findings
// ledger's serve mode (`findingsServe`), which `collapseJudged` folds
// judged tool results under. Losing that last one costs nothing on the
// default mode (`servedModeOf` falls back to it); under the bench-gated
// `'ledger-only'` the rebuilt `messages.entries` would carry fact results
// verbatim where the receipt hashed tickets — a subtree without its run
// log, on a mode no default reaches, recorded here rather than widened
// into `fields`.
//
// `tools.schemaHashes` is on the list because the forced tool's NAME is the
// run constant: losing it also loses the row the receipt hashes under that
// name. The fix a reader has is to pass the whole recording rather than the
// subtree, which is what the printed sentence says without naming any of
// this.
//
// THE MEASURED-FALSE SENTENCE, killed in 9.88.0's seventh round. It said
// "The fields below could not be fully recovered here" — a claim that the
// recovery DID fail. Driven on the ordinary view that raises this gap (a
// 'dynamic-grouped' agent with one plain tool, run log emptied), the
// damaged rebuild is byte-identical to the intact one: same `tools.names`,
// same `tools.schemas`, same absent `tools.forced`, same empty
// `messages.requestOnly`. The gap fires and costs NOTHING, and the sentence
// told the reader otherwise.
//
// THE REPAIR IS THE SENTENCE, NOT THE CONDITION, and the reason is that the
// condition cannot be narrowed by anything this file can read. What the
// missing log would have held is a forced tool's name and a `wants`
// declaration; whether the run had either is recorded IN THAT LOG. To fire
// the gap only where it costs something, the read would have to consult the
// record whose absence raises it. So the gap stays unconditional — it is
// the "you cannot tell" declaration, which is what this file is for — and
// the sentence became the conditional it always was: the fields MAY be
// short. Both directions are measured beside it in
// `gap-sentences.test.ts`: emptying the run log on a forced-output run
// takes the tool list from one name to none, on a staged-refs run it takes
// away the request-only line, and on the plain run it takes nothing.
fields: Object.freeze([
'tools.names',
'tools.forced',
'tools.schemaHashes',
'messages.requestOnly',
]),
why: 'The fields below may be SHORT: a name can be missing from the tool list, and a line ' +
'that went out with the request can be missing too. An absence below is not evidence ' +
'that there was nothing there — read the whole recording rather than a piece of it.',
}),
});
/**
* The fields no gap names, and the reason each one needs none — the OTHER half
* of the account.
*
* WHY IT EXISTS. `SERVED_GAPS` was hand-checked against the two shapes three
* times in one release and came up short every time, because "is every field
* named by a gap?" was a question a person answered by reading. It is now a
* question a walk answers: `test/time-travel/gap-catalogue-walk.test.ts`
* enumerates every field a real `Receipt` and a real `ServedView` carry and
* requires each one to be named by a gap OR to be a key here. A field in
* neither fails, by name.
*
* So this is not an exemption list. It is the place a field goes when NO GAP'S
* MECHANISM EXPLAINS IT, and the value is the reason in one sentence, for the
* next person who asks why the field has no gap. Adding a key here is as
* reviewable as adding one to a gap, and that is the point: both are a claim
* somebody wrote down.
*
* TWO reasons qualified when this list was written, and one of them has since
* emptied out:
*
* • NO FOLD CAN FAIL TO PRODUCE IT — the field is read straight off the
* located epoch, never through a fold. `callRuntimeStageId` is this kind.
* • ITS ABSENCE IS UNIVERSAL AND HAS NOTHING TO DO WITH THIS RECORDING — no
* chart IN THIS LIBRARY supplies it, on any run, so no gap about a limit of
* the rebuild describes it. `omittedForAttention` WAS this kind from
* 9.88.0's fourth review round to 9.93.0, and is not any more: the
* measurement it rested on ("no chart drops for attention") had missed the
* agent chart's window stage, which evicts turns for budget on every run
* whose strategy engages and now files them on the receipt. A recorded
* drop can be lost in exactly one way — with the receipt — so the field is
* named by `no-receipt-on-chart` and is not here. The kind is kept on this
* list because the next universally-absent field will need it, and because
* a reason that was true for five releases and then measured false is
* worth a sentence where the next person looks.
*
* A field a gap DOES name never belongs here, whatever else is also true of it.
* `epoch` was a key here through three rounds, on the true-but-irrelevant
* ground that `servedAt(k)` hands `k` back; what a base-less fold changes is
* what that number MEANS, `servedViews()` returns the fold's number outright,
* and `no-fold-base` names it now.
*
* Paths are spelled as they are on the shape that HAS the field — both keys
* here are `ServedView` fields. Gap `fields` are spelled as `Receipt` paths —
* see {@link ServedGap.fields} for the places the two shapes differ.
*
* @example
* ```ts
* import { SERVED_GAPS, UNGAPPED_FIELDS } from 'agentfootprint';
*
* // Why does nothing explain `callRuntimeStageId`? Because nothing has to.
* UNGAPPED_FIELDS['callRuntimeStageId'];
* Object.keys(SERVED_GAPS).length; // 7 gap kinds
* ```
*/
exports.UNGAPPED_FIELDS = Object.freeze({
// MECHANISM (not printed). Read straight off `EpochLocation.callRuntimeStageId`,
// which `epochAt` takes from the call's own commit bundle — never through
// `keyedFold`, so no missing base and no missing run log can touch it. An
// epoch with no bundle is not located at all, so there is no view to carry it.
callRuntimeStageId: 'Never absent here, so there is nothing about it for a gap to excuse.',
// THE SENTENCE THE RULE IS MODELLED ON. It makes no claim about code at all:
// it says what the field MEANS inside the account, and nothing outside the
// sentence can falsify it. Every other entry in this file was reduced to
// this shape in 9.88.0's sixth round.
gaps: 'The account itself rather than a fact about the request: a gap naming this list would be ' +
'the account excusing its own absence.',
});
/** A gap record, assembled from the catalogue — plus, where the site that
* raises it established one, the cause it read. */
function gapOf(kind, cause) {
return { gap: kind, ...exports.SERVED_GAPS[kind], ...(cause !== undefined && { cause }) };
}
/**
* The receipt this epoch's call committed, or WHY there is none. Shared by
* `receiptAt` and by the rebuild, which reads exactly TWO things off a receipt
* — the model/provider/runId basis, and whether a cache strategy stood between
* assembly and the port — and nothing else.
*
* THE ONE PLACE THAT KNOWS. This function is where the difference between
* "nothing was committed" and "something was committed and it is not a
* receipt" exists at all: everywhere else the two are one `undefined`. It used
* to be spent here, and the gap's printed sentence tried to make it up
* afterwards from a list of causes — which is how a shape refusal added in this
* release became a cause that sentence had already ruled out. So the answer
* carries the reason out, and {@link ServedGap.cause} is where it lands.
*
* THE ONE PLACE THAT NARROWS, too — and what it admits is what it says it
* admits. The check is the basis and nothing past it, so the value it hands
* back is a {@link StoredReceipt}: every container past the basis is what the
* MINTING release wrote, and a reader behind this narrowing reads them as
* optional, because the type says so and the compiler holds it to that.
* 9.93.0 read `receipt.cache.strategy` off a `Receipt` here — the minted
* shape, which promises `cache` — and a stored receipt with a basis and no
* `cache` made `servedAt` throw where 9.92 built a view. A reader reads the
* receipt it is handed; a missing container is a fact about the vintage,
* never a throw. It is also never repaired: nothing here fabricates a
* `cache: {}` or writes to the record.
*/
function readReceipt(location) {
const value = (0, epochs_js_1.readAfterCall)(location, conventions_js_1.RECEIPT_KEY);
// Nothing under the key: the run never minted one here. The record does not
// say which of the ways that happens applied, and neither does this.
if (value === undefined)
return { cause: 'no-receipt-committed' };
// Something IS under the key. A value without a basis is not a receipt —
// refuse it rather than hand back a half-shape a caller would read
// `.basis.runId` off — and REPORT the refusal, because a log that holds a
// non-receipt under the receipt key is damaged, which is a different fact
// about the recording from a run that never minted.
const candidate = value;
if (value === null || typeof value !== 'object' || typeof candidate.basis?.epoch !== 'number') {
return { cause: 'receipt-shape-rejected' };
}
return { receipt: value };
}
/** A committed tool list, or `undefined` when the key held no array — which is
* what makes it a FALLBACK CHAIN and not a merge: the first key that holds a
* list is the list this call served, and an empty array is an answer. */
function toolListOf(value) {
return Array.isArray(value) ? value : undefined;
}
/** `toolWantsByName` back as the map `findStagedRefs` takes. It is committed
* as a plain record because a `Map` does not survive the scope's write path
* (an object write is JSON-round-tripped, and a `Map` round-trips to `{}`). */
/**
* The serve mode `seed` put on the record (`findingsServe`), narrowed the way
* every run constant is: only the one non-default literal changes anything,
* and an absent or unrecognised value is the default the wire itself falls
* back to (`callLLM.ts · buildCallLLMStage`, `deps.findingsServe ?? …`) — so
* an unarmed run, which never wrote the key, collapses nothing.
*/
function servedModeOf(value) {
return value === 'ledger-only' ? 'ledger-only' : 'ledger-and-facts';
}
/**
* The answer-turn ask `seed` put on the record (`findingsAnswerAsk`, 9.103.0),
* narrowed the same way: only the one literal the wire ever writes appends
* the ask, and an absent key is the default the stage itself falls back to
* (`deps.findingsAnswerAsk ?? 'none'`) — so an armed run on the default,
* which never wrote the key, rebuilds the piece it served.
*/
function answerAskOf(value) {
return value === 'quote-facts' ? 'quote-facts' : 'none';
}
/**
* The declared ontology `seed` put on the record (`ontology`, 9.106.0),
* narrowed the way every run constant is: only a record carrying a `spec`
* with `nodes` and `sources` records composes a piece, and an absent key —
* every agent without `.ontology()` — composes nothing, which is what the
* wire did (`callLLM.ts · buildCallLLMStage`, under `deps.ontology`).
*/
function ontologyOf(value) {
if (value === null || typeof value !== 'object')
return undefined;
const { spec, tools } = value;
if (spec === null || typeof spec !== 'object')
return undefined;
const { nodes, sources } = spec;
if (nodes === null ||
typeof nodes !== 'object' ||
sources === null ||
typeof sources !== 'object') {
return undefined;
}
// The tool → skills join (9.108.0), taken as the record holds it — a
// record without the key (9.106.0, or a map naming only static tools)
// composes the bytes it always did.
const join = toolsJoinOf(tools);
return { spec: spec, ...(join !== undefined && { tools: join }) };
}
/** `tools` narrowed by shape: string → string[]; anything else is no join. */
function toolsJoinOf(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value))
return undefined;
const out = {};
for (const [name, ids] of Object.entries(value)) {
if (Array.isArray(ids) && ids.every((id) => typeof id === 'string'))
out[name] = ids;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function wantsMapOf(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value))
return undefined;
const map = new Map();
for (const [name, kinds] of Object.entries(value)) {
if (Array.isArray(kinds) && kinds.every((k) => typeof k === 'string')) {
map.set(name, kinds);
}
}
return map.size > 0 ? map : undefined;
}
/**
* The BOUNDARY half of the fold law: a container that leaves this file is this
* file's own.
*
* `keyedFold` deep-freezes its answers, so nothing a consumer does to a view
* can reach a later epoch. This is the second, cheaper guarantee on top of it:
* the two arrays a view would otherwise ALIAS straight out of the fold — the
* conversation and the tool schemas — are copied, so `ServedView` is a value in
* its own right and not a window onto the fold's cache. Elements are the fold's
* frozen objects; the container is ours.
*
* Every other array on a view is built in `viewOf` from scratch and aliases
* nothing, so it needs the COPY half of this and not the freeze — but it is
* frozen anyway, by {@link frozenView}, because the type says `readonly`
* throughout and a claim that is true of two containers out of six is a claim
* a reader cannot use.
*/
function detachedList(items) {
return Object.freeze([...items]);
}
/**
* A `ServedView` is a VALUE — all of it, not the two containers that happened
* to need copying.
*
* Freezing the whole view costs a handful of `Object.freeze` calls per epoch
* over containers that are already the right size to hand a person: a few
* system pieces, at most one request-only line, a tool list, the gaps. It buys
* one sentence a reader can rely on instead of a rule about which two arrays
* are safe. `messages.asSent` and `tools.schemas` are already frozen by
* {@link detachedList} — their ELEMENTS are the fold's own frozen objects and
* this function does not walk into them, because they are not ours to freeze
* twice.
*
* What this is NOT: protection against a shared reference. Nothing on a view
* except those two arrays is shared with anything — every other container is
* built here per call. The freeze makes the type's promise exact; the copy in
* `detachedList` is what makes the fold safe.
*/
function frozenView(view) {
for (const piece of view.system.pieces)
Object.freeze(piece);
Object.freeze(view.system.pieces);
for (const line of view.messages.requestOnly)
Object.freeze(line);
Object.freeze(view.messages.requestOnly);
Object.freeze(view.tools.names);
for (const gap of view.gaps)
Object.freeze(gap);
Object.freeze(view.gaps);
if (view.basis !== undefined)
Object.freeze(view.basis);
Object.freeze(view.system);
Object.freeze(view.messages);
Object.freeze(view.tools);
return Object.freeze(view);
}
/** Rebuild one epoch's view from a located epoch. */
function viewOf(location, served) {
// The receipt is read for exactly two things — WHICH model saw this, through
// WHICH provider, salted with WHICH run id; and whether a cache strategy
// stood between assembly and the port. Every other field below is rebuilt
// from committed pieces and never from the record it is checked against; a
// rebuild that read its own answer sheet would prove nothing. Both reads go
// through `readReceipt`'s narrowing and its type: a stored receipt carries
// what its vintage wrote, and nothing below dereferences a container the
// narrowing did not check.
const read = readReceipt(location);
const receipt = read.receipt;
// ── the system prompt ──────────────────────────────────────────────────
// The pieces are committed; the joined string never is. Same function the
// stage used, over the records as the stage read them.
const injections = (readAtCall(location, 'systemPromptInjections', served) ??
[]);
const iteration = readAtCall(location, 'iteration', served);
const recovery = (0, recovery_js_1.evidenceRecoveryPiece)(readAtCall(location, 'evidenceRecovery', served), readAtCall(location, 'evidenceRecoveryUsed', served), iteration);
// ── the conversation ───────────────────────────────────────────────────
// Read BEFORE the call's own bundle: the call appends the assistant turn to
// `history`, so the committed value at the call already includes the answer.
//
// TWO committed sources, because there are two kinds of chart and they do
// not agree on where the conversation lives. The agent charts keep it in
// `history` and treat `messagesInjections` as an observability projection.
// `LLMCall` and the message-API charts have NO history at all: the messages
// slot IS the conversation, composed by `messagesFromInjections` — the same
// function called here. Reading only `history` on those charts found nothing
// and reported an empty conversation the provider never sent.
//
// Neither present ⇒ the turns are UNKNOWN, and the gap below says so rather
// than letting `[]` pass for a proof.
const committedHistory = readAtCall(location, 'history', served);
const committedInjections = readAtCall(location, 'messagesInjections', served);
const conversation = Array.isArray(committedHistory)
? committedHistory
: Array.isArray(committedInjections)
? [...(0, composeRequest_js_1.messagesFromInjections)(committedInjections)]
: undefined;
const committed = conversation ?? [];
const history = (0, composeRequest_js_1.stripFrameworkFields)(committed);
// ── the findings ledger, served (9.101.0, step 3) ──────────────────────
// The SAME two pure functions the stage used, over the SAME committed
// inputs, in the SAME order (`callLLM.ts · buildCallLLMStage`): the piece
// from the ledger at the call and the wire's tool ids (nothing else — no
// call number, so a re-ask rebuilds the same bytes it served) — derived
// here as the stage derived them, from the COMMITTED conversation BEFORE
// the collapse and the strip (an id is never rewritten, so either side
// agrees; the batch settlement's marker, 9.113.0, is still on it — a
// settled message is no result, `findings/offer.ts` · `isResultMessage`);
// the collapse under the mode `seed` put on the record as the run constant
// `findingsServe` (the `forcedOutputToolName` precedent — a build-time fact
// read from the RECORD, never from the receipt this view is checked
// against), stripped after it, and the answer-turn ask under the run
// constant `findingsAnswerAsk` (9.103.0), written only when the ask went
// out. No `.findings()` ⇒ no key ⇒ all three are no-ops and the rebuild is
// the bytes it always was; no new gap kind, `withheld` untouched.
const ledger = readAtCall(location, 'findingsLedger', served);
const recordedLayout = (0, epochs_js_1.readAfterCall)(location, 'findingsLayout');
const recordedBound = (0, epochs_js_1.readAfterCall)(location, 'findingsContextBytes');
let layoutUnknown = recordedLayout !== undefined && recordedLayout !== context_js_1.FINDINGS_LAYOUT;
let lateLayout = recordedLayout === context_js_1.FINDINGS_LAYOUT;
if (lateLayout) {
try {
if (typeof recordedBound !== 'number')
throw new Error('Missing bound');
(0, context_js_1.findingsContextBytes)(recordedBound);
}
catch {
lateLayout = false;
layoutUnknown = true;
}
}
// No discriminator means the original saved layout, never the installed version.
const findings = lateLayout || layoutUnknown
? undefined
: (0, serve_js_1.findingsLedgerPiece)(ledger, (0, serve_js_1.servedToolCallIds)(committed), answerAskOf(readRunConstant(location, 'findingsAnswerAsk', served)));
const collapsed = (0, serve_js_1.collapseJudged)(committed, ledger, servedModeOf(readRunConstant(location, 'findingsServe', served)));
const asSent = collapsed === committed ? history : (0, composeRequest_js_1.stripFrameworkFields)(collapsed);
// ── the declared ontology, served (9.106.0) ────────────────────────────
// The SAME pure function the stage used, over the run constant `seed`
// wrote (the `findingsServe` precedent — a build-time fact read from the
// RECORD, never from the receipt this view is checked against). No
// `.ontology()` ⇒ no key ⇒ no piece, and the rebuild is the bytes it
// always was.
const ontologyRecord = ontologyOf(readRunConstant(location, 'ontology', served));
// The hidden skill ids the call was composed under (9.108.0) — a per-epoch
// key the Injection Engine's mount publishes, read at the call as the stage read it.
const hiddenAtCall = readAtCall(location, 'hiddenSkillIds', served);
const ontology = ontologyRecord === undefined
? undefined
: (0, serve_js_2.ontologyPiece)(ontologyRecord.spec, {
...(ontologyRecord.tools !== undefined && { tools: ontologyRecord.tools }),
...(hiddenAtCall !== undefined && { hiddenSkillIds: hiddenAtCall }),
});
// ── the system prompt, joined ──────────────────────────────────────────
// Injections, then the recovery piece, then the ontology piece, then the
// findings piece — the fixed order the stage joins in. Every request-only
// piece is joined, never committed as an injection.
const composedPieces = recovery === undefined && ontology === undefined && findings === undefined
? injections
: [
...injections,
...(recovery === undefined ? [] : [recovery]),
...(ontology === undefined ? [] : [ontology]),
...(findings === undefined ? [] : [findings]),
];
const pieces = (0, composeRequest_js_1.contributingPieces)(composedPieces).map((record) => ({
text: record.rawContent,
slot: record.slot,
source: record.source,
}));
// ── the tools ──────────────────────────────────────────────────────────
// `dynamicToolSchemas` is the PRE-assembly list. Two rules run after it and
// both are on the record: a withheld call (the wrap-up, or any call after
// the evidence revision under an enforcing answer validation —
// `composeRequest.ts` · `toolsWithheld`) is served no tool, and a
// 'tool-forced' strategy adds one.
//
// TWO committed sources, for the same reason the conversation above has two
// and with the same ordering rule (9.91.0). The agent charts map the tools
// slot's output onto `dynamicToolSchemas` at the mount boundary; the
// message-API charts carry it out under the slot's own name. Reading only
// the first reported an EMPTY tool list on a chart that served three — a
// Lens DENYING, about a fact the log holds perfectly well. The agent key is
// read first and the fallback is reached only when it holds no array, so no
// agent recording changes.
const withheld = (0, composeRequest_js_1.toolsWithheld)(readAtCall(location, 'wrapUpAsked', served), readAtCall(location, 'toolsOffAfterRevision', served));
const dynamic = toolListOf(readAtCall(location, 'dynamicToolSchemas', served)) ??
toolListOf(readAtCall(location, 'toolSchemas', served)) ??
[];
const registered = withheld !== null ? [] : dynamic;
const forcedRaw = readRunConstant(location, receipt_js_1.FORCED_OUTPUT_TOOL_KEY, served);
const forced = typeof forcedRaw === 'string' && forcedRaw.length > 0 ? forcedRaw : undefined;
// ── the request-only lines ─────────────────────────────────────────────
// The staged-refs nudge is a pure function of committed state: the
// conversation, the tools really served this call, and the `wants`
// declarations `seed` put on the record for exactly this reason.
const requestOnly = [];
const wants = wantsMapOf(readRunConstant(location, 'toolWantsByName', served));
if (wants !== undefined) {
const match = (0, stagedRefs_js_1.findStagedRefs)(asSent, wants, new Set(registered.map((t) => t.name)));
if (match !== undefined) {
requestOnly.push({
role: 'user',
text: (0, stagedRefs_js_1.stagedRefsNudgeLine)(match),
reason: 'staged-refs-nudge',
});
}
}
// The time line (step T6b) — the tools slot's committed composition, served only on the
// iteration that composed it and never on the wrap-up call (`callLLM` · the same three tests).
const timeLine = readAtCall(location, 'timeLine', served);
if (withheld === null &&
typeof timeLine?.text === 'string' &&
timeLine.text.length > 0 &&
timeLine.iteration === readAtCall(location, 'iteration', served)) {
requestOnly.push({ role: 'user', text: timeLine.text, reason: 'time-window-line' });
}
// The evidence conclusion (the figures dial) — the committed carrier's late
// line, on the iteration its instruction is served (`callLLM` · the same test).
const conclusion = (0, recovery_js_1.evidenceConclusionLine)(readAtCall(location, 'evidenceRecovery', served), readAtCall(location, 'evidenceRecoveryUsed', served), iteration);
if (conclusion !== undefined) {
requestOnly.push({ role: 'user', text: conclusion, reason: 'evidence-conclusion' });
}
if (lateLayout) {
try {
requestOnly.push({
role: 'user',
text: (0, context_js_1.findingsContext)(committed, (0, epochs_js_1.readAtCall)(location, 'findingsLedger'), recordedBound),
reason: 'findings-data',
});
}
catch {
layoutUnknown = true;
}
}
// ── what this view cannot prove ────────────────────────────────────────
// Order is deliberate: the two that make the WHOLE view suspect come first,
// so a renderer that shows one gap shows the one that matters most.
const gaps = [];
if (layoutUnknown)
gaps.push(gapOf('findings-layout'));
// BOTH bases, because there are two folds above and either can be baseless.
// Under 'dynamic-grouped' the epoch's pieces come from the turn's own inner
// log (`location.basis`) and every run constant — the forced tool's name, the
// `wants` the nudge is composed from — comes from the RUN log
// (`location.runBasis`). Raising the gap off the first alone let a grouped
// recording that travelled without its RUN base read every constant as absent
// and declare NOTHING: a Lens denying rather than omitting, which is the law
// this whole file is built on.
if (location.basis === 'log-only' || location.runBasis === 'log-only') {
gaps.push(gapOf('no-fold-base'));
}
if (conversation === undefined)
gaps.push(gapOf('no-conversation-on-record'));
if (!location.hasRunLog)
gaps.push(gapOf('no-run-log'));
// No receipt ⇒ nothing checks the rebuild, and the receipt-only fields are
// absent. Mutually exclusive with `provider-defaults` below, which is the
// caveat that applies when a receipt DID record the dials.
//
// The CAUSE rides along, from the one read that can tell "nothing was
// committed" from "something was, and it is not a receipt". The gap's
// sentence says neither — see {@link ServedGap.cause}.
if (read.receipt === undefined)
gaps.push(gapOf('no-receipt-on-chart', read.cause));
// A rewrite is possible only where a strategy stood between assembly and
// the port. The receipt says whether one did (`cache.strategy`, 9.93.0);
// with no receipt, or a receipt minted before the field — or the whole
// `cache` container — existed, the record cannot rule one out and the gap
// stays — the "you cannot tell" declaration, never an inference from
// absence. Only a receipt that SAYS `null` lifts it. The optional chain is
// the vintage law, not defensiveness: `StoredReceipt` declares both
// containers optional, so this is the one spelling that compiles.
if (receipt?.cache?.strategy !== null) {
gaps.push(gapOf('cache-transform'));
}
if (receipt !== undefined)
gaps.push(gapOf('provider-defaults'));
if (receipt?.replayPreparations !== undefined)
gaps.push(gapOf('thinking-replay'));
if (forced !== undefined)
gaps.push(gapOf('forced-tool-schema'));
return frozenView({
epoch: location.epoch,
callRuntimeStageId: location.callRuntimeStageId,
...(receipt !== undefined && {
basis: {
model: receipt.basis.model,
provider: receipt.basis.provider,
runId: receipt.basis.runId,
},
}),
system: { text: (0, composeRequest_js_1.joinSystemPrompt)(composedPieces), pieces },
messages: { asSent: detachedList(asSent), requestOnly },
tools: {
names: [...registered.map((t) => t.name), ...(forced !== undefined ? [forced] : [])],
schemas: detachedList(registered),
...(forced !== undefined && { forced }),
...(withheld !== null && { withheld }),
},
gaps,
});
}
/**
* Rebuild what the model was SERVED on epoch `k`, from the run's committed
* pieces alone.
*
* Works on a live snapshot and on a recording read back from JSON, in both
* chart shapes, on a run whose conversation lives in `messagesInjections`
* rather than `history` (`LLMCall`, the message-API charts), on a resumed run
* (every read folds from the checkpoint the run was seeded with), on a
* redacted run (the pieces are read exactly as the run committed them — a
* redacted piece rebuilds to its redacted bytes, which is what the record says
* a reader is allowed to see) and on a recording made before the receipt
* existed.
*
* It returns `undefined` for ONE reason only: the run has no such epoch.
* Anything it cannot prove about an epoch that DOES exist comes back as a
* named entry in `gaps`, never as a missing view and never as a confident
* empty one.
*
* @param source a runner (`Agent`, `LLMCall`) or a snapshot. Where no receipt
* was minted — a message-API chart handed no run id, a run that declined with
* `recordReceipt: false`, a chart of the caller's own — the rebuild is
* complete but UNCHECKED: `basis` is absent and `gaps` carries
* `no-receipt-on-chart` saying so.
* @param epoch the iteration number, 1-based — the run's own count.
*
* @example
* ```ts
* import { receiptAt, receiptHash, servedAt } from 'agentfootprint';
*
* const snapshot = agent.getSnapshot()!;
* const view = servedAt(snapshot, 1)!;
* const receipt = receiptAt(snapshot, 1)!;
* receiptHash(receipt.basis.runId, view.system.text) === receipt.system.hash; // true
* ```
*/
function servedAt(source, epoch) {
const recording = (0, epochs_js_1.recordingOf)(source);
const location = (0, epochs_js_1.epochAt)(recording, epoch);
return location === undefined ? undefined : viewOf(location, (0, marker_js_1.servedUnderPolicy)(recording));
}
exports.servedAt = servedAt;
/**
* Every epoch's served view, in run order — `servedAt` for a whole run, with
* one pass over the recording instead of one per epoch.
*
* @example
* ```ts
* servedViews(agent.getSnapshot()!).map((v) => v.tools.names.length); // [3, 3, 0]
* ```
*/
function servedViews(source) {
const recording = (0, epochs_js_1.recordingOf)(source);
const served = (0, marker_js_1.servedUnderPolicy)(recording);
return (0, epochs_js_1.epochLocations)(recording).map((location) => viewOf(location, served));
}
exports.servedViews = servedViews;
/**
* The receipt epoch `k`'s call left behind, or `undefined`.
*
* What comes back is a {@link StoredReceipt}: the receipt AS STORED, written
* by the release that minted it. A recording is older than the reader that
* opens it, so a container a later release added (`cache.strategy`, 9.93.0)
* may be absent — and it is handed back absent, not repaired, because the
* narrowing checks the basis and promises nothing past it. A reader reads the
* receipt it is handed; a missing container is a fact about the vintage,
* never a throw.
*
* `undefined` means one of two things on the record, and the same epoch's
* `servedAt(...)` view carries which: its `no-receipt-on-chart` gap has a
* {@link ServedGapCause}. Either nothing was committed under the receipt key —
* a recording made before the receipt existed, a chart whose LLM stage mints
* none, a run with `recordReceipt: false`, all of which leave that same record
* — or something WAS committed there and `readReceipt` refused it, because it
* is not a non-null object with a number at `basis.epoch`, and handing back a
* half-object a caller reads `.basis.runId` off is worse than saying no.
*
* It also returns `undefined` when the run has no epoch `k` at all, which is
* the answer `epochAt` gives and is not a fact about receipts; ask
* `servedAt(source, k)` if you need to tell a missing epoch from a missing
* receipt, because that one returns `undefined` for the missing epoch only.
*
* `servedAt` still rebuilds the view of an epoch that exists in every one of
* these cases, which is what makes an old recording readable instead of
* unreadable.
*
* The receipt comes back DEEP-FROZEN and is the same object every caller gets
* for this epoch: it is a value folded out of the log, and a fold's answers are
* detached (`keyedFold.ts` · `freezeDeep`). Copy it if you need to edit one.
*
* @example
* ```ts
* import { receiptAt } from 'agentfootprint';
*
* receiptAt(agent.getSnapshot()!, 3)?.tools.withheld; // 'wrap-up' on a wrap-up call
* ```
*/
function receiptAt(source, epoch) {
const location = (0, epochs_js_1.epochAt)(source, epoch);
return location === undefined ? undefined : readReceipt(location).receipt;
}
exports.receiptAt = receiptAt;
//# sourceMappingURL=servedView.js.map