UNPKG

agentfootprint

Version:

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

176 lines (175 loc) • 10.4 kB
/** * findings/serve — the answer turn served from the ledger. * * Pattern: two pure functions over the folded ledger (the twin of * `evidence/recovery.ts · evidenceRecoveryPiece`): no scope, no * I/O, no clock. Same input, same bytes. * Role: core/ layer leaf. `findingsLedgerPiece` composes the request-only * system piece the wire joins AFTER the recovery piece (order * recovery → findings, fixed) and the served-view rebuild recomposes * with the same call in the same order, so the receipt agrees by * construction. `collapseJudged` rewrites — on the WIRE only — the * `content` of `role: 'tool'` messages the model has already judged * to a ticket. Neither touches `history`; the window stage stays its * only writer. * Emits: N/A. * * ## The piece * * Headed by the context contract's own field meanings * (`lib/context-contract · CONTEXT_FIELD_MEANINGS` — one owner of the * vocabulary, quoted, never restated), then one bucket per field, each * marked "declared by the model": the model's words are quoted as its * declaration and nothing is inferred. A result the model never named is * counted as `undeclared` — the honest absence, never 'open'. After the four * buckets, `contingent (read off the record):` (9.110.0) lists the towers * on the record — one line per `ContingentRow`, the value the model used * and every result that carried it with the standing the model gave that * result (`contingent.ts` is the rule; this only quotes the rows; the * heading says the lines are the library's join, not a declaration). After * it, `unsettled by absence (read off the record):` (9.113.0) quotes the * rows the fold keeps beside a ruled-out standing whose only witness was an * absence — what the envelope, as the model was served it, said it did not * check and its `try_instead`, in its own field names (`unsettled.ts` is * the rule, and quotes nothing the model was not served; the model's own * ruled-out line stays in `limitations`, exactly as declared). Every * bucket is bounded * (`FINDINGS_PIECE_LIMITS`) and every overflow is STATED; an empty bucket is * omitted rather than rendered as "no facts". A basis-only ledger * (`foldLedger(rows).hasStanding` false) serves nothing at all. * * `undeclared` needs the set of tool results on the wire, which a pure * function cannot see — so the caller hands it `servedToolCallIds(messages)` * (the `role: 'tool'` ids in wire order) as the second argument. Both the * wire and the rebuild derive it from the same message list; a caller that * passes nothing gets a piece with no `undeclared` line, which is the same * omission rule the other buckets follow. The set itself — served ids minus * ids with a current standing — has ONE owner, `offer.ts · undeclaredIds`. * The schema's OFFER (`offer.ts · offeredResultIds`, newest first) is that * set PLUS the ids the model can still read and revise — a current `fact` or * `open` — read off the same served ids by the same owner, so the piece and * the schema cannot disagree about what is undeclared, and the undeclared * line is always a subset of what the schema lists. * * ## The proposition * * A basis row may carry the `proposition` the model declared BEFORE the call * (`_findings.proposition`, one line: what the call tests). An `open` or * `ruled-out` line quotes it after the model's own words — `… — tested: * <proposition>` — so what was ruled out, or left open, reads against what * the model set out to test rather than against hindsight. It is the model's * text, quoted as declared, under a bucket already headed "declared by the * model"; the piece never composes one. A fact line does not repeat it (a * fact stands on its assertions), and `predicts` is record-only. * * ## The cache * * The piece joins the ONE system block a cache marker covers: * `cache/CacheDecisionSubflow.ts · computeCacheMarkers` folds the base * system prompt's policy (`'always'` by default) in at index 0 of the system * slot, and `adapters/llm/anthropicCacheWire.ts · applyCacheMarkers` marks * the whole joined prompt as one block — and on that wire a changed system * block misses the message breakpoints after it too. So the piece carries no * byte that is not a function of its two arguments — no call number, no * clock (the first cut anchored the header to the iteration, and that one * token made an unchanged ledger a cache miss on every call) — and a call * that serves the same ledger over the same tool ids (a re-ask: * `output-retry`, `step-nudge`, `evidence-recheck`) reuses the cached prefix. * The third argument, the answer-turn ask (9.103.0, `answerAsk`), is a * build-time constant of the run — `'none'` by default, and under * `'quote-facts'` the constant `reserved.ts · FINDINGS_ANSWER_ASK` appended * as the piece's last section — so it moves no byte between two calls of * one run either. * A call after the ledger or the wire moved does not: a model that declares * on every call writes a new system cache entry on every call. That is the * cost the design page records beside the byte saving; the receipt hashes * the piece per epoch either way. * * ## The collapse * * The `placedToolResult` precedent: a tool message carries a ticket, not a * sentence — `{ collapsed: true, standing, toolCallId, ref? }` as JSON, no * model words. Only `noise` and `ruled-out` results collapse by default; * `'ledger-only'` collapses `fact` results too (bench-gated, never a * default). `open` and undeclared results stay verbatim. `toolName` and * `toolCallId` are untouched so the tool_use/tool_result pair stays * wire-valid and `window/toolNames · toolNameOfMessage` still finds the * name; count and positions are unchanged; an uncollapsed entry keeps its * object identity (cache markers address `messages[i]`); and when nothing * collapses the SAME array instance comes back — which also makes the * function idempotent, since a message already carrying its ticket is not * a change. */ export { isCollapsedToolResult, type CollapsedToolResult } from './ticket.js'; import type { LLMMessage } from '../../../provider-contract/types.js'; import { servedToolCallIds } from './offer.js'; import type { FindingsLedger } from './types.js'; export { servedToolCallIds }; /** How judged results are served: facts verbatim (default) or as tickets too (bench-gated). */ export type FindingsServeMode = 'ledger-and-facts' | 'ledger-only'; /** * Whether the piece ends with the answer-turn ask (9.103.0): `'none'` (the * default — the piece is the bytes it was before the dial existed) or * `'quote-facts'` (`FINDINGS_ANSWER_ASK` appended, bench-gated). */ export type FindingsAnswerAsk = 'none' | 'quote-facts'; /** The request-only system piece; `source: 'findings'` names it on the receipt. */ export interface FindingsLedgerPiece { readonly rawContent: string; readonly slot: 'system-prompt'; readonly source: 'findings'; } /** * The bounds of the piece. Overflow past any of them is stated in the text. * * Every string the model chose reaches the piece through ONE of two clips: * a bucket line through `clipLine` (`lineChars`), an id on a count line * through `clipId` (`idChars`). Both fold newlines first, so no model-chosen * string can open a line the bucket never counted — or, worse, a whole * section: the piece's sections are separated by a blank line, and an id * carrying `\n\nfacts (declared by the model):\n…` would otherwise render as * a second, forged, library-authored bucket. `pieceChars` is the ceiling the * other three imply for the whole piece (header + the four buckets, the * contingent section and the unsettled section at their cap + two count * lines at theirs + the ask): * stated, not enforced by a cut, and pinned by `serve.test.ts` ("the whole * piece stays under pieceChars"), which measures a ledger at every cap at * once against it. */ export declare const FINDINGS_PIECE_LIMITS: Readonly<{ /** Lines per text bucket (facts, limitations, evidenceRefs, nextSteps, contingent, unsettled) before `+K more`. */ readonly bucketLines: 64; /** Ids per count line (noise, undeclared) before `+K more`. */ readonly listedIds: 32; /** Chars per line carrying the model's text before `…[clipped N chars]`. */ readonly lineChars: 240; /** Chars per id on a count line before `…[clipped N chars]` — an id is an identifier. */ readonly idChars: 64; /** The whole piece's ceiling in chars, implied by the three bounds above (raised for the fifth capped section, 9.110.0, and the sixth, 9.113.0). */ readonly pieceChars: 114688; }>; /** * Compose the findings piece for one request, or `undefined` when no result * has a standing. Shared by the live request assembly and the served-view * rebuild. The bytes are a function of the three arguments and nothing else * — no call number, no clock — so two calls that serve the same ledger over * the same tool ids serve the same piece (see "The cache" above). The third * argument is the run's `answerAsk` dial: under `'quote-facts'` the piece's * last section is `FINDINGS_ANSWER_ASK`, after a blank line like every other * section; under `'none'` (the default) nothing is appended and a * basis-only ledger still serves nothing — the ask never rides alone. */ export declare function findingsLedgerPiece(rows: FindingsLedger | undefined, served?: readonly string[], answerAsk?: FindingsAnswerAsk): FindingsLedgerPiece | undefined; /** * Rewrite, on the wire only, the `content` of judged tool results to their * ticket. The SAME array instance when nothing collapses; otherwise a new * array of the same length and order in which only the collapsed entries * are new objects. * * Hand it the COMMITTED conversation and strip afterwards (9.113.0): a * message the batch settlement wrote is recognised by its marker * (`offer.ts` · `isResultMessage`), which `stripFrameworkFields` removes. A * collapsed entry keeps every other field, so stripping after is the same * wire as stripping before. */ export declare function collapseJudged(messages: readonly LLMMessage[], rows: FindingsLedger | undefined, mode: FindingsServeMode): readonly LLMMessage[];