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 • 15.2 kB
TypeScript
/**
* 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 {};
//# sourceMappingURL=replay.d.ts.map