/** * core/agent/repeatedCall — tell a model when it has already asked this. * * ── The measured failure ──────────────────────────────────────────────────── * A traced production run: the model called one tool three times in a row with * byte-identical arguments and got a byte-identical result each time. The tool * was doing its job — the arguments named a filter the backend did not honour, * so the same rows came back — and the model read the same rows as a fresh * answer each iteration, concluded nothing had changed, and tried again. Three * calls, three identical results, one wasted turn, and nothing anywhere in the * loop that could say "you have done this". * * That class of loop is invisible from inside the conversation, because the * history genuinely does show three separate calls that each returned data. The * only party with the whole picture is the framework, which watched all three * land. * * ── The intervention: a NOTE, never a refusal ─────────────────────────────── * On the Nth identical (tool, args) → identical result, the framework appends * one sentence to that result. It does not block the call, does not change the * result, does not error, and does not stop a later identical call from * running. That restraint is the design: * * • **A repeat is sometimes correct.** Polling a job until its status * changes is a loop of identical calls returning identical results, on * purpose. Refusing it would break a legitimate pattern to fix an * illegitimate one, and the model is the only party that knows which it is * doing. * • **A note is evidence; a refusal is a wall.** The model reads what * happened and decides. That is the same posture every teaching refusal in * this library takes, minus the refusal. * * ── Why the result must match too ─────────────────────────────────────────── * Identical ARGUMENTS alone are not evidence of anything: a "check status" call * with the same arguments returning a DIFFERENT status is progress. It is the * identical RESULT that makes the repeat pointless, so both halves are * required. Which also means the note can say something true and specific — * *calling again will not change it* — rather than a vague "you seem to be * repeating yourself". * * ── The escape hatch: `repeatedWhen: 'arguments'` ─────────────────────────── * The rule above assumes a tool's result is a FUNCTION of its arguments — * nothing different in, nothing different out. Some tools break that on * purpose: a screen/UI tool that stamps a fresh version number, timestamp, or * cursor into every answer, so a human or a downstream cache can tell which * render is current. For a tool like that the default fingerprint — which * folds the RESULT in — never repeats, so the detector is silently inert for * it even when the model fires the byte-identical call twice in a row. This * was found in a real recorded failure: an agent re-fired a completed * navigation sequence and nothing noticed, because every fire stamped a fresh * value and so looked like new information each time. * * A tool that declares `repeatedWhen: 'arguments'` on itself (see * `Tool.repeatedWhen` in `core/tools.ts`) tells the ledger to fingerprint on * the ARGUMENTS ALONE — the result never enters the key, and the note's * wording changes to match (it cannot honestly say "returned exactly this * result" when the whole point is that the result is not being compared). * Declared, never inferred, for the same reason `capabilities` and * `resultClass` are: only the tool's author knows whether its result is * signal or a stamp, and guessing from a name or a shape would rest a * detector on a heuristic. A tool that does NOT declare it keeps today's rule * exactly — this is additive, and every function below is byte-identical on * its default path. * * ── What is stored, and what is deliberately not ──────────────────────────── * A short non-cryptographic FINGERPRINT of the arguments and the result, never * the values. Tool arguments routinely carry the things redaction exists for, * and a fingerprint answers the only question this feature asks ("is this the * same?") and answers nothing else. * * ── And WHERE it is stored: beside the run, never inside its state ────────── * The counters live in {@link repeatedCallLedgers}, a run-keyed map the * dispatch loop holds — NOT on tracked scope. That is not a tidiness * preference, it is the zero-cost law: * * • Tracked scope IS the commit log, the snapshot, the narrative, every * recording and every causal slice. A key written there on every tool * landing would appear in all of them for every existing agent the moment * it upgraded — a turn that never repeated anything would no longer be * byte-identical to the release before, and a consumer asserting a state-key * set would break for a feature it never asked for. * • A within-turn counter is not conversation state. Nothing resumes from it, * nothing branches on it later, and no reader of a trace needs it: the * repeat itself lands on the record as `agentfootprint.tools.repeated_call`, * which is the telemetry channel this library puts per-attempt facts on * (the same reasoning as retries and streaming tokens, and the same * reasoning that keeps a consent record off tracked state). * * So an agent that never repeats a call is byte-identical to 9.25 in behaviour, * in state AND in events — the ledger exists only in memory, only for the * duration of a run, and only where a tool actually landed. */ /** How many identical (tool, args, result) landings trigger the note. The * SECOND one: the first repeat is the first moment the fact exists. */ export declare const REPEATED_CALL_THRESHOLD = 2; /** * The ledger: fingerprint → how many times this exact (tool, args, result) has * landed in this run. * * A plain record of small strings to numbers — a few dozen bytes per distinct * call, and nothing in it that could not be printed in a bug report. */ export type RepeatedCallLedger = Readonly>; /** * Where the ledgers live: one per RUN, held beside the dispatch loop. * * ── Why the run and not the conversation ──────────────────────────────────── * The failure this counts is a model calling the same thing twice inside ONE * answer. A counter that spanned turns would tell somebody who asked the same * question again tomorrow that they had already asked — which is true, and not * what the note says. `runId` is the exact grain, and it is already the key * every event on this run is stamped with. * * A resume mints a fresh `runId`, so a run that was paused for a person and * continued starts counting again. That is the honest reading of "this turn": * the framework watched half of it. * * ── Why BOUNDED ───────────────────────────────────────────────────────────── * One agent instance serves many runs, and nothing tells this map when a run * ended — a run can also end by throwing, by pausing forever, or by the process * losing interest. So it keeps the most recent few and drops the rest. The cost * of dropping one is exactly one note that is never given; it can never produce * a note for the wrong run, because a run only ever reads counters filed under * its own id. */ export interface RepeatedCallLedgers { /** This run's counters, or `undefined` before its first tool landed. */ read(runId: string): RepeatedCallLedger | undefined; /** File this run's counters, evicting the least recently written run when * the map is full. */ write(runId: string, ledger: RepeatedCallLedger): void; } /** Build the run-keyed holder. One per agent — the chart is built once, so the * loop that owns this is built once too. */ export declare function repeatedCallLedgers(maxRuns?: number): RepeatedCallLedgers; /** * The two fingerprints for one landing, and the ledger key that joins them. * * Kept apart on the way out so a debugger reading the event can tell "same * call, different answer" from "same call, same answer" — without either value * being present anywhere. * * `mode: 'arguments'` drops the result fingerprint out of the KEY (a `'*'` * stands in for it — never an 8-hex digest, so an arguments-only key can * never collide with a default-mode key for the same tool+args). The result * fingerprint is still computed and still returned: a tool declaring the * mode still gets a truthful digest on the record, it just isn't part of * what decides "is this the same call". */ export declare function repeatedCallKey(toolName: string, args: unknown, result: string, mode?: 'arguments'): { readonly key: string; readonly argsFingerprint: string; readonly resultFingerprint: string; }; /** What one landing came to. `occurrences` counts THIS one. */ export interface RepeatedCallOutcome { /** The ledger to write back to scope. */ readonly ledger: RepeatedCallLedger; /** How many times this exact call+result has now landed this run. */ readonly occurrences: number; /** The sentence to append, or `undefined` when this landing is not a * repeat (or the note has already been given for it). */ readonly note?: string; /** Digest of the arguments — never the arguments. Rides the typed event. */ readonly argsFingerprint: string; /** Digest of the result — never the result. */ readonly resultFingerprint: string; /** Which key this landing was fingerprinted under. Present only when the * tool declared `repeatedWhen: 'arguments'` — omitted, not a `'result'` * default value, because the omission IS the default: every existing * caller reads `undefined` here today and must keep reading it. */ readonly mode?: 'arguments'; } /** * Record one landing and decide whether it earns the note. * * PURE — takes the ledger, returns the next one. The dispatch loop owns reading * and writing scope, so this file never imports the agent and can be tested as * a table. * * The note fires ONCE per distinct call, on the Nth landing exactly. A third * and fourth identical call add nothing further: the model has been told, and * repeating the lesson every iteration would be the framework doing the very * thing it is complaining about. * * `mode` — omitted for every tool that has not opted in, so this parameter is * additive: an existing 3-arg call site keeps compiling and keeps producing * the exact ledger key and note it always has. Pass `'arguments'` (read off * `Tool.repeatedWhen` at the call site — this function never imports the * agent, so it cannot read it itself) to fingerprint on the arguments alone; * see the module header for why a tool would ever want that. */ export declare function noteRepeatedCall(ledger: RepeatedCallLedger | undefined, toolName: string, args: unknown, result: string, mode?: 'arguments'): RepeatedCallOutcome;