agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
215 lines (214 loc) • 11.2 kB
TypeScript
/**
* requestLedger — which dispatched tool call an MCP request belongs to, so the
* retry series that sends it files its attempts into THAT call's run; when the
* MCP SDK cancels a request, so the series sending it stops; and whether the
* request's JSON-RPC answer arrived.
*
* Pattern: a per-connection ledger + the SDK transport's `send`, watched in place.
* Role: The middle of the evidence seam (`tools/requestEvidence.ts` is its
* door). The retry series runs inside the transport's `fetch`, which
* the MCP SDK calls with a serialized body and nothing else — no tool
* call, no run. Three hops carry the call down to it:
*
* 1. call → marker `issue`: the MCP tool gets a library-private marker and
* puts it in its request's `_meta` (`callMarker.ts`). It
* survives an SDK that copies the params.
* 2. marker → id the watched `send` takes the marker out of the message
* — the server receives the bytes it always did — and
* keys the call by the JSON-RPC `id` the SDK assigned.
* 3. id → series `claim`: the series reads the `id` back out of the body
* it is about to send.
*
* CANCELLATION DOES NOT WAIT ON ATTRIBUTION. Every `tools/call` in flight gets
* an entry, matched or not, and the SDK says when it gives a request up: it
* sends `notifications/cancelled` naming the request's id — on its own
* timeout, or when the call's signal fires — through this same `send`. The
* ledger aborts that request's series there and then, so a request nobody waits
* for is never asked again, even when it could not be matched to a call.
*
* Under `'app-authorized'` (`strict`), two more things hold:
* - A `tools/call` that reaches `send` without a marker this ledger issued
* and has not used is REFUSED there (`replayUnavailable.ts`,
* `'request-unmatched'`): its answer could not be tied to any call, so it
* is never sent rather than guessed at.
* - Each matched request's answer is AWAITED, by its id: the connection's
* incoming messages are observed for the JSON-RPC response to it. A 202, or
* a stream, is not that answer. The watch ends when the answer arrives,
* when the SDK cancels the request, when the call settles (found by its
* marker, never by its params object) or when the connection closes —
* never when only the HTTP send finished, since a 202 or a stream can still
* carry the answer later.
*
* Whether the answers are observed at all is established once, at
* initialization: the `initialize` answer must pass the observer
* (`answersObserved`). An SDK whose `connect` replaces the transport's
* `onmessage` instead of keeping it leaves the observer out, and a call under
* `'app-authorized'` is then refused before it is sent (`replayUnavailable.ts`).
*
* Ids are the client's, one ledger per connection, so concurrent calls — and
* concurrent runs sharing one client — can never claim each other's request. A
* request that is not a `tools/call` (a `tools/list`, a notification, a
* response) claims nothing.
*
* `send` is installed as an OWN property of the transport instance — the same
* object, its own `send` bound to it, so a transport class with `#private`
* fields works as it always did. Assigning `send` again afterwards THROWS: a
* send that bypassed the ledger would quietly lose every call's attempts.
*
* Each matched `tools/call` also gets ONE `RequestReplay` (`replay.ts`), the
* facts its series, this ledger and its call share: the series' claim hands it
* over by the request's id, the call reads it from the entry it was issued
* (`IssuedCall`), which outlives the connection's close. The
* series writes what HTTP showed; this ledger writes whether the JSON-RPC
* answer arrived; the call writes how it settled.
*
* What it does NOT do: retry, wait, or decide anything. The series is the one
* retry owner; this only tells it whose attempts it is making, and when the SDK
* has stopped waiting for them.
*/
import type { RequestEvidenceSink } from '../tools/requestEvidence.js';
import type { RequestGuard } from './requestGuard.js';
import { RequestReplay } from './replay.js';
/** One call's claim on the request it sends. */
export interface RequestTicket {
/** Where the request's attempts are filed. */
readonly sink: RequestEvidenceSink;
/** What cancels the call's series — the call's own signal, the client's, the call settling. */
readonly signal?: AbortSignal;
}
/** What a series claims for the `tools/call` request it sends. */
export interface ClaimedRequest {
/** The ticket a dispatched call issued for this request — absent when none was matched. */
readonly ticket?: RequestTicket;
/** Fires when the MCP SDK cancels this request (`notifications/cancelled`). */
readonly cancelled: AbortSignal;
/** What this request's series and its call share (`replay.ts` · `RequestReplay`). */
readonly replay: RequestReplay;
/**
* The send gate of the pooled call this request belongs to (`requestGuard.ts`)
* — absent for a call no pool made.
*/
readonly guard?: RequestGuard;
}
/** How a connection's ledger is set up. */
export interface RequestLedgerOptions {
/** The client's name, which a refusal names. */
readonly server: string;
/** The `'app-authorized'` policy is in force: see the module note. */
readonly strict: boolean;
/**
* A pooled client's guard for every message that does not carry its own — its
* open, its listing, its ending (`mcp/pool/`) — and for a `tools/call` that
* reached `send` without a call carrying a guard: the pool answers that one
* with a guard that refuses it, so a pooled request is never sent unchecked,
* under either replay policy. A request it guards is checked before it is
* sent, and every message's send is counted under it. Absent for a client no
* pool built.
*/
readonly guardFor?: (message: unknown) => RequestGuard | undefined;
}
type JsonRpcId = string | number;
/** The request a call's marker was matched to. */
export interface MatchedRequest {
readonly id: JsonRpcId;
readonly replay: RequestReplay;
}
/**
* One call, from `issue` until it settles: the marker it sends, and the
* request matched to it once one was. The call holds this entry itself, so
* what it reads does not depend on the ledger still holding it.
*/
export interface IssuedCall {
readonly marker: string;
readonly ticket?: RequestTicket;
/** The pooled call's send gate, when a pool made the call (`requestGuard.ts`). */
readonly guard?: RequestGuard;
/** Set once, by the watched `send`: a marker matches ONE request. */
readonly request?: MatchedRequest;
}
/** Per connection: the calls issued and not yet settled, the requests being sent, the answers awaited. */
export declare class RequestLedger {
private readonly options;
/** Every call issued and not yet settled — keyed by its marker. */
private readonly issued;
/** Every `tools/call` sent and not yet settled — keyed by its JSON-RPC id. */
private readonly sending;
/** Matched requests whose JSON-RPC answer is awaited (`strict` only) — by id. */
private readonly awaiting;
/**
* A pooled call's guard by its request's id, until the call settles: the SDK's
* `notifications/cancelled` for that request is work started for the call, and
* is counted under it (`requestGuard.ts`).
*/
private readonly guards;
private initializeId;
private initializeAnswered;
private markers;
private calls;
private unboundNamed;
constructor(options?: RequestLedgerOptions);
/**
* A call is about to send its request: its entry, whose `marker` the call
* puts in the request's `_meta` (`callMarker.ts` · `markParams`). `ticket`
* is where its attempts are filed, when the call carries the run's evidence
* port.
*/
issue(ticket?: RequestTicket, guard?: RequestGuard): IssuedCall;
/** `call` settled: forget it, and stop awaiting its request's answer. */
settle(call: IssuedCall): void;
/** The connection closed: nothing it sent is awaited, and no call is pending, any more. */
close(): void;
/**
* Whether this connection's answers pass the observer: the `initialize`
* answer did. Read once the connection is initialized.
*/
get answersObserved(): boolean;
/** How many `tools/call` requests the watched transport has sent — matched to a call or not. */
get toolCallsSent(): number;
/**
* @internal How many entries the ledger holds: calls issued and not settled,
* requests being sent, answers awaited. Zero once every call settled.
*/
get retained(): number;
/** `true` the first time it is asked, then never again: an unmatched request is named once per connection. */
firstUnbound(): boolean;
/**
* Watch `transport`'s `send`: install a wrapped `send` as its OWN property, the
* original bound to the instance itself. Returns the SAME object — or
* `undefined` when it has no `send` to watch or refuses the property, and
* then nothing is watched and no call should expect its attempts filed.
*
* Its incoming messages are watched too: an `onmessage` set before the
* client connects, which the current MCP SDK's `Protocol.connect` keeps and
* calls before its own dispatch. Whether it was kept is known once the
* `initialize` answer has, or has not, passed it (`answersObserved`).
*/
watch<T extends object>(transport: T): T | undefined;
/** Observe incoming messages before whatever handled them already — never instead of it. */
private watchMessages;
/** One incoming message: the `initialize` answer, or the answer to an awaited request. */
private observe;
/**
* The `tools/call` request `body` carries, while its send runs — its ticket
* when a call issued one, and the signal its cancellation fires. `undefined`
* for any other body. Parses only while some request is being sent.
*/
claim(body: unknown): ClaimedRequest | undefined;
/** One message through the watched transport. */
private send;
/**
* Any message but a `tools/call`: sent as it always was — unless a pool
* guards it (`guardFor`), and then a REQUEST is checked first and refused
* unsent, and every message's send is counted under the guard.
* Notifications are never refused: a `notifications/cancelled` is the
* courtesy that tells the server to stop.
*/
private sendOther;
/** The guard of the pooled call a `notifications/cancelled` names, if any. */
private cancelledGuard;
/** The `initialize` request: its answer is the one that proves the observer is in place. */
private noteInitialize;
/** The SDK gave a request up: stop the series still sending it, and stop awaiting its answer. */
private cancelIfAsked;
}
export {};