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