/** * 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 type { CombinedRecorder, FlowChart, FlowChartExecutor, FlowchartCheckpoint, RunOptions } from 'footprintjs'; import { EventDispatcher } from '../events/dispatcher.js'; import type { RunnerPauseOutcome } from './pause.js'; import type { EventListener, ListenOptions, Unsubscribe, WildcardListener, WildcardSubscription } from '../events/dispatcher.js'; import type { AgentfootprintEventType } from '../events/registry.js'; import type { EventMeta } from '../events/types.js'; import type { EnableNamespace, Runner } from './runner.js'; import type { TeardownReason, ToolSessionTier } from './toolSessions.js'; /** * Make a unique run id. Exported for tests; internal use normally. */ export declare function makeRunId(): string; export declare abstract class RunnerBase implements Runner { protected readonly dispatcher: EventDispatcher; protected readonly attachedRecorders: CombinedRecorder[]; /** * 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. */ protected lastExecutor: FlowChartExecutor | undefined; /** * 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()`. */ private 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. */ getLastSnapshot(): ReturnType | undefined; /** * 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 live 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. */ getSnapshot(): ReturnType | undefined; /** * 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(): number; /** * 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(): FlowChart; /** * 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. */ private 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(): T | undefined; /** * 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`. */ protected getGroupTranslator(): import('./translator.js').GroupTranslator | 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: import('./translator.js').GroupTranslator): T | undefined; /** * 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()`). */ protected buildUIGroupMetadata(): import('./translator.js').GroupMetadata | 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. */ protected initChart(builder: () => FlowChart): void; /** * Execute the runner. Subclass may override for specialized input * mapping, but default invokes getSpec() + FlowChartExecutor. */ abstract run(input: TIn | string, options?: RunOptions): Promise; /** * Resume a paused run from its checkpoint. Default behavior: rebuild the * chart, wire the same core recorders + consumer recorders, call * `executor.resume(checkpoint, input)`, and emit `pause.resume` before * returning. Subclass overrides only if it needs specialized behavior. */ abstract resume(checkpoint: FlowchartCheckpoint, input?: unknown, options?: RunOptions): Promise; /** * 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". */ protected detectPause(executor: FlowChartExecutor, result: unknown): RunnerPauseOutcome | undefined; /** * Emit `agentfootprint.pause.request` through the dispatcher. Called by * `detectPause()`. Subclasses should not emit this directly. */ private emitPauseRequest; /** * Emit `agentfootprint.pause.resume` through the dispatcher. Called from * concrete runners' `resume()` BEFORE invoking `executor.resume()`. */ protected emitPauseResume(checkpoint: FlowchartCheckpoint, input: unknown): void; on(type: K, listener: EventListener, options?: ListenOptions): Unsubscribe; on(type: WildcardSubscription, listener: WildcardListener, options?: ListenOptions): Unsubscribe; off(type: K, listener: EventListener): void; off(type: WildcardSubscription, listener: WildcardListener): void; once(type: K, listener: EventListener, options?: Omit): Unsubscribe; once(type: WildcardSubscription, listener: WildcardListener, options?: Omit): Unsubscribe; /** * 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(): void; /** * 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?: AgentfootprintEventType | WildcardSubscription): number; /** * 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: CombinedRecorder): Unsubscribe; /** * 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. */ private readonly liveStrategyHandles; /** 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. */ private shutdownInFlight; /** * Track a strategy handle for `shutdown()` and hand back a handle that * forgets itself once the consumer releases it. */ private trackStrategyHandle; /** * 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`). * * @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'`). * * @example Graceful exit for a script * const telemetry = agent.enable.observability({ strategy: cloudwatch }); * const answer = await agent.run({ message: 'hi' }); * await agent.shutdown(); */ shutdown(options?: { readonly stop?: boolean; }): Promise; /** * 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 */ protected toolSessionTier: ToolSessionTier | undefined; /** * End the tool sessions held for one hosting session. * * **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'); * }); */ closeToolSessions(options?: { readonly sessionId?: string; readonly reason?: TeardownReason; }): Promise; readonly enable: EnableNamespace; /** * 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: string, payload: Record): void; /** * Build a minimal EventMeta for a consumer-level emit OUTSIDE a stage * context. Real stage code uses `buildEventMeta` with a TraversalContext. */ protected minimalMeta(): EventMeta; /** * Composition ancestry for this runner. Subclass may override to append * its own identity (e.g. `'Sequence:bot'`). */ protected compositionPath(): readonly string[]; /** * 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. */ protected getDispatcher(): EventDispatcher; }