agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
363 lines • 18.8 kB
TypeScript
/**
* toolSessions — the end-signal a tool can be handed, and the tier that fires it.
*
* Pattern: Registrar + Template Method for the firing matrix.
* Role: tools/ layer primitive. `ToolExecutionContext.onTeardown` registers here;
* `Agent.run`'s terminals, `RunnerBase.closeToolSessions` and
* `RunnerBase.shutdown` fire it.
* Emits: nothing itself. `register()` ANSWERS what it did (started/reused) so
* the caller — still inside a stage — can emit with a real
* `runtimeStageId`, and teardown REPORTS (see {@link ToolSessionReport})
* so the runner can emit the two that fire after the last stage.
*
* ── The gap this closes ─────────────────────────────────────────────────────
* A session-based tool service (a managed code interpreter, a headless browser)
* is Start → Invoke ×N → Stop. Before 9.7.0 a `Tool` had nowhere to hold the
* middle of that: `ToolExecutionContext` carried no run or session identity, and
* nothing in the framework ever said "this is over". So a tool either paid
* start-up on every call, or held the session in a module-level map — which, in
* a standing agent serving many people from one process, hands ONE live sandbox
* to whoever calls next. That is not a leak of memory; it is a leak of a
* filesystem, an environment and half-run state, across users.
*
* ── Why the registration lives on the CONTEXT ───────────────────────────────
* Not `Tool.dispose()`: a `Tool` is a singleton, built once and shared by every
* run and every session, so "dispose the tool" cannot mean "dispose this
* caller's session".
* Not a lifecycle PORT the consumer wires: that makes the common case (one tool
* that happens to hold a session) a wiring exercise, and the tool that knows the
* key is the one that cannot reach the port.
* `ctx.onTeardown(cleanup, { scope, key })` is the only seam where the key and
* the resource are both in hand at the same instant.
*
* ── The seven laws ──────────────────────────────────────────────────────────
* 1. **At most once, ever** per registration — the `stopped`-flag mechanism
* `strategies/lifecycle.ts` uses for strategies, applied one layer down.
* 2. **Idempotent by `(tool, scope, key)`, first wins.** A second registration
* under the same key does not replace the first — the first is the one
* holding the live handle; replacing it would drop that handle on the
* floor. The repeat is a TOUCH: it refreshes liveness (that is how the
* idle sweep and the LRU learn a session is still in use) and answers
* `'reused'`.
* 3. **Reverse order of registration**, settled with `Promise.allSettled`.
* 4. **Bounded** by `timeoutMs` (default 5s). Teardown is on the SIGTERM path;
* an unbounded `stop()` turns a container stop into a wait for SIGKILL.
* 5. **Never throws into the run** — but **never silent** either. Every
* failure, and every timeout, is reported. "Swallowed AND silent" is what
* separates a passive recorder from a resource that did not get released.
* 6. **Tolerates "already gone."** The far side reaps idle sessions on its own
* schedule; a `Stop` on a session AWS already collected is a no-op, not an
* incident. It still reports, so the difference is visible.
* 7. **Nothing live is ever persisted.** A checkpoint carries what survives
* `structuredClone`; a session handle does not. A resumed run re-opens.
*
* Zero-cost when unused: no tool registers → the runner never builds a tier →
* the terminal path is one `undefined` check.
*/
import type { RunContext } from '../bridge/eventMeta.js';
import type { ToolExecutionContext } from './tools.js';
/**
* How long a registered cleanup is allowed to live.
*
* - `'call'` — until `tool.execute` settles (resolve OR throw). Available
* at every door, including `mcpServe`, where a served call is
* the only unit there is.
* - `'run'` — until the run reaches a terminal that is NOT a pause.
* **A pause is not a terminal**: a check-in on a code
* interpreter is a person deciding, and tearing the sandbox
* down there destroys the exact state the resume needs.
* - `'session'` — until the composition root says the hosting session ended
* (`agent.closeToolSessions({ sessionId })`). Nobody but the
* composition root can know that: a request/reply deployment
* has no end-of-session signal, and inventing one would be a
* library guessing about somebody else's protocol.
* - `'shutdown'` — until `agent.shutdown()` (which `standingAgent`'s close
* calls). The backstop under all three.
*/
export type TeardownScope = 'call' | 'run' | 'session' | 'shutdown';
/** Why a cleanup ran. Reported on `agentfootprint.tools.session_closed`. */
export type TeardownReason = 'call-end' | 'run-end' | 'session-end' | 'shutdown' | 'idle' | 'evicted';
/** What a tool says about the cleanup it is registering. */
export interface TeardownOptions {
/** Default `'run'`. Refused by name when the door cannot honour it — see
* {@link ToolExecutionContext.teardownScopes}. */
readonly scope?: TeardownScope;
/**
* Dedup key within `(tool, scope)`. Omitted → the tool gets one registration
* per scope, which is right for a tool that holds exactly one thing.
*
* Derive it with {@link toolSessionKey} rather than by hand: a key that is
* narrower than the identity it isolates is the cross-binding bug, and a key
* that is wider is a silent latency change.
*/
readonly key?: string;
/** The adapter holding the resource — `CodeRunner.id`, say. Reported so a
* row names its backend instead of only its tool. */
readonly runnerId?: string;
/** One free-form fact about what was opened (the language, the browser
* profile). Reported as-is; never a place for user data. */
readonly label?: string;
}
/** The call a registration came from — what the firing matrix filters on. */
export interface ToolSessionOrigin {
/** Actual registration-time event context, retained only in memory. */
readonly runContext?: RunContext;
readonly tool: string;
readonly toolCallId: string;
/** Absent when the door has no run (`mcpServe`). */
readonly runId?: string;
/** Absent unless the run is bound to a hosting conversation. */
readonly sessionId?: string;
}
/**
* What `register()` did — a new session, or a call joining one already held.
*
* Returned rather than reported, because the two halves of a session's life
* happen in different places and only one of them has a stage to be stamped
* with. A start and a reuse happen INSIDE `tool.execute`, where the caller
* still holds the scope and can emit with the real `runtimeStageId`; a close
* happens after the run's last stage committed, where nothing does. Reporting
* both through one channel would have meant stamping a live, mid-stage event
* with the teardown pseudo-stage — a small lie, and exactly the kind that makes
* a trace disagree with itself.
*/
export type RegisterOutcome = 'started' | 'reused';
/**
* A teardown that happened. The runner turns it into
* `agentfootprint.tools.session_closed` / `_close_failed`.
*/
export interface ToolSessionReport {
readonly kind: 'closed' | 'close-failed';
readonly tool: string;
readonly scope: TeardownScope;
/** {@link hashSessionKey} of the isolation key — never the key. */
readonly keyHash: string;
readonly runnerId?: string;
readonly label?: string;
/** Which firing site ran it. */
readonly reason: TeardownReason;
/** Wall-clock from registration to close. */
readonly durationMs: number;
/** `close-failed` only. */
readonly error?: string;
readonly errorClass?: string;
}
/**
* Derive the isolation key a tool should hold a session under.
*
* ONE implementation, exported, because the derivation is the security
* boundary. Returns `undefined` when the facts the scope needs are absent —
* which is a refusal to guess, not a failure: the caller decides whether to
* narrow the scope loudly or refuse the call.
*
* ```
* session → t=<tenant|_>/p=<principal|_>/s=<sessionId> requires sessionId
* run → t=<tenant|_>/p=<principal|_>/r=<runId> requires runId
* call → c=<toolCallId> always available
* ```
*
* **`sessionId` alone must never key a live session.** The hosting port says
* why in its own words: a `sessionId` "is not identity and must never be
* trusted as identity on its own: anyone who can reach the host can put any
* string here, including someone else's." A code interpreter keyed on
* `sessionId` alone hands a live sandbox — files, environment, half-run state —
* to anyone who guesses one. Tenant and principal are in the key whenever they
* exist; a deployment that has no principal is thereby STATING it is
* single-principal rather than quietly assuming it.
*
* `'shutdown'` is not a key scope: it is when everything goes, not a thing to
* hold one session under. Ask for it and you get `undefined`.
*
* @example
* const key = toolSessionKey(ctx, 'run');
* if (!key) throw new Error("run_code: scope 'run' needs a run …");
*/
export declare function toolSessionKey(ctx: Pick<ToolExecutionContext, 'toolCallId' | 'runId' | 'sessionId' | 'identity'>, scope: TeardownScope): string | undefined;
/**
* A short, stable digest of an isolation key.
*
* The key carries tenant, principal and the hosting `sessionId`. Publishing it
* on the event wire would put a user identifier into every exporter's payload —
* so the wire carries this instead, which is enough to JOIN two rows and not
* enough to name whose they are. `meta.sessionId` already carries the session
* legitimately (9.4.0); the payload does not repeat it.
*
* SHA-256, first 12 hex chars, wherever `node:crypto` resolves. In a browser
* bundle, where it does not, this falls back to the package's non-cryptographic
* FNV-1a digest — stated here rather than implied, because a fallback nobody
* documented is how "hashed" comes to mean less than a reader assumed.
*/
export declare function hashSessionKey(key: string): string;
/** Defaults, named so a test and a docstring cannot drift from the code. */
export declare const TOOL_TEARDOWN_TIMEOUT_MS = 5000;
/** A session untouched this long is swept on the tier's next interaction. */
export declare const TOOL_SESSION_IDLE_MS = 900000;
/** How many live registrations one tier holds before it evicts the coldest. */
export declare const TOOL_SESSION_MAX_LIVE = 64;
export interface ToolSessionTierOptions {
/** Per-cleanup ceiling. Default {@link TOOL_TEARDOWN_TIMEOUT_MS}. */
readonly timeoutMs?: number;
/** Idle ceiling for the lazy sweep. Default {@link TOOL_SESSION_IDLE_MS}. */
readonly idleMs?: number;
/** Live-registration ceiling before LRU eviction. Default
* {@link TOOL_SESSION_MAX_LIVE}. */
readonly maxLive?: number;
/** Where TEARDOWN reports go. The runner wires this to the typed event
* dispatcher. Starts and reuses are the caller's to announce — see
* {@link RegisterOutcome}. */
readonly report?: (report: ToolSessionReport, origin: ToolSessionOrigin) => void;
/** Clock seam — tests drive idle and duration without waiting. */
readonly now?: () => number;
}
/**
* The teardown tier for one runner.
*
* Holds nothing until a tool registers, and the runner builds one only when
* that happens — so an agent whose tools hold no sessions pays a single
* `undefined` check at each terminal and nothing else.
*/
export declare class ToolSessionTier {
private readonly registrations;
private readonly timeoutMs;
private readonly idleMs;
private readonly maxLive;
private readonly report;
private readonly now;
private seq;
constructor(options?: ToolSessionTierOptions);
/** Live registrations. Diagnostics and tests. */
liveCount(): number;
/**
* Resolve once every teardown this tier started IN THE BACKGROUND has
* settled.
*
* Two firings have no caller to await them: the idle sweep and the LRU
* eviction, both of which are triggered by a SYNCHRONOUS `register()` inside
* somebody else's `tool.execute`. They cannot be awaited there without making
* a cleanup's latency the tool's latency, so they run detached — and this is
* how a shutdown path, or a test, joins them.
*/
settled(): Promise<void>;
/** Fires nobody is awaiting — see {@link settled}. */
private readonly background;
/** Start a detached fire and keep it joinable. */
private detach;
/**
* Register a cleanup, or TOUCH the one already holding this key (law 2).
*
* Synchronous and total: it never throws, because it runs inside somebody
* else's `tool.execute`. Scope support is judged one layer up, where
* `teardownScopes` is known.
*
* @returns `'started'` for a new session, `'reused'` when this call joined
* one already held, with `calls` counting how many have now shared it. The
* CALLER announces it, because the caller is the one still inside a stage
* and able to stamp a real `runtimeStageId` — see {@link RegisterOutcome}.
*/
register(origin: ToolSessionOrigin, cleanup: () => void | Promise<void>, options?: TeardownOptions): {
readonly outcome: RegisterOutcome;
readonly keyHash: string;
readonly calls: number;
};
/** Fire every `'call'` registration this tool call opened. */
fireCall(toolCallId: string): Promise<void>;
/**
* Fire `'run'`-scoped registrations at a run terminal.
*
* Called from `Agent.run`/`resume` at a terminal that is NOT a pause — and
* deliberately not from a `finally`, which also runs on the two pause shapes.
*
* **`runId` is optional for unhosted callers.** A `'run'` scope means
* "release this when the TURN that opened it ends", and a turn is not a
* runId: a pause and its resume are one turn across two runs (`resume()`
* builds a fresh executor with a fresh id). Filtering on the id would leave
* every session a paused turn had opened alive forever — the failure would
* look like nothing at all, because the run answered fine.
*
* Hosted agents use `fireSessionRuns` instead: one runner can retain pauses
* for several conversations even though only one run executes at a time.
* A session terminal must never close another conversation's resources.
*
* Pass an id where a caller really does mean one specific run.
*/
fireRun(runId?: string): Promise<void>;
/** End run resources for one hosting conversation across pause/resume run IDs. */
fireSessionRuns(sessionId: string): Promise<number>;
/**
* Fire the `'session'` registrations for one hosting session — or, with no
* `sessionId`, every `'session'` registration there is.
*
* Answers how many it closed, so a composition root can log a number instead
* of hoping.
*/
fireSession(sessionId?: string, reason?: TeardownReason): Promise<number>;
/** Fire EVERYTHING, whatever scope it asked for. The backstop. */
fireShutdown(): Promise<number>;
/**
* Sweep sessions nobody has touched for `idleMs`.
*
* LAZY, on the next tier interaction — never a timer. A library that installs
* an interval keeps the host process alive, which is the same reason
* `shutdownOn` refuses to grab signals unless asked.
*/
sweepIdle(): void;
private evictOverflow;
/**
* The one firing implementation: select, claim (law 1), reverse (law 3),
* settle everything (law 5).
*
* Selection and claiming happen SYNCHRONOUSLY before the first `await`, so
* two overlapping fires — a shutdown racing an idle sweep — cannot both take
* the same registration.
*/
private fire;
/** One cleanup, bounded, reported either way. Never rethrows. */
private run;
}
/** A teardown that outran its budget. Named so an alert can route on it. */
export declare class ToolTeardownTimeoutError extends Error {
readonly timeoutMs: number;
constructor(what: string, timeoutMs: number);
}
/**
* This process no longer holds the handle a conversation's session was reached
* by, so the call was not sent.
*
* Not an expiry: nothing was sent and nothing answered. The transport that held
* the handle forgot it (`evidence`), and the remote session's fate is unknown
* (`remote`) — it may still run, and it can no longer be ended from here,
* because its id went with the handle.
*
* The message is one fixed sentence; `cause`, when present, is for the app.
*/
export declare class LocalSessionHandleLostError extends Error {
readonly code: "ERR_LOCAL_SESSION_HANDLE_LOST";
/** What proves the loss: the transport no longer holds the handle. */
readonly evidence: "transport-forgot-handle";
/** What is known of the remote session: nothing. */
readonly remote: "unknown";
constructor(options?: {
readonly cause?: unknown;
});
}
/**
* Is `error` a {@link LocalSessionHandleLostError}? By its code as well as its
* class, so a second copy of the module (a CJS and an ESM build in one
* process) is still recognised.
*/
export declare function isLocalSessionHandleLost(error: unknown): error is LocalSessionHandleLostError;
/**
* Run `work`, giving up after `timeoutMs`.
*
* The abandoned promise is deliberately NOT cancelled — there is nothing to
* cancel a vendor's in-flight `Stop` with, and pretending otherwise would be
* worse than saying we stopped waiting. The timer is unref'd where the runtime
* allows it, so a pending teardown can never be the reason a process stays up.
*
* Shared, not copied: the MCP client's `terminate()` bounds its wait with it.
*
* @throws {@link ToolTeardownTimeoutError} when `timeoutMs` passes first; what
* `work` throws, otherwise
*/
export declare function withTimeout(work: () => Promise<void>, timeoutMs: number, what: string): Promise<void>;
//# sourceMappingURL=toolSessions.d.ts.map