UNPKG

agentfootprint

Version:

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

292 lines (291 loc) • 15.1 kB
/** * replay — who may send an MCP tool call again, and what is known of what each * attempt did: two facts, decided apart and recorded apart. * * Pattern: a declaration read once + one permission rule + one accumulator. * Role: Layer-3 tool transport. `mcpClient({ replay })` reads the app's * declaration (`replayDeclarationOf`) when the client is built. The * call's retry series (`throttleRetry.ts` · `Series`) asks `permit` * before every repeat and records the answer beside the attempt; * `SeriesKnowledge` carries what is known of the series' effect from * one attempt to the next. * * ── THE LAW ────────────────────────────────────────────────────────────────── * PERMISSION to repeat comes only from the app: the (server, tool) pairs it * declares — the server is the client the declaration is given to — and the * failure kinds each tool may be repeated on. Never from the model, a tool's * description or a server's annotations (`readOnlyHint`): nothing here reads * them. It is configuration, read once when the client is built — never a * per-call question. * * KNOWLEDGE of whether an attempt ran comes only from the record and from an * established service guarantee. No reply this library reads is one. A 429 is * not: RFC 6585 §4 defines it as rate limiting, and `Retry-After` says when to * ask again, not that asking is safe. AgentCore Runtime's session-busy body is * not either: AWS documents a retryable conflict while a session starts or * stops, and the body is matched by its code and words, which a server behind * the runtime can send after it ran the call. So every attempt that was sent * and did not complete is `unknown`; `none` is only a request never sent. A * vendor's own SDK may retry its own 429s because the vendor documents its own * service; a generic MCP client cannot assume that of an arbitrary server. * * Neither is read from the other: a permission never makes an effect known, * and an effect never grants a permission. * * ── WHAT A REPEAT IS ───────────────────────────────────────────────────────── * The same validated, authorized request sent again: the same body — the same * arguments — to the same server, in the same session, as the same identity. * Nothing is re-planned. A credential the library vends, or a signature, is * made afresh for each attempt (signatures expire), for that same identity. * * ── THE POLICIES ───────────────────────────────────────────────────────────── * 'compatibility' — the default, kept in 12.0: today's behaviour by name. A * 429 is repeated for every tool, on the ASSUMPTION that the server * refused it before running it. Each such repeat records that * assumption; the attempt's effect stays `unknown`, and the caller sees * the error it always did. It gains none of the strict policy's * guarantees: it never establishes that answers are seen or that * requests are matched to their calls. * 'app-authorized' — opt-in, by name only: a 429 or a network failure is * repeated only when the app's entry for the tool names it. What the * caller sees follows footprintjs's law * (`RequestReplay` · `uncertain`, `callLifetime.ts`): a received refusal * or error keeps the server's error as it came, its effect still * `unknown`; a call that genuinely lacked an answer somewhere — an * attempt with no response, or a request accepted over HTTP whose * JSON-RPC answer never came — and then fails, fails with * `UnknownEffectError`, its final error the cause. That needs each * request matched to its call and each answer seen; a connection that * cannot give both refuses the tool call before its request is sent * (`replayUnavailable.ts`), so the contract never silently weakens. It * never falls back to 'compatibility', and nothing chooses it from the * transport. * * Under both, the adapter the app chose retries its own refusal within the * budget — AgentCore's session-busy reply — as the ADAPTER's policy, on an * assumption it records the same way (`permission: { by: 'adapter', assumes }`), * never as evidence. And THE SIDE-EFFECT GUARD holds: a tool the app declares * as making changes is never repeated automatically unless the server is * declared to deduplicate by an idempotency key the call carries — * footprintjs's law, a retry of an effect without a key can apply it twice. A * key protects only when the server uses it; the library cannot check that. * * The protocol's own requests (`initialize`, `tools/list`, notifications) run * no tool. The declaration does not govern them, and a 429 on one is asked * again under both policies, as it always was. They file no record. */ import type { ToolRequestEffect, ToolRequestPermission, ToolRequestReason, ToolRequestReplayKind } from '../events/payloads.js'; /** * Which rule decides whether a tool call is sent again after a refusal or a * failure. `'compatibility'` is the default, kept in 12.0: it keeps its stated * retry assumption and gains none of the strict guarantees. `'app-authorized'` * applies only when written, and never falls back to `'compatibility'`. */ export type McpReplayPolicy = 'app-authorized' | 'compatibility'; /** * A failure kind the app may declare a tool repeatable on: HTTP 429 * (`'throttle'`), or a request that failed in transport without an answer * (`'network'`, footprintjs's category: a dropped or refused connection, or a * timeout raised below the retry series). Either may have run on the server, so * a repeat after it leaves the call's effect `unknown`. */ export type McpReplayKind = ToolRequestReplayKind; /** One tool's entry in {@link McpReplayOptions.tools}. */ export interface McpReplayTool { /** * The failure kinds this tool may be sent again on. Default: none. Under * `'compatibility'` a 429 is repeated without it, recorded as an assumption; * under `'app-authorized'` only what is named here is repeated. */ readonly on?: readonly McpReplayKind[]; /** * `true` — this tool makes changes. It is then never sent again * automatically, under either policy, unless the server is declared to * deduplicate (`deduplicates`) and the call carries the key. */ readonly changes?: boolean; } /** * How the server deduplicates a tool call: by an idempotency key the call * carries in the named argument. Declare it only when the server does; a key * protects only when the server uses it. */ export interface McpReplayDeduplication { /** * The argument that carries the key — present as a non-empty string in the * call for a repeat to be allowed. Set it in your code (a `toolMiddleware` * that writes it from the call), never from what the model wrote: a key the * model reuses for a different operation makes the server drop that one. */ readonly argument: string; } /** * The app's replay declaration for one MCP server — its client. Read once when * the client is built; a field it does not know is refused there, so a * misspelled `changes` cannot quietly switch the side-effect guard off. */ export interface McpReplayOptions { /** * Default `'compatibility'`, in 12.0 as in 11.x. An explicit * `'app-authorized'` never falls back to it. */ readonly policy?: McpReplayPolicy; /** The tools of this server, by the name the server lists them under. */ readonly tools?: Readonly<Record<string, McpReplayTool>>; /** The server deduplicates by an idempotency key the call carries. */ readonly deduplicates?: McpReplayDeduplication; } interface ToolEntry { readonly on: ReadonlySet<McpReplayKind>; readonly changes: boolean; } /** @internal A declaration, read and checked once. */ export interface ReplayDeclaration { readonly policy: McpReplayPolicy; readonly tools: ReadonlyMap<string, ToolEntry>; /** The argument the server deduplicates by, when it is declared to. */ readonly keyArgument?: string; } /** @internal No declaration: the compatibility policy, no tool named. */ export declare const COMPATIBILITY: ReplayDeclaration; /** * @internal `options` read as a declaration — `undefined` is the compatibility * policy with nothing named. * * @throws TypeError naming the field, never echoing a value, for anything the * rule cannot mean: an unknown field, a policy or kind it does not know, an * argument name that is not one. */ export declare function replayDeclarationOf(options: unknown, where: string): ReplayDeclaration; /** * @internal Whether the declaration needs a retry series even with the 429 * rule off (`retryOnThrottle: false`): a tool declared repeatable on * `'network'`, or the `'app-authorized'` policy — its record and its "may have * run" rest on the series. */ export declare function needsSeries(declaration: ReplayDeclaration): boolean; /** * @internal One request, as the permission rule needs it: the protocol's own, * or a `tools/call` — its tool when the body names one, and whether its * arguments carry the key the server deduplicates by. */ export type ReplayTarget = { readonly kind: 'protocol'; } | { readonly kind: 'tool'; readonly name?: string; readonly keyCarried: boolean; }; /** * @internal `body` — the request exactly as it will be sent — read as a * target. A declaration that names nothing under `'compatibility'` decides the * same for every request, so its bodies are never parsed. */ export declare function replayTargetOf(body: unknown, declaration: ReplayDeclaration): ReplayTarget; /** @internal A failure the series met, as the permission rule reads it. */ export interface MetFailure { /** A refusal a rule recognised (`throttle`, `session-busy`), or a request that failed in transport. */ readonly kind: 'throttle' | 'session-busy' | 'network'; /** * What the adapter that recognised this refusal assumes in retrying it, as * its own policy — set on its rule. Never evidence of the attempt's effect. */ readonly assumes?: 'refused-before-execution'; } /** @internal Why the series did not ask again, by the rule. */ export type ReplayDeclined = Extract<ToolRequestReason, 'not-retryable' | 'not-permitted' | 'changes-without-key'>; /** * @internal The rule's answer: ask again, with what allowed it — absent for a * protocol request, which has no record — or not, and why. */ export type ReplayDecision = { readonly repeat: true; readonly permission?: ToolRequestPermission; } | { readonly repeat: false; readonly declined: ReplayDeclined; }; /** @internal A thrown failure the rule can read: footprintjs's `network` category, or nothing. */ export declare function thrownFailure(error: unknown): MetFailure | undefined; /** * @internal THE rule: may the series send `target` again after `failure`? * Asked before every repeat; the budget is asked after it. */ export declare function permit(declaration: ReplayDeclaration, target: ReplayTarget, failure: MetFailure): ReplayDecision; /** * @internal What is known of a whole series' effect, one attempt at a time. * Uncertainty survives: once any attempt is `unknown`, the series is, whatever * later attempts show — a proven refusal does not resolve it, and a success * does not prove the operation happened only once. Evidence that something ran * (`applied`) is never erased by a later failure. */ export declare class SeriesKnowledge { private unknown; private applied; note(effect: ToolRequestEffect): void; get effect(): ToolRequestEffect; } /** @internal How `callTool` settled, as the call reads it. */ export type CallVerdict = 'completed' | 'tool-error' | 'failed'; /** * @internal One `tools/call` request's replay facts, shared by the series that * sends it, the ledger that watches its answer, and the call that waits for it * — the ledger hands all three the same object (`requestLedger.ts`). * * TWO LAYERS, KEPT APART. The series sees HTTP: whether an attempt got a * response at all, and the status of the one it delivered. A response is not * the MCP call's answer — a 202, or a stream, can be accepted and never carry * it — so the ledger watches the connection's incoming messages for the * JSON-RPC response to THIS request's id. And "its effect is unknown" is not * "it lacked an answer": a received 429 or error is an answer whose effect is * unknown. * * Each fact is written once, by its owner: the series (what it sent, whether an * attempt lacked any response, what it delivered, the policy), the ledger (the * logical answer), the call (how it settled). */ export declare class RequestReplay { readonly id: string | number; private requests; private appAuthorized; private attemptUnanswered; private delivered; private logical; private settled; private failedAsMayHaveRun; /** * @param id the request's JSON-RPC id, how its answer is found. * @param watched whether the connection's incoming messages are observed — * without that, the logical layer is unknown and never decides anything. */ constructor(id: string | number, watched: boolean); /** The series sent the request once more. */ sentOne(): void; /** The series runs under the `'app-authorized'` policy. */ appAuthorizedPolicy(): void; /** * The series: an attempt genuinely lacked an answer — no response arrived * (a lost connection, a timeout, a cut). A received refusal is never this. */ noAnswer(): void; /** The series handed the MCP SDK a response with this status. */ deliveredStatus(status: number): void; /** The ledger: the JSON-RPC response to this request's id arrived. */ answered(): void; /** The call settled; `mayHaveRun` when it failed as "may have run". The first word stands. */ settle(verdict: CallVerdict, mayHaveRun: boolean): void; get sent(): number; /** * Under `'app-authorized'`, a call that does not complete fails as "may have * run" when the call genuinely lacked an answer somewhere: an attempt got no * response, or the HTTP layer accepted the request (a 2xx) and the JSON-RPC * response never came. A chain of received refusals and errors is an answer * throughout: it keeps the server's error. */ get uncertain(): boolean; /** The HTTP layer took the request (2xx), and the logical answer never arrived. */ private get acceptedWithoutAnswer(); get verdict(): CallVerdict | undefined; get mayHaveRun(): boolean; } export {};