UNPKG

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
/** * 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 };