UNPKG

agentfootprint

Version:

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

977 lines • 49.9 kB
/** * RunnerBase — shared implementation of the Runner interface. * * Pattern: Template Method (GoF). Subclasses override `buildChart()` and * optionally `onBeforeRun()` / `onAfterRun()`; the base handles * dispatcher, recorder attachment, custom emit, and subscription. * Role: Base class for LLMCall, Agent, Sequence, Parallel, Conditional, Loop. * Emits: Nothing directly — its attached recorders do. */ import { armDeadline, linkedController } from '../lib/abort.js'; import { EventDispatcher, REFUSE } from '../events/dispatcher.js'; import { bindChartStages } from '../redaction/chartBinding.js'; import { declarationOf, declareRedaction, redactionDeclaredBy } from '../redaction/declared.js'; import { policyOfCoverage } from '../redaction/coverage.js'; import { policyOfMarks, unionRedactionPolicies } from '../redaction/policy.js'; import { adoptScopeOutsideRun, coverageOfExecutor, createRunRedaction, outsideRunFor, servingAhead, servingOfExecutor, } from '../redaction/runRedaction.js'; import { servableSnapshot } from './servableSnapshot.js'; import { registerRunnerLive } from './runnerLive.js'; import { redactConsentUrlForEvent } from '../identity/consent.js'; import { argumentAskReplyForEvent, isArgumentAskPause } from './agent/arguments/askMarker.js'; import { readAskComponent } from '../tools/askComponent.js'; import { pauseDemandsDecision } from './pause.js'; import { noteDecisionTakenUp, pausedRequestIdOf } from './consentRequest.js'; import { readAwaitingInput } from '../tools/inputRequest.js'; import { CONSUMER_SCOPE_RUN_ID, parseSubflowPath } from '../bridge/eventMeta.js'; import { attachObservabilityStrategy, attachCostStrategy, attachLiveStatusStrategy, } from '../strategies/attach.js'; import { ASYNC_DISPOSE } from '../strategies/lifecycle.js'; import { flushWithinDeadline, shutdownTimeoutOf } from '../strategies/flushDeadline.js'; import { attachFlowchart, } from '../recorders/observability/FlowchartRecorder.js'; import { attachLocalObservability, } from '../recorders/observability/localObservability.js'; /** The reason a pause request names when its pause carries none — typed, so a * redacted record keeps it as the library's own words (`FixedPauseReason`). */ const DEFAULT_PAUSE_REASON = 'stage requested pause'; /** * Where a pause happened, as the SEGMENT list every other event carries * (`buildEventMeta`) — so a reader finds the owner of `pause.request` and * `pause.resume` the way it finds every event's. The checkpoint's own list is * footprintjs's resume cursor, one cumulative mount id per level * (`['step-p', 'step-p/x']`); its last entry names the whole path. */ function pauseSegments(checkpoint) { // Read defensively: `resume()` builds `pause.resume` BEFORE the engine // validates the checkpoint, and a hostile one must still fail there, by name. const cursor = checkpoint.subflowPath; const last = Array.isArray(cursor) ? cursor[cursor.length - 1] : undefined; return typeof last === 'string' ? parseSubflowPath(last) : []; } let _runIdSeq = 0; /** * Make a unique run id. Exported for tests; internal use normally. */ export function makeRunId() { return `run-${Date.now()}-${++_runIdSeq}`; } /** * Is `value` shaped like an id {@link makeRunId} mints? The recogniser lives * beside the minter so the format has one owner. * * Asked by `core/agent/callerIdentity.ts · callerIdentityOf`, which must tell * the per-run DEFAULT identity (`{ conversationId: '<runId>' }`, seed's third * rung) apart from an identity a caller named — the one fact a resumed run * cannot otherwise recover, because a flowchart checkpoint does not carry the * paused run's id. */ export function isMintedRunId(value) { return typeof value === 'string' && /^run-\d+-\d+$/.test(value); } /** * One cancellation signal per run, for every runner. A caller may hand it to * the engine (`signal`, which stops the traversal) or to the stages * (`env.signal`, which tools, the reliability loop and every provider call * read). Handed only to the engine, it is handed to the stages too — so a * stage never takes the run's own abort for a failure (the reliability loop * filed it as a fail-fast, and an agent inside a composition retried the * cancelled call up to its cap). Handed to both, the run follows BOTH: either * one cancels. Every runner's `run` / `resume` builds its engine options here * (through {@link underRunSignal} where it can). The SAME object when there is * nothing to add. * * The run's DEADLINE joins the same signal (`timeoutMs`). The engine arms its * own timer for the traversal only, and only when no signal was given — so a * deadline used to stop the chart while the provider call in flight kept * running (and billing), and a deadline beside a caller's signal did nothing * at all. Folded in here, it cancels that call like any abort * (`lib/abort.ts`), and the run still fails with the engine's own words. * * `release()` ends what this armed — the deadline's timer and the links on * the caller's signals — and is called once the run settles (finished, failed * or paused), as the engine released its own timer: a deadline never fires on * a run that already finished, and no listener outlives the run on a * long-lived signal. A resume arms a fresh one. * * It is also where the incoming `request` STOPS: the engine is handed the * options without it, so a sealed request never reaches footprintjs's run * options (the runner reads it before this call, `memberRun.ts`). */ export function runSignalOf(options) { const engine = withoutRequest(options); const timeoutMs = engine?.timeoutMs; const hasDeadline = typeof timeoutMs === 'number' && timeoutMs !== 0 && Number.isFinite(timeoutMs); const signal = engine?.signal; const envSignal = engine?.env?.signal; if (engine === undefined || (!hasDeadline && (signal === undefined || signal === envSignal))) { return { options: engine, release: NOTHING_ARMED }; } if (!hasDeadline && envSignal === undefined) { // One signal, handed to the stages as well: nothing to combine, nothing armed. return { options: { ...engine, env: { ...engine.env, signal } }, release: NOTHING_ARMED }; } // Two signals, or a deadline: ONE run signal follows them all, for exactly // the run's lifetime (`linkedController`), and the deadline aborts it. const { controller, unlink } = linkedController(signal, envSignal); const disarm = hasDeadline ? armDeadline(controller, timeoutMs, () => new Error(`Execution timed out after ${timeoutMs}ms`)) : NOTHING_ARMED; // The deadline now rides the signal; the engine is not handed a second timer. const { timeoutMs: _timeoutMs, ...rest } = engine; void _timeoutMs; return { options: { ...rest, signal: controller.signal, env: { ...engine.env, signal: controller.signal }, }, release: () => { disarm(); unlink(); }, }; } const NOTHING_ARMED = () => undefined; /** * `start` with this run's engine options ({@link runSignalOf}), released once * it settles — finished, failed or paused. */ export async function underRunSignal(options, start) { const run = runSignalOf(options); try { return await start(run.options); } finally { run.release(); } } /** `options` without `request` — the SAME object when it carries none. */ function withoutRequest(options) { if (options === undefined || !('request' in options)) return options; const { request: _request, ...rest } = options; void _request; return rest; } export class RunnerBase { dispatcher = new EventDispatcher(); attachedRecorders = []; /** * The most recently used FlowChartExecutor — set by subclasses in * `run()` so consumers can read the canonical structural snapshot * via `getLastSnapshot()`. Single source of structural truth: this * is footprintjs's snapshot, NOT a domain re-derivation. */ lastExecutor; /** * Cached footprintjs FlowChart, built ONCE at construction time via * `initChart()`. Subsequent `getSpec()` calls return this same * object — reference-stable across all consumers (Lens spec memos, * footprintjs's OpenAPI/MCP caches, recorder-side correlation). * * Set via `initChart(builder)`, called from the subclass constructor * AFTER all instance fields are populated. Read via `getSpec()`. * * Why eager construction: the `StructureRecorder` contract is * "fires once per node at build time" — lazy construction would * fire each recorder every `getSpec()` / `run()` call (2N invocations * per run instead of N), break reference equality, and trigger * `_mergeStageMap` false-positive collisions on second build. * See `RunnerBase.initChart` for details. * * Visibility note: `private` (not `protected`) so subclasses cannot * bypass the `initChart()` double-init guard by writing the field * directly. All legitimate access goes through `getSpec()` and * `initChart()`. */ chart; /** * Returns the footprintjs snapshot from the most recent run. The snapshot is * the CANONICAL STRUCTURE: nodes, edges, executionTree, runtimeStageId, * commitLog. * * Domain consumers (Lens, Trace, dashboards) read this for shape * and join their own per-stage payload by `runtimeStageId`. They * MUST NOT re-derive structure from typed events — that's the * design footprintjs's CLAUDE.md Convention 1 explicitly forbids. * * `undefined` before the first `run()` has STARTED. After that it is the * most recent run's snapshot, including across multi-turn reuse of the same * runner instance. * * **Live during a run.** The executor is assigned at run start, so a caller * reading this from inside a run — an event listener, a tool, a recorder — * gets the IN-FLIGHT snapshot, partially filled, not the last completed one. * {@link RunnerBase.getSnapshot} is the same value under the name that says * so. Anything that must describe a FINISHED run has to capture at the * terminal flush instead of polling this. * * **Served, under a redaction policy.** When the run was covered by one * (`Agent.create({ redact })`, a composed member's, or one a calling tool * handed down), this is footprintjs's REDACTED view — * `getSnapshot({ redact: true })`, through `servableSnapshot`, the one owner * of what a run may show: `sharedState` and every subflow's state from the * redacted mirror, the commit log as scrubbed at write time, and no * `initialState` (the raw pre-run base never passed the policy, so a fold of * it reports `basis: 'log-only'`). Without a policy it is the snapshot * exactly as footprintjs builds it. The library's own logic never reads this * getter — what it computes on is the live run (`liveSnapshot`). */ getLastSnapshot() { if (this.lastExecutor === undefined) return undefined; const coverage = coverageOfExecutor(this.lastExecutor); switch (coverage.state) { case 'covered': return servableSnapshot(this.lastExecutor, coverage.policy); case 'declared-none': return servableSnapshot(this.lastExecutor, undefined); case 'unknown': // An executor no run of this runner opened: nothing says what its // record may show, so none of it is served — fail closed, never raw. return undefined; } } /** * The LIVE snapshot of the most recent run — real values, never served. * For the runner's own logic only (continuing a conversation, building a * checkpoint, decoding the run's verdict): what the agent computes on is the * class the redaction law never covers. Anything handed OUT goes through * {@link RunnerBase.getLastSnapshot}. */ liveSnapshot() { return this.lastExecutor?.getSnapshot(); } /** * Registers this runner's LIVE taps — the real-value event path and the live * committed state — for the library's own mechanisms outside this class * (`runnerLive.ts`: a host's streamed reply, spend ledger and session store, * `toSSE({ format: 'text' })`). Deliberately not a method: no consumer can * reach the real values through a runner, so none can make them a record. */ constructor() { // Every runner DECLARES: positively none, until a subclass declares its // own policy (`Agent.create({ redact })`, a composition adopting its // members') — never a lookup that misses (`redaction/coverage.ts`). declareRedaction(this, undefined); // Before any run opens its redaction, an event of no run (a consumer's // `emit`, a host's fact) is served under what this runner DECLARES — read // at dispatch time, since the declaration is made after this constructor. let declaredServing; // A fact of a run this dispatcher holds no serving for is refused, never // served under another run's policy; the run-id format is ours to judge. this.dispatcher.useRunIdRecogniser(isMintedRunId); this.dispatcher.useInstanceServing(() => { const declaration = declarationOf(this); if (declaration.state === 'unknown') return REFUSE; if (declaredServing?.declaration !== declaration) { declaredServing = { declaration, serving: servingAhead(policyOfCoverage(declaration)) }; } return declaredServing.serving; }); registerRunnerLive(this, { onRealEvent: (listener) => this.dispatcher.onRealEvent(listener), liveState: () => this.lastExecutor?.getRuntime().globalStore.getState(), liveSnapshot: () => this.liveSnapshot(), }); } /** * Open the redaction for one run — called by every runner's * `createExecutor`, for every run, before `new FlowChartExecutor`. * * The policy in force is this runner's declaration joined with `handedDown` * (a policy the caller added for this run — `AgentRunOptions.redact`). The * returned object supplies the executor's scope factory (so every stage's * events are served at their source, `src/redaction/runRedaction.ts`) and * hands the policy to footprintjs (`applyTo`). The dispatcher serves the * run's direct facts by the same rule from here on. */ openRunRedaction(handedDown, getRunContext) { const policy = unionRedactionPolicies(redactionDeclaredBy(this), handedDown); const run = createRunRedaction({ policy, dispatcher: this.dispatcher, getRunContext }); // Under the run's own id too: a fact it dispatches after the next run // opened is still served under this run's policy. this.dispatcher.useServing(run.serving, getRunContext().runId); return run; } /** * Alias for `getLastSnapshot()` that mirrors `FlowChartExecutor.getSnapshot()` * so consumers (lens, playground, ExplainableShell) can read the live or * just-completed snapshot through the same method name they'd use on a * footprintjs executor — without having to know whether they're holding * an agentfootprint Runner or a raw executor. * * During an active run, returns the in-progress snapshot (commit log + * execution tree built incrementally as stages execute). Between runs, * returns the last completed run's snapshot. Undefined before any run has * started. Served exactly as `getLastSnapshot()` is: under the run's * redaction policy, the placeholder where a selected value was. */ getSnapshot() { return this.getLastSnapshot(); } /** * How many commits the run has written so far — footprintjs's * `executor.getCommitCount()`, forwarded. * * This is the run's TIME AXIS. One commit lands per executed stage, in * order, so the count sampled at some moment is that moment's position * in the run. Observers stamp it to say WHEN they fired: it is what * `boundaryRecorder({ getCommitCount })` records on every boundary, and * the only reason a step strip can be rebuilt from a stored recording * later. Sample it live, at the moment of the event — a number read * once and captured is a number about the wrong instant. * * `0` before the first run, and during a run it climbs; between runs it * is the last run's total. Cumulative across `resume()` on the same * executor, and it counts the whole run — a subflow's own commits are * kept out of the run-level log by footprintjs, so this is the parent * timeline, not a sum of every nested one. */ getCommitCount() { return this.lastExecutor?.getCommitCount() ?? 0; } // ─── Subclass hooks ──────────────────────────────────────────── /** * Return the footprintjs FlowChart for this runner — the canonical * design-time blueprint. STABLE REFERENCE across calls (`getSpec() * === getSpec()`). Set once at construction via `initChart()`. * * Pairs with the run-time getters (`getLastSnapshot`, * `getCommitCount`) and matches `ExplainableShell.spec` + * `specToReactFlow(spec, ...)` consumer conventions. Its * `buildTimeStructure` field is what a viewer draws — save it with the * snapshot when storing a run, since no snapshot carries it. * * DO NOT OVERRIDE in subclasses — the reference-identity contract * (Lens / OpenAPI / MCP caches memo on this returning the same * object) depends on the inherited body returning `this.chart` * directly. To customise build behaviour, override `buildChart()` * instead; this getter must remain a thin cache-read. */ getSpec() { if (this.chart === undefined) { throw new Error(`${this.constructor.name}: chart not initialized — the subclass must call \`this.initChart(() => this.buildChart())\` in its constructor before any \`getSpec()\` / \`run()\`.`); } return this.chart; } /** * Cached `getUIGroup()` output. Computed lazily on first read so the * subclass constructor doesn't need to run the translator before all * its members exist (e.g., Parallel builds its branches list mid- * construction). After first invocation, subsequent calls return the * same reference — reference-stable, matches the `getSpec()` contract. * * `null` (not `undefined`) is the explicit "computed; result was * undefined" marker so we can distinguish from "not yet computed." * Consumers see `undefined` when no translator was attached. */ uiGroupCache; /** * Return the consumer-shaped UI group for this composition — produced * by invoking the consumer's `groupTranslator` (if attached) with this * runner's `GroupMetadata`. Returns `undefined` when no translator was * attached. * * STABLE REFERENCE across calls. Computed on first access and cached; * subsequent calls return the same value. Pairs with `getSpec()` — * library shape on one side, consumer-shaped UI on the other. * * Subclasses MUST override `buildUIGroupMetadata()` (the next hook) to * supply the `GroupMetadata` for their composition kind. This method * (the public surface) is `final`-by-convention — do not override. */ getUIGroup() { if (this.uiGroupCache !== undefined) { return this.uiGroupCache.value; } const translator = this.getGroupTranslator(); if (translator === undefined) { this.uiGroupCache = { value: undefined }; return undefined; } const metadata = this.buildUIGroupMetadata(); if (metadata === undefined) { this.uiGroupCache = { value: undefined }; return undefined; } // SEAL THE CACHE BEFORE INVOKING THE TRANSLATOR so a throwing // translator can't be re-invoked on the next `getUIGroup()` call. // The `GroupTranslator` JSDoc guarantees "Runs ONCE per composition" // — that invariant must hold for throwing translators too, otherwise // a translator with side effects (telemetry, counters) would // double-count on every re-read after a throw. Re-throws the same // error so the caller still sees the failure on FIRST call; second // call returns `undefined` (the sealed value). this.uiGroupCache = { value: undefined }; const value = translator(metadata); this.uiGroupCache = { value }; return value; } /** * Subclass hook — returns the consumer's translator if one was * provided at construction time. Default: no translator (returns * undefined). Each composition overrides to surface its own * `opts.groupTranslator`. */ getGroupTranslator() { return undefined; } /** * Translate this runner's group metadata with a CALLER-SUPPLIED * translator that overrides the runner's own default. Used by * parent compositions to apply per-method translator overrides. * See the `Runner.getUIGroupWith` JSDoc for the contract. */ getUIGroupWith(override) { const metadata = this.buildUIGroupMetadata(); if (metadata === undefined) return undefined; return override(metadata); } /** * Subclass hook — returns the `GroupMetadata` for this composition. * Default: undefined, meaning "no group translation for this runner * kind." Compositions override to supply their members + kind. Called * AT MOST ONCE per runner (result is cached by `getUIGroup()`). */ buildUIGroupMetadata() { return undefined; } /** * Build + cache the runner's `FlowChart` exactly once. Called by the * subclass constructor AFTER all instance fields are set, so the * builder lambda can close over them safely. * * Throws if called twice on the same instance — the chart is meant * to be immutable post-construction. Each `run()` reuses the same * chart in a fresh `FlowChartExecutor`. * * Implementation invariant (per footprintjs inventor review): * each attached `StructureRecorder` fires exactly N times per * construction (N = node count). Two `getSpec()` calls return the * same `FlowChart` object reference. `_mergeStageMap` collision * guards never see false-positives because each child runner's * stage functions are created once and reused. */ initChart(builder) { if (this.chart !== undefined) { throw new Error(`${this.constructor.name}: initChart() called twice — the chart is built once at construction and is immutable.`); } this.chart = builder(); // A stage that runs outside this runner's runs (its chart mounted into an // executor the app built) serves its events under what this runner // declares — its policy, or positively none — bound to THIS runner at // build, by identity, and held by it: no registry another runner's stage // could read, and never left untied (an untied scope reads as unknown). let outside; bindChartStages(this.chart, (scope) => { const declaration = declarationOf(this); if (outside?.declaration !== declaration) outside = outsideRunFor(declaration); adoptScopeOutsideRun(scope, outside); }); } // ─── Pause/resume utilities (shared by every concrete runner) ─── /** * Inspect an executor result. On pause, emits `pause.request` and returns * a `RunnerPauseOutcome`. Otherwise returns `undefined` and the subclass * continues its normal result-shape handling (string vs BranchResults vs * Error). * * Subclasses call this BEFORE their own type checks, so pause is never * misinterpreted as "unexpected result shape". */ detectPause(executor, result) { if (!executor.isPaused()) return undefined; const checkpoint = executor.getCheckpoint(); if (checkpoint === undefined) return undefined; const pauseData = checkpoint.pauseData !== undefined ? checkpoint.pauseData : typeof result === 'object' && result !== null && 'paused' in result ? result.pauseData : undefined; this.emitPauseRequest(checkpoint, pauseData, servingOfExecutor(executor)); // A check-in pause carries its typed request under `pauseData.checkIn` and a // middleware ask carries its question under `pauseData.ask` (the dispatch // handler tags them there before pausing). Surface both as first-class // fields so consumers can `isCheckInPause(outcome)` / `isAskPause(outcome)` // and read the evidence pack or the question without reaching into the raw // payload. A plain `askHuman` pause has neither key → both stay absent. // // 8.13.0 — WHICH key is present is read by `pauseDemandsDecision`, the one // reader of that shape, shared with `Agent.resume`'s refusal. The surface a // consumer is told about and the surface the library enforces cannot drift. const gate = pauseDemandsDecision(pauseData); const checkIn = gate?.kind === 'checkIn' ? pauseData.checkIn : undefined; const ask = gate?.kind === 'ask' ? pauseData.ask : undefined; const awaitingInput = gate === undefined ? readAwaitingInput(pauseData) : undefined; return { paused: true, checkpoint, pauseData, ...(checkIn && { checkIn }), ...(ask && { ask }), ...(awaitingInput && { awaitingInput }), }; } /** * Emit `agentfootprint.pause.request` through the dispatcher. Called by * `detectPause()`. Subclasses should not emit this directly. Served under * the serving of the run that paused (`runServing`, from its executor) — * never the run opened last on this instance. */ emitPauseRequest(checkpoint, pauseData, runServing) { const meta = this.minimalMeta(); const reasonFromData = typeof pauseData === 'object' && pauseData !== null && 'reason' in pauseData ? String(pauseData.reason) : DEFAULT_PAUSE_REASON; const event = { type: 'agentfootprint.pause.request', payload: { reason: reasonFromData, // This mirrors the WHOLE `pauseData` onto the wire, which is right for // a check-in's evidence pack and wrong for a bearer capability. A 3LO // consent URL is withheld here BY NAME (8.6.0) — the same discipline // the audit adapter's BOUND_FIELDS applies to `tool_end.result`. Every // other pause shape passes through by reference, unchanged. questionPayload: typeof pauseData === 'object' && pauseData !== null ? redactConsentUrlForEvent(pauseData) : { data: pauseData }, }, meta: { ...meta, runtimeStageId: `${checkpoint.pausedStageId}#paused`, subflowPath: pauseSegments(checkpoint), }, }; this.dispatcher.dispatchForRun(event, runServing); } /** * Emit `agentfootprint.pause.resume` through the dispatcher. Called from * concrete runners' `resume()` BEFORE invoking `executor.resume()` — so * before the leg's run opens its redaction. The payload is the person's * reply, a record like every event: it is served under the policy the * resumed leg is covered by (this runner's declaration joined with * `handedDown`, the caller's per-run policy, and the names the paused leg * kept out — the checkpoint's marks), installed here first — otherwise a * fresh instance, a later process or another pool lane would dispatch it * unserved (`runRedaction.ts` · `servingAhead`). */ emitPauseResume(checkpoint, input, handedDown) { // Taken up by this library: a door watching the paused request sees every // use of its decision from here (`consentRequest.ts` · `watchDecisionUse`). noteDecisionTakenUp(pausedRequestIdOf(checkpoint)); // The resumed leg's own policy beside this runner's declaration: what the // caller hands it, joined with the names the paused leg kept out (the // checkpoint's marks) — returned for the runner to hand THIS leg's // executor (`openRunRedaction`), so nothing per-run is kept on the instance. const resumeLeg = unionRedactionPolicies(handedDown, policyOfMarks(checkpoint.redactionMarks)); this.dispatcher.useServing(servingAhead(unionRedactionPolicies(redactionDeclaredBy(this), resumeLeg))); const meta = this.minimalMeta(); const pausedDurationMs = Date.now() - checkpoint.pausedAt; // Which registered component the paused ask nominated to collect this // answer (9.24.0) — read from the checkpoint's own pause payload // (`readAskComponent` knows every home the three pause kinds use), so the // resume record says which surface the person answered through. Absent for // every prose-only ask, and the payload is byte-identical there. const answeredVia = readAskComponent(checkpoint.pauseData)?.componentId; this.dispatcher.dispatch({ type: 'agentfootprint.pause.resume', payload: { // The inputs layer's OWN ask (honesty layer 2): its answer fills // arguments a tool's view may hide, and such a value rides no event — // so the reply's shape travels (request id, field ids) and every // answered value reads 'REDACTED' (`askMarker.ts` · // `argumentAskReplyForEvent`). The `answered` rows carry what each // tool's view allows. Every other pause kind: the reply, unchanged. resumeInput: isArgumentAskPause(checkpoint.pauseData) ? argumentAskReplyForEvent(input) : typeof input === 'object' && input !== null ? input : { input }, pausedDurationMs, ...(answeredVia !== undefined && { componentId: answeredVia }), }, meta: { ...meta, runtimeStageId: `${checkpoint.pausedStageId}#resumed`, subflowPath: pauseSegments(checkpoint), }, }); return resumeLeg; } on(type, listener, options) { // Cast via unknown — the public overloads on EventDispatcher restrict // `type` to either a specific key or a known wildcard; our public // signature is equivalent but TS can't prove that through the union. return this.dispatcher.on(type, listener, options); } off(type, listener) { this.dispatcher.off(type, listener); } once(type, listener, options) { return this.dispatcher.once(type, listener, options); } /** * Lifecycle escape hatch — drop EVERY event listener on this runner in * one call (typed, domain-wildcard, and `'*'`). Delegates to * `EventDispatcher.removeAllListeners()`. * * For long-lived runners on servers: when you can't thread an * AbortSignal or keep every Unsubscribe handle, call this between * requests to guarantee zero residual subscriptions. Note it also * removes listeners wired by `enable.*` strategies — re-enable after * calling if you still want them. Does NOT touch attached recorders * (see `attach()` — recorders have their own Unsubscribe). */ removeAllListeners() { this.dispatcher.removeAllListeners(); } /** * Diagnostic — how many event listeners this runner currently retains. * No argument = total across all buckets (the leak-detection number); * with a subscription key = that bucket only. Delegates to * `EventDispatcher.listenerCount()`. */ listenerCount(type) { return this.dispatcher.listenerCount(type); } // ─── Recorder attach ─────────────────────────────────────────── /** * Attach a footprintjs CombinedRecorder to observe every subsequent run. * * LIFECYCLE CONTRACT (who owns cleanup): * - Attached recorders live for the RUNNER's lifetime, not a run's. * NOTHING auto-expires per-run — a recorder attached once observes * every later `run()` until you call the returned Unsubscribe. * - The CALLER owns cleanup. Keep the Unsubscribe and call it when the * observer's life ends (request scope, UI unmount, test teardown). * - Event listeners (`on()` / `once()`) follow the same rule, with two * extra outs: pass `{ signal }` for AbortSignal auto-cleanup, or call * `removeAllListeners()` to bulk-drop listeners (listeners ONLY — * recorders are not affected). * - `once()` listeners are the only self-expiring subscription. * * attach() is NOT idempotent: every call pushes another entry. (At run * time footprintjs's executor dedupes recorders by ID, so same-ID * duplicates won't double-fire — but the runner-side array still * grows.) Attaching in a per-run loop without detaching is the classic * server leak; attach once, or detach per-run. * * WHEN it starts observing: the NEXT run. Recorders are handed to the * executor when the executor is built, at run start, so one attached WHILE * a run is in flight sees nothing of that run and everything of the one * after — it is not dropped, it is early. Between runs (or before the * first) is the ordinary case and works exactly as it reads. Event * listeners are the opposite: `on()` takes effect immediately, but only for * events emitted after it, so a listener added mid-run sees the rest of * that run and none of its beginning. */ attach(recorder) { this.attachedRecorders.push(recorder); return () => { const idx = this.attachedRecorders.indexOf(recorder); if (idx >= 0) this.attachedRecorders.splice(idx, 1); }; } // ─── Enable namespace (Tier 3 observability features) ───────── /** * Every live `enable.*` strategy handle on this runner — what * `shutdown()` drains. A handle leaves the set as soon as it is * unsubscribed or stopped, so a server that enables per request and * unsubscribes per request does not grow one. */ liveStrategyHandles = new Set(); /** In-flight `shutdown()`, so concurrent callers share one drain. Cleared * when it settles — the runner stays usable, so a later shutdown runs * again rather than resolving against a stale promise. */ shutdownInFlight; /** * Track a strategy handle for `shutdown()` and hand back a handle that * forgets itself once the consumer releases it. */ trackStrategyHandle(handle) { const live = this.liveStrategyHandles; live.add(handle); const tracked = Object.assign(() => { handle(); live.delete(handle); }, { flush: () => handle.flush(), stop: () => { handle.stop(); live.delete(handle); }, [ASYNC_DISPOSE]: async () => { // Read the method back through the SAME key it was stored under. // `Symbol.asyncDispose` literally would miss it on an engine old // enough to need the fallback key — the one place these two // spellings could drift apart. const dispose = handle[ASYNC_DISPOSE]; await dispose?.(); live.delete(handle); }, }); // Same cast as `makeStrategyHandle`: a computed symbol key reads as an // index signature, not the well-known member `AsyncDisposable` names. return tracked; } /** * Drain and release what was enabled on this runner. * * **The agent itself remains usable afterwards; `shutdown()` drains and * releases what was enabled on it.** Nothing about the runner is destroyed: * `run()` still works, listeners still fire, and enabling telemetry again * gives you a fresh, live handle. * * The order is the part worth having in one place: * * 1. every handle FLUSHES first — including the events still queued on a * `detach` driver, which have not reached the strategy yet; * 2. only then does anything stop, so a strategy shared by two handles is * fully drained before either releases it; * 3. a strategy is stopped only once nothing is still subscribed to it, * and at most once ever (see `strategies/lifecycle.ts`). * * Each flush waits under ONE deadline, `timeoutMs`. A sink still flushing at * the deadline — still delivering, or stuck on a hung disk, mount or network * call — cannot hold the shutdown: it is reported by name through that * sink's own `onError` (the console when it has none), and the shutdown goes * on. Its write is not cancelled; nothing can cancel it. * * A call made while a shutdown is in flight joins it: its own `timeoutMs` * is still checked, but the first call's deadline governs, as its `stop` * does. * * @param options.stop - Default `true`. Pass `false` to drain WITHOUT * releasing — what a host does when it is shutting down but does not own * the agent it was handed (`standingAgent`'s default `shutdown: 'flush'`). * @param options.timeoutMs - How long to wait for each sink's flush, in ms. * Default 30 000 (the same number footprintjs's `drainObservers` uses). * `Infinity` waits without a deadline — how `shutdown()` behaved before * this option existed — and arms no timer. A finite value of any size is * honoured, even past the ~24.8 days one host timer can hold. A value that * is not a positive number makes the returned promise reject with a * `TypeError`; nothing throws synchronously. * * @example Graceful exit for a script * const telemetry = agent.enable.observability({ strategy: cloudwatch }); * const answer = await agent.run({ message: 'hi' }); * await agent.shutdown(); */ async shutdown(options = {}) { const timeoutMs = shutdownTimeoutOf(options.timeoutMs); if (this.shutdownInFlight) return this.shutdownInFlight; const shouldStop = options.stop ?? true; const handles = [...this.liveStrategyHandles]; this.shutdownInFlight = (async () => { // Flush everything before stopping anything — a strategy two handles // share must not be stopped while the other still has data to ship. await Promise.allSettled(handles.map((handle) => flushWithinDeadline(handle, timeoutMs))); // TOOL SESSIONS CLOSE ON EITHER SETTING (9.7.0) — including // `{ stop: false }`, and this is a deliberate asymmetry worth reading. // // `stop` governs BORROWED strategies: a host that is shutting down but // does not own the agent it was handed drains without releasing what the // caller still has. A tool session is not borrowed. THIS runtime opened // it, on behalf of runs it executed, and nobody else holds a handle to // close it — so `{ stop: false }` leaving it open would not be politeness, // it would be a leaked sandbox on `standingAgent`'s DEFAULT path. // // Nothing live is cut: by the time a composer reaches here its host is // closed and in-flight runs have finished. And it stays true to // "the agent itself remains usable afterwards" — the next run opens a // fresh session, exactly as the first one did. await this.toolSessionTier?.fireShutdown(); if (!shouldStop) return; for (const handle of handles) { handle.stop(); this.liveStrategyHandles.delete(handle); } })().finally(() => { this.shutdownInFlight = undefined; }); return this.shutdownInFlight; } // ─── Tool sessions (9.7.0) ───────────────────────────────────── /** * The teardown tier for tools that hold sessions, built on FIRST * registration and `undefined` until then. * * Only a runner that dispatches tools ever sets it — today that is `Agent`. * A `Sequence` or a `Loop` has no tool-dispatch loop, so its tier stays * absent and every terminal here is one `undefined` check. * * @internal */ toolSessionTier; /** * End the tool sessions held for one hosting session. * Pass `{ scope: 'run', sessionId }` to terminate only its paused turn's * resources, such as after an explicit input cancellation. Session-scoped * resources and other conversations remain open. * * **The mechanism is the library's; the TIMING is yours.** Nothing in this * package can know when a request/reply session is over — a `HostRequest` * carries a `sessionId` and no end, `SessionLifecycle` is `hydrate`/`persist` * by design (a TTL, a scan or a delete is the STORE's own API, not a demand * this port makes of every store that will ever implement it), and AWS itself * does not tell you: an idle timeout is the reality. Guessing a boundary here * would tear down a live sandbox mid-conversation. * * So the composition root, which already owns the shape of the process, says * when — the same doctrine that stops `shutdownOn` from grabbing signals by * default. On the conversation door that is one line: * * ```ts * conversation.onClose(() => void agent.closeToolSessions({ sessionId })); * ``` * * A request/reply deployment that knows its own boundary — a logout, a job * finishing, a cart abandoned — calls the same method. * * Never calling it is survivable, not silent: sessions idle out on the tier's * lazy sweep, a bounded live count evicts the coldest, and `shutdown()` takes * whatever is left. * * @returns how many cleanups ran. `0` when this runner holds none — a * composition, or an agent whose tools never opened anything. * * @example * host.onSessionEnd(async (sessionId) => { * const closed = await agent.closeToolSessions({ sessionId }); * log.info({ sessionId, closed }, 'tool sessions released'); * }); */ async closeToolSessions(options = {}) { if (options.scope === 'run' && (!options.sessionId || typeof options.sessionId !== 'string')) throw new TypeError('Closing run resources requires an explicit sessionId.'); if (!this.toolSessionTier) return 0; if (options.scope === 'run') return this.toolSessionTier.fireSessionRuns(options.sessionId); return this.toolSessionTier.fireSession(options.sessionId, options.reason ?? 'session-end'); } enable = { flowchart: (opts) => // Hand the recorder's attach(), the dispatcher AND the commit // count out as narrow capabilities — no reference to `this`, no // coupling to the runner class tree. attachFlowchart wires a // TopologyRecorder via the attach path AND subscribes to the event // dispatcher for ReAct step transitions (stream.llm_* / // stream.tool_*). The commit count is the third: without it every // boundary is stamped at index 0 and the recording's step strip // cannot be rebuilt — silently, since the events look complete. attachFlowchart((r) => this.attach(r), this.dispatcher, opts, () => this.getCommitCount()), localObservability: (opts) => attachLocalObservability((r) => this.attach(r), this.dispatcher, opts, Date.now, { getStructure: () => { // The serialized STATIC chart — lets the offline Trace rebuild the // flowchart (Replay Option A). Captured lazily; spec is reference-stable. const spec = this.getSpec(); return spec.buildTimeStructure; }, getSnapshot: () => this.getLastSnapshot(), getCommitCount: () => this.getCommitCount(), redactedByPolicy: () => { if (this.lastExecutor === undefined) return false; // Unknown reads as covered: a label never claims less than the record may hold. return coverageOfExecutor(this.lastExecutor).state !== 'declared-none'; }, }), // v2.8 grouped strategy enablers — see // `docs/inspiration/strategy-everywhere.md`. // Each returns a handle: the same `Unsubscribe` function as before, now // carrying `flush()` / `stop()` (and `await using`). The runner keeps a // reference so `shutdown()` can drain them all in one call. observability: (opts) => this.trackStrategyHandle(attachObservabilityStrategy(this.dispatcher, opts)), cost: (opts) => this.trackStrategyHandle(attachCostStrategy(this.dispatcher, opts)), liveStatus: (opts) => this.trackStrategyHandle(attachLiveStatusStrategy(this.dispatcher, opts)), }; // ─── Consumer custom emit ────────────────────────────────────── /** * Emit a consumer-defined custom event. * * If `name` matches a registered event type, this routes exactly like a * library-emitted event (via the typed EventMap). Otherwise it flows * through to wildcard listeners (`'*'`) as an opaque CustomEvent with * minimal meta. Library events remain reserved under `agentfootprint.*`. */ emit(name, payload) { if (!this.dispatcher.hasListenersFor(name)) return; const meta = this.minimalMeta(); const event = { type: name, payload, meta, }; this.dispatcher.dispatch(event); } /** * Emit a library event on behalf of one hosting session — the hosting door's * own facts (an artifact a screen redeemed, one a host filed for a turn, a * hand-over that failed) — attributed to the session it was produced for * and, when there was one, the run. * * The meta is `emit`'s consumer-scope meta plus `sessionId`, and `runId` when * the fact was produced FOR a run (a host's filing names the turn's run, * captured when its binding was created). Those stamps are what * `bridge/eventMeta.ts · eventBelongsToRun` reads, so a collector that keeps * ONE run's events (the run's recording, the self-explain evidence) leaves * out a fact produced for another session or another run. Delivery is * unchanged: every listener still receives it. * * @internal — the hosting door's; consumers emit through {@link emit}. */ emitAttributed(name, payload, attribution) { if (!this.dispatcher.hasListenersFor(name)) return; const meta = { ...this.minimalMeta(), ...(attribution.runId !== undefined && { runId: attribution.runId }), sessionId: attribution.sessionId, }; this.dispatcher.dispatch({ type: name, payload, meta, }); } // ─── Internals exposed to subclasses ─────────────────────────── /** * Build a minimal EventMeta for a consumer-level emit OUTSIDE a stage * context. Real stage code uses `buildEventMeta` with a TraversalContext. */ minimalMeta() { const now = Date.now(); return { wallClockMs: now, runOffsetMs: 0, runtimeStageId: 'consumer-emit#0', subflowPath: [], compositionPath: this.compositionPath(), runId: CONSUMER_SCOPE_RUN_ID, }; } /** * Composition ancestry for this runner. Subclass may override to append * its own identity (e.g. `'Sequence:bot'`). */ compositionPath() { return []; } /** * Provide access to the internal dispatcher for internal recorders. * NOT part of the public Runner contract — internal recorders (e.g. * ContextRecorder) receive this at construction. */ getDispatcher() { return this.dispatcher; } } //# sourceMappingURL=RunnerBase.js.map