UNPKG

agentfootprint

Version:

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

405 lines • 18.3 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 { errorCategory } from 'footprintjs'; /** @internal No declaration: the compatibility policy, no tool named. */ export const COMPATIBILITY = Object.freeze({ policy: 'compatibility', tools: new Map(), }); const POLICIES = new Set(['app-authorized', 'compatibility']); const KINDS = new Set(['throttle', 'network']); /** * @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 function replayDeclarationOf(options, where) { if (options === undefined) return COMPATIBILITY; const read = new DeclarationReader(`${where}: \`replay\``); const top = read.record(options, '', ['policy', 'tools', 'deduplicates']); const policy = top.policy === undefined ? 'compatibility' : read.policy(top.policy); const tools = new Map(); if (top.tools !== undefined) { const named = read.record(top.tools, '.tools', undefined); for (const [tool, entry] of Object.entries(named)) { if (tool.length === 0) read.refuse('.tools', 'tool names that are not empty'); tools.set(tool, read.tool(entry, tool)); } } const keyArgument = top.deduplicates === undefined ? undefined : read.deduplication(top.deduplicates); return Object.freeze({ policy, tools, ...(keyArgument !== undefined && { keyArgument }), }); } /** Reads one declaration, refusing by field what it cannot mean. */ class DeclarationReader { where; constructor(where) { this.where = where; } /** A plain object, its own keys within `allowed` (any key when `undefined`). */ record(value, at, allowed) { if (value === null || typeof value !== 'object' || Array.isArray(value)) { this.refuse(at, 'an object'); } const record = value; if (allowed !== undefined) { for (const key of Object.keys(record)) { if (!allowed.includes(key)) { this.refuse(at, `an object with only ${allowed.map((k) => `\`${k}\``).join(', ')}`); } } } return record; } policy(value) { if (typeof value === 'string' && POLICIES.has(value)) return value; return this.refuse('.policy', "'app-authorized' or 'compatibility'"); } tool(value, tool) { const at = `.tools[${JSON.stringify(tool)}]`; const entry = this.record(value, at, ['on', 'changes']); const on = new Set(); if (entry.on !== undefined) { if (!Array.isArray(entry.on)) this.refuse(`${at}.on`, "an array of 'throttle' or 'network'"); for (const kind of entry.on) { if (typeof kind !== 'string' || !KINDS.has(kind)) { this.refuse(`${at}.on`, "an array of 'throttle' or 'network'"); } on.add(kind); } } if (entry.changes !== undefined && typeof entry.changes !== 'boolean') { this.refuse(`${at}.changes`, 'a boolean'); } return Object.freeze({ on, changes: entry.changes === true }); } deduplication(value) { const declared = this.record(value, '.deduplicates', ['argument']); const { argument } = declared; if (typeof argument === 'string' && argument.length >= 1 && argument.length <= 128 && !hasControl(argument)) { return argument; } return this.refuse('.deduplicates.argument', 'an argument name of 1–128 characters with no control character'); } refuse(at, wanted) { throw new TypeError(`${this.where}${at} must be ${wanted}.`); } } /** * @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 function needsSeries(declaration) { if (declaration.policy === 'app-authorized') return true; for (const entry of declaration.tools.values()) if (entry.on.has('network')) return true; return false; } const PROTOCOL = Object.freeze({ kind: 'protocol' }); /** A request that may be a tool call and names no tool the rule can read: no entry applies. */ const UNNAMED_TOOL = Object.freeze({ kind: 'tool', keyCarried: false }); /** * @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 function replayTargetOf(body, declaration) { if (declaration.policy === 'compatibility' && declaration.tools.size === 0) return UNNAMED_TOOL; if (body === undefined || body === null) return PROTOCOL; if (typeof body !== 'string') return UNNAMED_TOOL; let message; try { message = JSON.parse(body); } catch { return UNNAMED_TOOL; } if (Array.isArray(message)) return message.some(isToolCall) ? UNNAMED_TOOL : PROTOCOL; if (!isToolCall(message)) return PROTOCOL; const params = message.params; if (!isRecord(params)) return UNNAMED_TOOL; const { name, arguments: args } = params; const { keyArgument } = declaration; return { kind: 'tool', ...(typeof name === 'string' && { name }), keyCarried: keyArgument !== undefined && carriesKey(args, keyArgument), }; } function isToolCall(message) { return isRecord(message) && message.method === 'tools/call'; } /** The arguments hold a key under `argument`: a non-empty string. The server judges the rest. */ function carriesKey(args, argument) { if (!isRecord(args) || !Object.prototype.hasOwnProperty.call(args, argument)) return false; const key = args[argument]; return typeof key === 'string' && key.length > 0; } function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } /** @internal A thrown failure the rule can read: footprintjs's `network` category, or nothing. */ export function thrownFailure(error) { return errorCategory(error) === 'network' ? { kind: 'network' } : undefined; } /** * @internal THE rule: may the series send `target` again after `failure`? * Asked before every repeat; the budget is asked after it. */ export function permit(declaration, target, failure) { if (target.kind === 'protocol') { // No tool runs: a refusal is asked again as it always was, a failure is not. return failure.kind === 'network' ? NOT_RETRYABLE : REPEAT_PROTOCOL; } const entry = target.name === undefined ? undefined : declaration.tools.get(target.name); // The side-effect guard comes first, under every policy and every permission. if (entry?.changes === true && (declaration.keyArgument === undefined || !target.keyCarried)) { return CHANGES_WITHOUT_KEY; } const keyed = entry?.changes === true; if (failure.assumes !== undefined) { return repeat({ by: 'adapter', assumes: failure.assumes }, keyed); } if (failure.kind !== 'session-busy' && entry?.on.has(failure.kind) === true) { return repeat({ by: 'declaration', on: failure.kind }, keyed); } if (failure.kind === 'throttle' && declaration.policy === 'compatibility') { return repeat({ by: 'compatibility', assumes: 'refused-before-execution' }, keyed); } return failure.kind === 'throttle' ? NOT_PERMITTED : NOT_RETRYABLE; } const REPEAT_PROTOCOL = Object.freeze({ repeat: true }); const NOT_RETRYABLE = Object.freeze({ repeat: false, declined: 'not-retryable' }); const NOT_PERMITTED = Object.freeze({ repeat: false, declined: 'not-permitted' }); const CHANGES_WITHOUT_KEY = Object.freeze({ repeat: false, declined: 'changes-without-key', }); function repeat(permission, deduplicated) { return { repeat: true, permission: Object.freeze({ ...permission, ...(deduplicated && { deduplicated: true }) }), }; } // ─── What is known of a series' effect ─────────────────────────────── /** * @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 class SeriesKnowledge { unknown = false; applied = false; note(effect) { if (effect === 'unknown') this.unknown = true; else if (effect === 'applied') this.applied = true; } get effect() { if (this.unknown) return 'unknown'; return this.applied ? 'applied' : 'none'; } } /** * @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 class RequestReplay { id; requests = 0; appAuthorized = false; attemptUnanswered = false; delivered; logical; settled; failedAsMayHaveRun = false; /** * @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, watched) { this.id = id; this.logical = watched ? 'awaited' : 'unwatched'; } /** The series sent the request once more. */ sentOne() { this.requests += 1; } /** The series runs under the `'app-authorized'` policy. */ appAuthorizedPolicy() { this.appAuthorized = true; } /** * 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() { this.attemptUnanswered = true; } /** The series handed the MCP SDK a response with this status. */ deliveredStatus(status) { this.delivered = status; } /** The ledger: the JSON-RPC response to this request's id arrived. */ answered() { if (this.logical === 'awaited') this.logical = 'answered'; } /** The call settled; `mayHaveRun` when it failed as "may have run". The first word stands. */ settle(verdict, mayHaveRun) { if (this.settled !== undefined) return; this.settled = verdict; this.failedAsMayHaveRun = mayHaveRun; } get sent() { return this.requests; } /** * 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() { return this.appAuthorized && (this.attemptUnanswered || this.acceptedWithoutAnswer); } /** The HTTP layer took the request (2xx), and the logical answer never arrived. */ get acceptedWithoutAnswer() { const status = this.delivered; return this.logical === 'awaited' && status !== undefined && status >= 200 && status < 300; } get verdict() { return this.settled; } get mayHaveRun() { return this.failedAsMayHaveRun; } } /** Whether `text` holds a control character (C0, DEL or C1). */ function hasControl(text) { for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i); if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return true; } return false; } //# sourceMappingURL=replay.js.map