UNPKG

agentfootprint

Version:

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

980 lines • 56.9 kB
import { FINDINGS_LAYOUT, findingsContext, findingsContextBytes, } from '../core/agent/findings/context.js';
import { contributingPieces, joinSystemPrompt, messagesFromInjections, stripFrameworkFields, toolsWithheld, } from '../core/agent/composeRequest.js';
import { findStagedRefs, stagedRefsNudgeLine } from '../core/agent/stagedRefs.js';
import { evidenceConclusionLine, evidenceRecoveryPiece } from '../core/agent/evidence/recovery.js';
import { collapseJudged, findingsLedgerPiece, servedToolCallIds, } from '../core/agent/findings/serve.js';
import { ontologyPiece } from '../ontology/serve.js';
import { epochAt, epochLocations, readAfterCall, readAtCall as foldAtCall, readRunConstant as foldRunConstant, recordingOf, } from './epochs.js';
import { servedUnderPolicy } from '../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 = foldAtCall(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 = foldRunConstant(location, key);
    return served && value === LOG_PLACEHOLDER ? undefined : value;
}
import { FORCED_OUTPUT_TOOL_KEY, RECEIPT_BOUNDARY, } from '../core/agent/receipt/receipt.js';
import { RECEIPT_KEY } from '../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.
 */
export const 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_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
 * ```
 */
export const 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, ...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 = readAfterCall(location, 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 = 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)
            ? [...messagesFromInjections(committedInjections)]
            : undefined;
    const committed = conversation ?? [];
    const history = 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 = readAfterCall(location, 'findingsLayout');
    const recordedBound = readAfterCall(location, 'findingsContextBytes');
    let layoutUnknown = recordedLayout !== undefined && recordedLayout !== FINDINGS_LAYOUT;
    let lateLayout = recordedLayout === FINDINGS_LAYOUT;
    if (lateLayout) {
        try {
            if (typeof recordedBound !== 'number')
                throw new Error('Missing bound');
            findingsContextBytes(recordedBound);
        }
        catch {
            lateLayout = false;
            layoutUnknown = true;
        }
    }
    // No discriminator means the original saved layout, never the installed version.
    const findings = lateLayout || layoutUnknown
        ? undefined
        : findingsLedgerPiece(ledger, servedToolCallIds(committed), answerAskOf(readRunConstant(location, 'findingsAnswerAsk', served)));
    const collapsed = collapseJudged(committed, ledger, servedModeOf(readRunConstant(location, 'findingsServe', served)));
    const asSent = collapsed === committed ? history : 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
        : 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 = 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 = 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, 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 = findStagedRefs(asSent, wants, new Set(registered.map((t) => t.name)));
        if (match !== undefined) {
            requestOnly.push({
                role: 'user',
                text: 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 = 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: findingsContext(committed, foldAtCall(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: 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
 * ```
 */
export function servedAt(source, epoch) {
    const recording = recordingOf(source);
    const location = epochAt(recording, epoch);
    return location === undefined ? undefined : viewOf(location, servedUnderPolicy(recording));
}
/**
 * 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]
 * ```
 */
export function servedViews(source) {
    const recording = recordingOf(source);
    const served = servedUnderPolicy(recording);
    return epochLocations(recording).map((location) => viewOf(location, served));
}
/**
 * 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
 * ```
 */
export function receiptAt(source, epoch) {
    const location = epochAt(source, epoch);
    return location === undefined ? undefined : readReceipt(location).receipt;
}
//# sourceMappingURL=servedView.js.map