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
JavaScript
/**
* 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