UNPKG

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