/** * toolSessions — the end-signal a tool can be handed, and the tier that fires it. * * Pattern: Registrar + Template Method for the firing matrix. * Role: core 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 { 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 { 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=/p=/s= requires sessionId * run → t=/p=/r= requires runId * call → c= 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, 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) => 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; /** 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, options?: TeardownOptions): { readonly outcome: RegisterOutcome; readonly keyHash: string; readonly calls: number; }; /** Fire every `'call'` registration this tool call opened. */ fireCall(toolCallId: string): Promise; /** * 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, and the Agent omits it.** 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. * * Firing all of them is exactly right under the runner's own * ONE-IN-FLIGHT-RUN-PER-AGENT invariant: at a terminal there is no other turn * whose sessions these could be. An ABANDONED pause is the interesting case * and it lands the right way round too — the next completed turn releases * what the abandoned one left holding. * * Pass an id where a caller really does mean one specific run. */ fireRun(runId?: string): Promise; /** * 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; /** Fire EVERYTHING, whatever scope it asked for. The backstop. */ fireShutdown(): Promise; /** * 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); }