agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
451 lines (450 loc) • 25.1 kB
TypeScript
/**
* arguments/serve — what the inputs layer says, and to whom.
*
* Pattern: Lens. Pure composers, one per served surface: the ruled tool's
* SCHEMA (a rebuilt copy per request, never the registry reference),
* the past-tense NOTE on a result whose call ran on a filled value
* (a declared default, or the person's answer to the batch ask), the
* REFUSALS a call reads when it does not run, and the "Assumed"
* block under an existing `.limitsTravelWithTheAnswer()`.
* Role: core/ layer leaf of the inputs layer (honesty layer 2). Every
* sentence here is registered in `test/modelFacingSurfaces.test.ts`:
* it says what the model may do and what the record keeps, and
* promises no outcome a later path can break. Loaded through
* `import()` by every static site that serves one of them — the
* tools slot and seed (the schema), ToolCalls through
* `dispatch.ts` (the note, the refusal), the final branch's armed
* variant (the block) — and only under the arm: the optional-family
* law of docs-next's site budget, so a plain agent's graph never
* carries this module.
* Emits: N/A.
*
* ## The value in a sentence goes through the tool's own view
*
* A served schema and a served tool message are both part of the record. When
* the tool's argument view (`core/toolShownArgs.ts` · `shownArgsOf`) hides a
* ruled argument, no sentence here prints its value — it says the value is
* hidden by the tool's view instead.
*/
import type { LLMToolSchema } from '../../../provider-contract/types.js';
import type { InputValue } from '../../../tools/inputRequest.js';
import { ASSUMED_BLOCK_HEADING, type TimeLimitLines } from '../../../semantics/coverage/answer.js';
import type { TimeLimitFacts } from '../../../semantics/coverage/timeLimitFacts.js';
import { type RuledToolLike } from '../../../tools/declare.js';
import { type PeriodFacts, type TimeRefusal } from '../../../time/convert.js';
import type { ReaderWindows } from '../../../time/bind.js';
import type { ZoneName } from '../../../time/zone.js';
import type { InstantText } from '../../../time/instant.js';
/** A declared value as a sentence prints it: a string quoted, a number or boolean bare. */
export declare function printedValue(value: InputValue): string;
/**
* Whether the tool's own argument view hides `argument` — asked with a probe
* of the declared value, so the answer is the view's, never a guess.
*/
export declare function hidesArgument(tool: unknown, argument: string, value: InputValue): boolean;
/**
* The sentence a ruled `assume` property carries in the served schema: what the
* library does when the call leaves the argument out, and what the record
* keeps. It promises nothing a later path can break — not that the call runs
* (permission may deny it), only what fills a value that is left out.
*/
export declare function assumeSentence(value: InputValue, hidden: boolean): string;
/**
* The sentence a ruled `ask` property carries in the served schema: the rule
* asks the person for the value, so the model may leave it out — and should,
* unless the person gave it. No value is printed: an `ask` rule has no default.
*/
export declare const ASK_SENTENCE = "The tool's rule asks the person for this value; leave it out unless the person gave it.";
/**
* The sentence a ruled `ask` property carries under DECLARED SOURCES
* (`.findings({ argumentSources: true })`, `.inputsLayer({ argumentSources:
* true })`) in place of `ASK_SENTENCE`: the same rule, and — at the place the
* model decides whether to send the value — where it says the person gave it.
* Under the arm a present value with no traced source is asked of the person
* (`resolve.ts`), so "leave it out unless the person gave it" alone told the
* model that SENDING the value was the whole of its statement: in the paid
* step-5 run (`bench/inputs/runs/haiku45-step5`) it left the period out on 32
* of 32 calls where the person gave none — and a period the person HAD given
* was traced to their words, unasked, on 7 of 88 calls.
*/
export declare const ASK_SOURCES_SENTENCE: string;
/** The options every served-schema composer here takes. */
export interface ServeOptions {
/**
* Declared sources are armed (`.findings({ argumentSources: true })` or
* `.inputsLayer({ argumentSources: true })`) AND this tool's served schema
* carries `_findings.from`: an `ask` property says where the model declares
* the person's words, and an answered note says a later call may cite the
* answer. The CALLER decides it per tool, by the rule the planters and the
* dispatch peel ask (`findings/reserved.ts` · `ownsReservedArgument`, which
* this leaf cannot import): a tool whose author owns `_findings` is served
* undecorated, so a sentence naming `_findings.from` would tell the model to
* write into the author's own argument. Absent → the sentences steps 3 and 4
* serve, byte for byte.
*/
readonly sources?: boolean;
}
/**
* The served copy of a ruled tool's schema: every argument an `assume` or an
* `ask` rule governs leaves `required` (the library fills it, or asks the
* person for it, when the call leaves it out) and its property's description
* gains the rule's sentence (an `ask` rule's names `_findings.from` under the
* sources arm, `options.sources`). A rebuilt copy — the registry schema, which
* `validateToolArgs` judges and `mcpServe` serves, is never edited. The SAME
* reference back for a tool with no rules, rules that cannot be read (the
* dispatch re-read refuses its calls) or no tool at all (a chart with no
* claimant record) — so an agent whose tools declare nothing serves the bytes
* it always did.
*
* @example
* ```ts
* withArgumentRules(searchLogs.schema, searchLogs).inputSchema.required; // ['service']
* ```
*/
export declare function withArgumentRules(schema: LLMToolSchema, tool: RuledToolLike | undefined, options?: ServeOptions): LLMToolSchema;
/**
* The served list with each ruled tool's schema decorated (`withArgumentRules`,
* from the rules of the implementation that WINS each name), or the SAME list
* when no served schema changed — so an armed agent whose wire carries no
* ruled tool commits the bytes it always did. The tools slot's one decoration
* site (`core/slots/buildToolsSlot.ts` · `commitWire`) calls it under the arm.
* `optionsOf` gives each schema its own `ServeOptions`: under declared sources
* one wire can hold a tool that carries `_findings.from` beside a provider's
* tool whose author owns `_findings`, which does not.
*/
export declare function rulesOnWire(served: readonly LLMToolSchema[], winningTools: ReadonlyMap<string, RuledToolLike>, optionsOf?: (schema: LLMToolSchema) => ServeOptions | undefined): readonly LLMToolSchema[];
/**
* What the served line reads: the turn's SETTLED windows of the person's
* words (`time/windows.ts` · `readerWindowsOf` — each mention's quote and
* its one window: the one the person confirmed or gave in the time ask, or a
* `model` reader's reading), the quotes still PENDING (a proposal the person
* has not answered, a zone to name, or words the library could not read —
* `pendingUnread`), the turn's clock, and the app's `.time({ zone })`.
*/
export interface ServedWindows extends ReaderWindows {
readonly appZone?: ZoneName;
}
/**
* The ONE served time line (TQ13; step T6b's serving placement): one
* request-only `user` line appended LAST to the request, at the decision
* point — composed at the tools slot's one decoration site
* (`core/slots/buildToolsSlot.ts` · `commitWire`) from the tools it really
* serves, carried to `callLLM` on `timeLine`, and rebuilt by
* `time-travel/servedView.ts` from the same committed key. Four parts:
*
* - SETTLED — each window the person confirmed or gave in the time ask (or a
* `model` reader's reading), in the person's zone, WHOSE it is, and each
* served period tool's values for it — the exact conversion, else the wider
* one the fill would use (said so); a value the tool's view hides is named
* hidden. So the model never re-derives a window from words, and the answer
* states the window it was built on.
* - CONTROL — the window the person set in the app's time control, in
* their zone, with each served period tool's values for it; with none
* (no period tool served, or none can read it) the window alone (G14).
* - REFUSED — a window of the person's a served period tool refused before
* dispatch this turn: the refusal's reason in the result's own words, then
* what an answer states. That quote is no longer pending for that tool.
* - PENDING — a proposal the person has not answered is no window yet, and a
* call that writes its own window runs as sent, unconfirmed (§ 7.3). The
* line names the next step — the call with the period arguments left out,
* so the library's own form confirms the window with the person — and the
* two moves that are not it (a question about the time in the reply, a
* written window). Once a call of the turn already ran on a written window
* (`ranUnconfirmed`), it names the limit an answer states instead.
*
* `undefined` when no part has anything to say — a turn with no time words
* and no control window serves no line at all; with no served period tool
* only the control half can speak. The halves carry no opening: {@link timeLine} composes the served line and
* opens it, once, with {@link TIME_LINE_SOURCE}.
*
* @example
* ```ts
* timeWindowsLine(served, winningTools, { now, windows: [], pending: ['yesterday'] });
* // 'The window for “yesterday” is not settled yet: the person confirms it in the library's own
* // form, which shows its reading of those words with the zone and opens when client_activity
* // is called with start_time, end_time left out (or the call is refused with the reason). So
* // the next step is that call — not a question about the time in the reply, and not a window
* // written into the call, which would run unconfirmed.'
* ```
*/
export declare function timeWindowsLine(served: readonly LLMToolSchema[], winningTools: ReadonlyMap<string, RuledToolLike>, windows: ServedWindows): string | undefined;
/**
* The served time limits from the Tools mount's `timeLimits` FACTS
* (`semantics/coverage/timeLimitFacts.ts` · `TimeLimitFacts`): rendered for the MODEL
* (`semantics/coverage/timeLimits.ts` · `renderTimeLimits` — the one composer the
* limits block asks too), then {@link timeLimitsSentence}. The facts cross
* the mount unrendered so the renderer loads here, under the arm, and never
* on the graph a plain agent loads.
*
* @example
* ```ts
* timeLimitsLine(undefined); // undefined — the turn's reads match what was asked
* ```
*/
export declare function timeLimitsLine(facts: TimeLimitFacts | undefined): string | undefined;
/**
* The time limits an answer states (time step T8's serving placement): the
* result checks that hold this turn — a read narrower, wider or shifted from
* what was asked, a window older than the source keeps, sources on different
* clocks — served to the MODEL as the library's conclusion, LATE, at the
* decision point, in the ONE served time line (TQ13) after the windows'
* halves. The step-7b bench showed why: raw facts on a result are not
* compared by the model, a conclusion served at the decision point is.
*
* `undefined` when nothing holds — a turn whose reads match what was asked
* serves no sentence at all. It carries no opening: {@link timeLine} opens
* the served line once with {@link TIME_LINE_SOURCE}.
*
* @example
* ```ts
* timeLimitsSentence({ period: ['client_activity read less than was asked — asked: …; read: …'], clocks: [] });
* // 'The time the tools read is not the time asked about — client_activity read less than was
* // asked — asked: …; read: …. So the answer to the person states the time each result read and
* // claims nothing about time no result read.'
* ```
*/
export declare function timeLimitsSentence(lines: TimeLimitLines | undefined): string | undefined;
/** What a line says when the run's clock zone is unknown (G15) — the zone is never guessed. */
export declare const UNKNOWN_ZONE_CLAUSE = "the person's time zone is not known";
/** The run clock the served line names (`agent/buildAgentChart.ts` · `timeClockArg`, off the turn's `clock` row). */
export interface ServedClock {
readonly now: InstantText;
/** The person's zone — or, with `zoneUnknown`, only the UTC spelling (G15). */
readonly zone: ZoneName;
readonly zoneUnknown?: true;
}
/**
* The run clock, for the model (G16): the turn's `now` to the minute, its
* weekday and date in the person's zone, the zone named — so the model never
* answers "today" or "this morning" from its training date. Under `.time()`
* it is the first part of the ONE served time line on every request; never
* without it. `undefined` when there is no clock (or it cannot be spelled).
*
* @example
* ```ts
* clockSentence({ now: '2026-10-09T15:40:00Z', zone: 'America/Los_Angeles' });
* // "This turn's time: Friday 2026-10-09 08:40 America/Los_Angeles (UTC-07:00)."
* clockSentence({ now: '2026-10-09T15:40:00Z', zone: 'UTC', zoneUnknown: true });
* // "This turn's time: Friday 2026-10-09 15:40 UTC (the person's time zone is not known)."
* ```
*/
export declare function clockSentence(clock: ServedClock | undefined): string | undefined;
/**
* The ONE served time line's opening: WHO says it — the library, not the
* person. The line is a request-only `user` message, so an unmarked one reads
* as the person talking. Measured twice: the T8 bench's first paid round
* (stopped at 41 runs) — 15 of 15 answers to the limits sentence opened
* "You're right" / "I apologize", and naming the library alone (round 1,
* stopped at 46) still drew "Thank you for the clarification"; the T6b paid
* run, whose windows halves carried no opening — 37 of 37 answers after the
* unconfirmed-call sentence opened "You're right… I apologize". So it also
* says the note is no correction, and how an answer uses it. It says nothing
* about WHEN to answer: the pending half names a call as the next step.
* Applied once, by {@link timeLine}, whichever halves the line holds.
*
* The bytes are the authorship registry's (`lib/saidByPerson.ts` ·
* `LIBRARY_NOTE_OPENING`, one of `LIBRARY_AUTHORED_PREFIXES`), so the line
* that is served LAST on every request under `.time()` is never read as the
* person's words by `isSaidByPerson` — the last `role: 'user'` message of a
* request is the library's, and "this turn" is not (G17).
*/
export declare const TIME_LINE_SOURCE: string;
/**
* The ONE served time line (TQ13): its parts in order — the windows' halves,
* then the limits — opened ONCE with {@link TIME_LINE_SOURCE}. The tools
* slot's one decoration site (`core/slots/buildToolsSlot.ts` · `commitWire`)
* calls it, `callLLM` appends the result LAST to the request, and
* `time-travel/servedView.ts` rebuilds it byte for byte from the same
* committed `timeLine` key. `undefined` when no part has anything to say — a
* turn with nothing to say serves no line (and without `.time()` nothing here
* runs at all).
*
* @example
* ```ts
* timeLine([undefined, timeLimitsLine(facts)]);
* // '[A note from the library that runs the tools — …] The time the tools read is not …'
* timeLine([undefined, undefined]); // undefined
* ```
*/
export declare function timeLine(parts: readonly (string | undefined)[]): string | undefined;
/** One filled argument, as the note names it. */
export interface FilledArgument {
readonly argument: string;
/** A window fill of an `object` form is an object (printed as JSON). */
readonly value: InputValue | Readonly<Record<string, InputValue>>;
readonly hidden: boolean;
/**
* `answered`: the person's answer to the batch ask filled it. `window`: the
* turn's one window of the person's did (the time layer), `from` saying
* whose. Absent: the tool's rule assumed it.
*/
readonly source?: 'answered' | 'window';
/**
* On a `window` fill: the person's words, a `model` reader's unconfirmed
* reading of them, a UI control — or the window the person CHOSE when the
* library asked about their words (`answered`, the lazy word-driven ask).
*/
readonly from?: 'said' | 'derived-from-reading' | 'control' | 'answered';
/**
* On a `window` fill: no form held the window exactly, so the value reads a
* WIDER one (the time layer, step T5b) — `reads-more`, or `tool-trims` when
* the tool declares that it drops the rows outside the asked window.
*/
readonly wider?: 'reads-more' | 'tool-trims';
/**
* The value the call had CARRIED, which the person's answer replaced
* (declared sources: an untraced value is asked about). Absent: the call
* had left the argument out.
*/
readonly carried?: InputValue;
}
/**
* The past-tense note ToolCalls appends to the result of a call that RAN on a
* value the library filled — one bracket per filled argument the call really
* ran with (a clause whose fill a before-tool middleware rewrote is left
* out), after the tool's own bytes. The message then carries `toolChars`, and
* every reader of a result's content as the TOOL's words reads through that
* cut (`lib/toolBytes.ts` · `toolBytesOf`): the evidence index, the answer's
* standing and the answer account's in-view results, the unsupported-argument
* seam's grounded corpus and the empty-lookup seam's producer corpus. The
* value is kept in the note because the model needs it to reason about what
* ran ("no errors in 2h"); it is never evidence for that value, and it never
* hides a reading of the tool's own bytes.
*
* @example
* ```ts
* filledNote('search_logs', [{ argument: 'window', value: '2h', hidden: false }]);
* // '\n\n[window was not in the search_logs call this result answers; the call ran with "2h",
* // the value the tool's rule assumes — recorded as assumed, not as the person's.]'
* ```
*/
export declare function filledNote(toolName: string, fills: readonly FilledArgument[], options?: ServeOptions): string;
/**
* The clause an answered note gains under declared sources: a later call may
* name the person's answer as its source — `turn`, checked against the
* ledger's `answered` rows (`checks.ts` · `checkTurn`). A permission, never a
* promise: the claim is still checked. In the paid step-5 run the model sent
* an answered period again on the next turn with no `from` entry (and so was
* asked again) or left it out (and so was asked again): nothing it was served
* said an earlier ANSWER was a source it could name.
*/
export declare const ANSWERED_SOURCE_CLAUSE = "; a later call may cite that answer in `_findings.from` with source 'turn'";
/** The result a call reads when its tool's rules could not be read at dispatch. */
export declare function unreadableRulesRefusal(toolName: string, reason: string): string;
/**
* The result a call reads when its tool declares argument rules and the agent
* was built without the inputs layer (a ToolProvider served the tool, and the
* build could not see it) — refused rather than run unruled, because
* configured-and-inert looks exactly like configured-and-working.
*/
export declare function unmountedRulesRefusal(toolName: string): string;
/**
* The result a call reads when the person was asked for a ruled argument
* `MAX_ASK_ROUNDS` times and no answer fitted what the tool accepts — the call
* does not run. `expected` is the property schema's own expectation (never the
* person's answer).
*/
export declare function unansweredRefusal(toolName: string, unanswered: readonly {
readonly argument: string;
readonly expected?: string;
}[]): string;
/**
* The result a call reads when it needed a person — its check-in consent gate
* tripped, or the tool itself asked to pause — in a batch that had ALREADY
* paused once, to ask the person for argument values: a resumed batch has no
* second pause to give (at most one human question per resume). The call did
* not finish; nothing about it is decided for the person.
*/
export declare function secondPauseRefusal(toolName: string, why: 'check-in' | 'tool-pause'): string;
/**
* The result a call reads when it was refused BEFORE DISPATCH on its window
* (the time layer, step T5b — time design § 7.2): the window breaks one of the
* tool's declared facts, spans days for a tool that reads one day per call, a
* sent wall time is one the zone's clocks skip, or no form the tool declares
* can read the person's window, exactly or wider (`no-form-holds` — the tool's
* own default never stands in for it). Splitting a window into several calls
* is the model's choice, never the library's.
*
* @example
* ```ts
* timeRefusal('search_logs', 'time-future', { direction: 'past' });
* // 'search_logs was not run on that call: the window it asked for had not happened yet, and the
* // tool declares that its source holds only the past.'
* ```
*/
export declare function timeRefusal(toolName: string, refusal: TimeRefusal, facts: PeriodFacts, argument?: string): string;
/**
* The clause a refusal by the one-question law gains when the refused call
* carried the person's answers: they were KEPT for the tool's next call that
* leaves those arguments out (`kept.ts`), so the model may propose the call
* again without them. Empty when the call carried none.
*
* @example
* ```ts
* keptAnswersNote('purge_logs', ['window']);
* // " The person's answer for window was kept for the next purge_logs call that leaves it out,
* // so the call may be proposed again without window."
* ```
*/
export declare function keptAnswersNote(toolName: string, argumentNames: readonly string[]): string;
/**
* The block's opening line. Stable — tests and readers match on it. Owned by
* the appended section's one composer (`semantics/coverage/answer.ts`), beside the
* limits block's heading, so a reader of an answer's text finds every block
* the framework appends through one module; re-exported here, where the block
* is composed.
*/
export { ASSUMED_BLOCK_HEADING };
/** One assumed value, as the answer's block names it. */
export interface AssumedLine {
readonly toolName: string;
readonly argument: string;
/** The row's value — the tool's own view; `'REDACTED'` when it hides it. */
readonly value: string;
readonly hidden: boolean;
}
/**
* The "Assumed" block: one line per distinct (tool, argument, value) of this
* turn's `default` rows, in the order the rows were filed. Composed by the
* framework from the rows — the model does not write it, so it cannot drop
* it. Empty string when there is nothing assumed.
*/
export declare function assumedBlock(lines: readonly AssumedLine[]): string;
/**
* The "Assumed" block for THIS turn, read off the record: every `default`
* argument row of `turn`, in the order it was filed, less each row a
* before-tool rewrite superseded (its call ran with the rewrite's value, not
* the one the row names). `readDecisions` hands over `middlewareDecisions`; it
* is asked only when a row exists (a run with nothing assumed never reads the
* key), and is absent on an agent with no before-tool chain, whose record can
* hold no tool rewrite. `''` when nothing is assumed. The final branch's armed
* variant (`stages/prepareFinal.ts` · `prepareFinalWithLimitsAndAssumedStage`)
* calls it through `import()`.
*
* @example
* ```ts
* assumedBlockOf(state.findingsLedger ?? [], state.turnNumber, undefined);
* // "Assumed (a tool's rule, not your words):\n- window = \"2h\" (search_logs)"
* ```
*/
export declare function assumedBlockOf(ledger: readonly {
readonly kind: string;
}[], turn: number, readDecisions: (() => readonly unknown[]) | undefined): string;
/**
* This turn's assumed values, as the "Assumed" block's lines: every `default`
* argument row of `turn`, in the order it was filed, less each row a
* before-tool rewrite superseded (its call ran with the rewrite's value, not
* the one the row names). `readDecisions` hands over `middlewareDecisions`; it
* is asked only when a row exists, and is absent on an agent with no
* before-tool chain, whose record can hold no tool rewrite. `[]` when nothing
* is assumed. The block prints each distinct (tool, argument, value) once.
*
* @example
* ```ts
* assumedLinesFor(state.findingsLedger ?? [], state.turnNumber, undefined);
* // [{ toolName: 'search_logs', argument: 'window', value: '2h', hidden: false }]
* ```
*/
export declare function assumedLinesFor(ledger: readonly {
readonly kind: string;
}[], turn: number, readDecisions: (() => readonly unknown[]) | undefined): AssumedLine[];