UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

443 lines • 20.9 kB
/** * 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 { encodeIdentityField } from '../memory/identity/index.js'; import { fnv1a } from '../lib/fnv1a.js'; import { lazyRequire } from '../lib/lazyRequire.js'; /** * 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 function toolSessionKey(ctx, scope) { // Every field goes through the SHARED identity encoder rather than being // joined raw. This key holds a live interpreter sandbox, so two identities // that produce one key share a filesystem — and joining unescaped fields with // `/` and `=` markers meant a value could donate a marker and shift a // boundary: tenant `acme/p=bob` with principal `x` composed the same string // as tenant `acme` with principal `bob/p=x`. The absent-versus-`_` pair was // the same collision `identityNamespace` was fixed for in 9.40.0, one module // over, and this is the encoder that fix produced. // // No reachable attack was found — the markers are prefix-anchored, so a // caller controlling only the trailing field cannot shift a boundary into // somebody else's key. It is closed anyway: "no attack today" is a property // of the current call sites, not of the function. if (scope === 'call') return `c=${encodeIdentityField(ctx.toolCallId)}`; if (scope === 'shutdown') return undefined; const prefix = `t=${encodeIdentityField(ctx.identity?.tenant)}/p=${encodeIdentityField(ctx.identity?.principal)}`; if (scope === 'session') { return ctx.sessionId ? `${prefix}/s=${encodeIdentityField(ctx.sessionId)}` : undefined; } return ctx.runId ? `${prefix}/r=${encodeIdentityField(ctx.runId)}` : 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 function hashSessionKey(key) { const crypto = loadCrypto(); if (crypto) return crypto.createHash('sha256').update(key).digest('hex').slice(0, 12); return fnv1a(key); } let cryptoModule; function loadCrypto() { if (cryptoModule !== undefined) return cryptoModule ?? undefined; try { const mod = lazyRequire('node:crypto'); cryptoModule = typeof mod?.createHash === 'function' ? mod : null; } catch { cryptoModule = null; } return cryptoModule ?? undefined; } /** Defaults, named so a test and a docstring cannot drift from the code. */ export const TOOL_TEARDOWN_TIMEOUT_MS = 5_000; /** A session untouched this long is swept on the tier's next interaction. */ export const TOOL_SESSION_IDLE_MS = 900_000; /** How many live registrations one tier holds before it evicts the coldest. */ export const TOOL_SESSION_MAX_LIVE = 64; /** * 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 class ToolSessionTier { registrations = new Map(); timeoutMs; idleMs; maxLive; report; now; seq = 0; constructor(options = {}) { this.timeoutMs = options.timeoutMs ?? TOOL_TEARDOWN_TIMEOUT_MS; this.idleMs = options.idleMs ?? TOOL_SESSION_IDLE_MS; this.maxLive = options.maxLive ?? TOOL_SESSION_MAX_LIVE; this.report = options.report ?? (() => { }); this.now = options.now ?? (() => Date.now()); } /** Live registrations. Diagnostics and tests. */ liveCount() { return this.registrations.size; } /** * 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. */ async settled() { while (this.background.size > 0) { await Promise.allSettled([...this.background]); } } /** Fires nobody is awaiting — see {@link settled}. */ background = new Set(); /** Start a detached fire and keep it joinable. */ detach(work) { this.background.add(work); void work.finally(() => this.background.delete(work)); } /** * 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, cleanup, options = {}) { this.sweepIdle(); const scope = options.scope ?? 'run'; const key = options.key ?? `${scope}:${origin.tool}`; const mapKey = `${origin.tool}${scope}${key}`; const existing = this.registrations.get(mapKey); if (existing) { // First wins — it is the one holding the live handle. The repeat is what // tells the sweep and the LRU that this session is still being used. existing.touchedMs = this.now(); existing.calls += 1; return { outcome: 'reused', keyHash: existing.keyHash, calls: existing.calls }; } const nowMs = this.now(); const registration = { id: ++this.seq, tool: origin.tool, scope, key, keyHash: hashSessionKey(key), origin, cleanup, ...(options.runnerId !== undefined && { runnerId: options.runnerId }), ...(options.label !== undefined && { label: options.label }), startedMs: nowMs, touchedMs: nowMs, calls: 1, fired: false, }; this.registrations.set(mapKey, registration); this.evictOverflow(); return { outcome: 'started', keyHash: registration.keyHash, calls: 1 }; } /** Fire every `'call'` registration this tool call opened. */ fireCall(toolCallId) { return this.fire((r) => r.scope === 'call' && r.origin.toolCallId === toolCallId, 'call-end').then(() => undefined); } /** * 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) { return this.fire((r) => r.scope === 'run' && (runId === undefined || r.origin.runId === runId), 'run-end').then(() => undefined); } /** End run resources for one hosting conversation across pause/resume run IDs. */ fireSessionRuns(sessionId) { return this.fire((r) => r.scope === 'run' && r.origin.sessionId === sessionId, 'run-end'); } /** * 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, reason = 'session-end') { return this.fire((r) => r.scope === 'session' && (sessionId === undefined || r.origin.sessionId === sessionId), reason); } /** Fire EVERYTHING, whatever scope it asked for. The backstop. */ fireShutdown() { return this.fire(() => true, 'shutdown'); } /** * 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() { if (this.registrations.size === 0) return; const cutoff = this.now() - this.idleMs; const stale = [...this.registrations.values()].filter((r) => r.touchedMs <= cutoff); if (stale.length === 0) return; this.detach(this.fire((r) => stale.includes(r), 'idle')); } evictOverflow() { if (this.registrations.size <= this.maxLive) return; const surplus = this.registrations.size - this.maxLive; const coldest = [...this.registrations.values()] .sort((a, b) => a.touchedMs - b.touchedMs || a.id - b.id) .slice(0, surplus); this.detach(this.fire((r) => coldest.includes(r), 'evicted')); } /** * 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. */ async fire(match, reason) { const claimed = []; for (const [mapKey, registration] of this.registrations) { if (registration.fired || !match(registration)) continue; registration.fired = true; this.registrations.delete(mapKey); claimed.push(registration); } if (claimed.length === 0) return 0; claimed.sort((a, b) => b.id - a.id); // reverse registration order await Promise.allSettled(claimed.map((registration) => this.run(registration, reason))); return claimed.length; } /** One cleanup, bounded, reported either way. Never rethrows. */ async run(registration, reason) { const startedAt = this.now(); const base = { tool: registration.tool, scope: registration.scope, keyHash: registration.keyHash, ...(registration.runnerId !== undefined && { runnerId: registration.runnerId }), ...(registration.label !== undefined && { label: registration.label }), reason, }; try { await withTimeout(async () => registration.cleanup(), this.timeoutMs, `${registration.tool} teardown`); this.report({ ...base, kind: 'closed', durationMs: this.now() - startedAt, }, registration.origin); } catch (err) { // Law 5: swallowed at the caller, never silent on the wire. this.report({ ...base, kind: 'close-failed', durationMs: this.now() - startedAt, error: err instanceof Error ? err.message : String(err), ...(err instanceof Error && { errorClass: err.constructor?.name ?? err.name }), }, registration.origin); } } } /** A teardown that outran its budget. Named so an alert can route on it. */ export class ToolTeardownTimeoutError extends Error { timeoutMs; constructor(what, timeoutMs) { super(`${what} did not finish within ${timeoutMs}ms and was abandoned. The cleanup may still ` + 'be running; the runner stopped waiting so a shutdown cannot hang on a vendor call.'); this.name = 'ToolTeardownTimeoutError'; this.timeoutMs = timeoutMs; } } /** * 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 class LocalSessionHandleLostError extends Error { code = 'ERR_LOCAL_SESSION_HANDLE_LOST'; /** What proves the loss: the transport no longer holds the handle. */ evidence = 'transport-forgot-handle'; /** What is known of the remote session: nothing. */ remote = 'unknown'; constructor(options = {}) { super('The session for the call this result answers could not be reached from here, so that call was not sent.', { ...(options.cause !== undefined && { cause: options.cause }), }); this.name = 'LocalSessionHandleLostError'; } } /** * 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 function isLocalSessionHandleLost(error) { return (error instanceof LocalSessionHandleLostError || (error !== null && typeof error === 'object' && error.code === 'ERR_LOCAL_SESSION_HANDLE_LOST')); } /** * 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 async function withTimeout(work, timeoutMs, what) { let timer; try { await Promise.race([ work(), new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new ToolTeardownTimeoutError(what, timeoutMs)), timeoutMs); timer.unref?.(); }), ]); } finally { if (timer !== undefined) clearTimeout(timer); } } //# sourceMappingURL=toolSessions.js.map