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