agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
308 lines (307 loc) • 16.3 kB
TypeScript
/**
* pool — the state behind `mcpClientPool`: who holds which client, what is
* charged, and what ends when.
*
* Pattern: one state machine, mutated only between awaits.
* Role: §§ 5–7 of the design. Every mutation of the sets and the budget runs
* synchronously — a check and the move it allows are never split by
* an await — so two callers cannot both take the last slot.
*
* - ADMISSION: every listing, call, acquire and ending is admitted against
* `maxOperations` before anything is sent. At each interaction the lazy
* sweep (revoked leases, idle ends, deferred ends) runs FIRST, so it
* reserves its endings before the new operation is admitted.
* - ONE OPEN PER KEY, from a plan resolved at that moment, under the pool's own
* signal and `initTimeoutMs`, never a caller's. A caller that gives up
* detaches alone; the operation that started the open keeps its charge until
* the open's work settles. Past the deadline every waiter is refused at once
* (`init-timeout`), and until the open's work settles the key cannot open
* again. A failure reaches every current waiter and is not cached.
* - THE HANDOFF: every operation waiting for an open holds a RESERVATION on
* the client until it takes its lease, so between the open settling and
* that lease the client is busy — nobody can evict, idle-end or end it. The
* client's state is checked again after every wait, and the lease taken in
* the same step: a lease is never handed out on a client that is ending.
* - MAKING ROOM: the principal's, then the pool's, least recently used IDLE
* client moves to the ending set — or `PoolFullError` at once.
* - ENDING reserves an ending slot and an operation slot FIRST; when either is
* full the client stays in the open set, counted, reported deferred, and is
* ended when slots free. Every end goes through `terminate()` — but a client
* whose handle this process lost: nothing can be sent for it, so its ending
* is its local close alone, charged the same way until it settles.
* - SETTLEMENT, never an abort, returns a slot (`work.ts`).
* - NOTHING UNCHECKED: on a pooled client every request carries a guard — its
* call's, or the client's open, listing or ending — and a request with none
* is refused before it is sent (`request-unmatched`), at the watched `send`
* and at the pool's own fetch alike.
*/
import type { Tool } from '../../tools/tools.js';
import type { McpTerminateOutcome } from '../types.js';
import { ClientSet, type PreviousEnd } from './clientSet.js';
import { SharedDefinitions, type Definition, type DefinitionList } from './definitions.js';
import { McpPoolRefusedError, PoolFullError, type McpPoolLimit } from './errors.js';
import { type PoolKey } from './identityKey.js';
import { type PoolSettings } from './options.js';
import { type ClientPlan } from './plan.js';
import { PooledClient, type EndReason } from './pooledClient.js';
import { Reporter, type UseFacts } from './record.js';
import { OperationBudget, Work, type Operation, type OperationKind, type WorkKind } from './work.js';
/** An app lease, as the pool tracks it for `maxLeaseMs`. */
export interface HeldLease {
readonly client: PooledClient;
readonly takenAt: number;
/** Revoked: its next use throws `lease-expired`. */
revoked: boolean;
/** Released: idempotent. */
released: boolean;
}
/** Where a list's definitions live: a client's own, or a shared entry. */
export interface ServedList {
readonly list: DefinitionList;
readonly shared: boolean;
/** The definitions context: `pc:` or `sh:`. */
readonly context: string;
}
/** What `stats()` returns: counts, never keys. */
export interface McpPoolStats {
/** Clients in the open set: opening, busy or idle. */
readonly open: number;
readonly busy: number;
readonly idle: number;
readonly opening: number;
/** Clients being ended, until each ending's work has settled. */
readonly ending: number;
/** Operations charged. */
readonly operations: number;
/** Shared definition entries kept. */
readonly entries: number;
/** Charged work past its deadline that has not settled, by what it is doing. */
readonly unsettled: Readonly<Record<WorkKind, number>>;
/** Endings whose remote outcome was not confirmed. */
readonly unresolvedEndings: number;
}
export declare class Pool {
readonly settings: PoolSettings;
readonly clients: ClientSet;
readonly budget: OperationBudget;
readonly shared: SharedDefinitions;
readonly reporter: Reporter;
/** The pool's own signal: what every open, listing and call runs under; aborted at `close()`. */
private readonly lifetime;
private readonly leases;
private readonly settleWaiters;
/** Waiting for some client to become idle (`terminate`, `close`). */
private readonly idleWaiters;
closed: boolean;
unresolvedEndings: number;
constructor(settings: PoolSettings);
now(): number;
get signal(): AbortSignal;
/**
* The lazy sweep, run at the start of every interaction: leases past
* `maxLeaseMs` revoked, idle clients ended, deferred endings retried — all of
* it before a new operation is admitted.
*/
interact(): void;
/**
* Admit one operation, or refuse at once: `closed`, or `PoolFullError`
* (`limit: 'operations'`) — before anything is sent.
*/
admit(kind: OperationKind, phase: WorkKind, callerSignal: AbortSignal | undefined): Operation;
refused(reason: McpPoolRefusedError['reason'], cause?: unknown): McpPoolRefusedError;
full(limit: McpPoolLimit): PoolFullError;
/**
* A library lease on `key`'s client for `operation`, opening one when the key
* has none (inside the operation's slot). `plan` is the plan resolved for a
* new client; a key that already has one keeps its own. The lease is
* released when the operation SETTLES.
*/
leaseClient(key: PoolKey, operation: Operation, facts: UseFacts, plan?: ClientPlan): Promise<PooledClient>;
/**
* Wait for `client`'s open inside `operation`, holding a reservation on it
* all the while — between the open settling and this lease the client is
* busy, so nobody can evict, idle-end or end it — then check it again and
* lease it in the same step, with no wait between. `undefined` when it no
* longer serves: the caller's next round finds the key's next client.
*/
private leaseOnceOpen;
/** `client` may have just become idle: a deferred ending may run, and a waiter for idleness may go on. */
private becameIdle;
private takeLease;
/** One lease ended: the client may now be idle, and a deferred ending may run. */
releaseLease(client: PooledClient): void;
private notifyIdle;
/** An app lease: a hold on a client for as long as the app keeps it, at most `maxLeaseMs`. */
holdLease(client: PooledClient): HeldLease;
/** End an app lease — at most once, whether released or revoked. */
endLease(lease: HeldLease): void;
private revokeExpiredLeases;
/** Every app lease is revoked: the pool is closing. */
revokeAllLeases(): void;
/**
* Open a client for `key`: make room, resolve its plan, admit it to the open
* set, and start the one open. The operation that started it tracks the open's
* settlement, so its charge stays until the open's work has settled.
*/
private openFor;
/** The one open of `client`, under the pool's own signal and `initTimeoutMs`. */
private startOpen;
private dropFailedOpen;
/** Wait for `client`'s open inside `operation`: the caller's signal detaches the caller only. */
private waitOpen;
/** What `mcpClient` is opened with: the connection-shaping options, the pool's signal. */
private clientOptions;
/** What only a pool asks of the client it opens (`clientPort.ts`). */
private seamFor;
/**
* The guard for a message on `client` that carries none of its own: a tool
* call always brings its call's guard, so one without it is refused; any
* other request runs under the client's open, listing or ending — and with
* none under way, it is refused too. Nothing pooled is sent unchecked.
*/
private guardOf;
/** The guard of a request nothing covers: it refuses, so the request is never sent. */
private readonly unmatched;
/**
* The pool's own fetch, outermost on every pooled transport: a request is
* checked against its guard (the pooled call's, else the client's phase) and
* counted under it; the guard's signal is linked in; and the answer is read
* for the two pieces of evidence the pool classifies on — a 404 the MCP server
* itself sent to a request that carried its session id, and a handle this
* process lost. The client's own standalone stream (a GET), a notification and
* an answer to the server's own request are the client's, not an operation's:
* they pass untouched. Any other request with no guard is refused unsent.
*/
private pooledFetch;
/**
* Make room for a new client of `key`: the principal's least recently used
* idle client, then the pool's, moves to the ending set — or `PoolFullError`
* at once, naming the budget. There is no queue.
*/
private makeRoom;
/**
* Move `victim` to the ending set to make room. When the slots cannot be
* reserved, the arrival is refused at once, and the victim stays in the open
* set, counted, reported deferred — ended once slots free.
*/
private evict;
/** An ending slot and an operation slot, both, or which one is full. */
private reserveEnding;
/**
* End `client` for `reason` — when it is idle and both slots can be reserved.
* Otherwise it stays in the open set, still counted, and is reported deferred
* (once); it is ended when its last lease ends or slots free.
*/
endClient(client: PooledClient, reason: EndReason): void;
private defer;
/**
* Move `client` into the ending set under `operation`, and end it through
* `terminate()` — or, for a lost handle, through its local close alone.
*/
private startEnding;
private noteEnded;
/**
* A client whose handle this process lost: nothing can be sent for it, so it
* leaves its key at once — the next use opens the key's next client — and is
* ended under the ending rule like any other: its ending is its LOCAL close,
* charged an ending slot and an operation slot until that close settles, its
* remote outcome recorded unresolved. Busy, or with no slots free, it waits —
* still counted in the open set, reported deferred.
*/
lostHandle(client: PooledClient): void;
/** A client whose session the server ended: no new lease; ended (through `terminate()`) when idle. */
expired(client: PooledClient): void;
private sweepIdle;
/** Retry every deferred ending whose client is now idle. */
retryDeferred(): void;
/** Where `client`'s definitions are served from: its shared entry, or its own list. */
servedListOf(client: PooledClient): ServedList;
/**
* The definitions context `client` serves under NOW: the app's `share` asked
* again over the client's own configuration. A changed answer moves the
* client to the new context and stops serving its own list, so every tool
* bound under the old one is refused before it is sent.
*
* @throws `binding-changed`, `share`'s error as its cause, when the answer
* cannot be had — never the old context
*/
refreshContext(client: PooledClient): PooledClient['definitions'];
/** Does `context` still name the definitions context `client` serves under? False when it cannot be had. */
contextHolds(client: PooledClient, context: string): boolean;
/**
* The list `client` serves under NOW, listed again on it when it cannot be
* served as it stands — one listing at a time per list; a second caller joins
* it inside its own slot. Every attempt asks the app's names again
* (`refreshContext`), so a listing that a moved context discarded or refused
* at its send is listed once more under the context of now, never written
* into the one it began under. A listing raced by an invalidation is
* discarded too. Refused (`stale-tools`) when three attempts serve nothing.
* What it returns was checked against the context with no wait since.
*/
ensureListed(client: PooledClient, operation: Operation): Promise<ServedList>;
/**
* What `client` can serve under the context of now — asked of the app's names
* here — or, when nothing can, the list it should list. Too long to share:
* the conversation's own client keeps that list, under the same context.
*/
private servableNow;
/**
* One listing on `client` for `served`'s context. Its requests are sent only
* while that context holds — asked at every boundary they cross — and its
* answer is written only if the context still holds when it arrives and the
* list's generation held: an answer the app's names moved under belongs to
* no context, and is dropped.
*/
private startListing;
/**
* A request failed: when the pool's fetch read the server's own 404 for the
* session, or this process lost the handle, the client leaves its key — the
* next use opens the next client under the SAME key — and the error says so.
*/
classifyFailure(client: PooledClient, work: Work, error: unknown): RequestEvidenceOutcome;
/** The server said a client's tool list changed: that client's list and its shared entry. */
private toolsChangedOn;
/** The app's permission signal: every list in the scope. */
permissionsChanged(scope: {
readonly tenant?: string;
readonly principal?: string;
}): void;
/** The server changed: every list. */
toolsChanged(): void;
/**
* Stop new leases on every client `scope` names, at once; wait for their
* in-flight work up to `waitMs`; then end each one whose slots can be
* reserved. The rest are reported deferred and ended when they can be.
*/
terminate(matches: (key: PoolKey) => boolean, reason: EndReason, waitMs: number): Promise<readonly McpTerminateOutcome[]>;
/** End each of `clients` whose slots can be reserved; the outcomes of those that started. */
private endNow;
/** Wait until every client in `clients` is idle, or `waitMs` passed. */
private waitIdle;
/**
* Close: refuse new work, revoke app leases, abort in-flight work through the
* pool's own signal, then end every client as slots can be reserved — at most
* `maxEnding` at a time — in rounds, each bounded by `waitMs` (and each
* ending's answer by its own deadline). What could not be ended yet is
* reported deferred and ended when slots free; work still unsettled stays
* charged until it settles.
*/
closeAll(waitMs: number): Promise<readonly McpTerminateOutcome[]>;
/** Wait until an ending slot and an operation slot are free, or `waitMs` passed. */
private waitSlots;
/** Refuse new work, revoke app leases, abort in-flight work through the pool's signal. */
shutDown(): void;
/** Resolve once every charged operation and every ending has settled. */
settled(): Promise<void>;
private quiet;
private notifySettled;
stats(): McpPoolStats;
/** The bound shape of every definition in `served`, for a client of `key`. */
definitionsOf(served: ServedList): readonly Definition[];
/** The tools a list's client-own entries carry, by name (per-client lists only). */
ownTool(served: ServedList, name: string): Tool | undefined;
}
/** What a failed request proved about its client, if anything. */
export type RequestEvidenceOutcome = 'expired' | 'handle-lost' | undefined;
/** Re-exported for the bound tool: which end reasons name a previous client. */
export type { PreviousEnd };