agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
551 lines (550 loc) • 29.8 kB
TypeScript
/**
* 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, RedactionPolicy } from 'footprintjs';
import type { RunOptions } from './runner.js';
import type { RunContext } from '../bridge/eventMeta.js';
import { EventDispatcher } from '../events/dispatcher.js';
import { type RunRedaction } from '../redaction/runRedaction.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 '../tools/toolSessions.js';
/**
* Make a unique run id. Exported for tests; internal use normally.
*/
export declare function makeRunId(): string;
/**
* 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 declare function isMintedRunId(value: unknown): boolean;
/**
* 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 declare function runSignalOf<T extends RunOptions>(options: T | undefined): RunSignal<T>;
/** One run's engine options, and the release of what was armed for them. */
export interface RunSignal<T> {
readonly options: T | undefined;
/** Disarm the run's deadline and unlink its signals — the run settled. Idempotent. */
readonly release: () => void;
}
/**
* `start` with this run's engine options ({@link runSignalOf}), released once
* it settles — finished, failed or paused.
*/
export declare function underRunSignal<T extends RunOptions, R>(options: T | undefined, start: (engine: T | undefined) => Promise<R>): Promise<R>;
export declare abstract class RunnerBase<TIn = unknown, TOut = unknown> implements Runner<TIn, TOut> {
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.
*
* **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(): ReturnType<FlowChartExecutor['getSnapshot']> | 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}.
*/
protected liveSnapshot(): ReturnType<FlowChartExecutor['getSnapshot']> | undefined;
/**
* 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();
/**
* 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.
*/
protected openRunRedaction(handedDown: RedactionPolicy | undefined, getRunContext: () => RunContext): RunRedaction;
/**
* 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(): ReturnType<FlowChartExecutor['getSnapshot']> | 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 = unknown>(): 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<T = unknown>(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<TOut | RunnerPauseOutcome>;
/**
* 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<TOut | RunnerPauseOutcome>;
/**
* 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. Served under
* the serving of the run that paused (`runServing`, from its executor) —
* never the run opened last on this instance.
*/
private emitPauseRequest;
/**
* 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`).
*/
protected emitPauseResume(checkpoint: FlowchartCheckpoint, input: unknown, handedDown?: RedactionPolicy): RedactionPolicy | undefined;
on<K extends AgentfootprintEventType>(type: K, listener: EventListener<K>, options?: ListenOptions): Unsubscribe;
on(type: WildcardSubscription, listener: WildcardListener, options?: ListenOptions): Unsubscribe;
off<K extends AgentfootprintEventType>(type: K, listener: EventListener<K>): void;
off(type: WildcardSubscription, listener: WildcardListener): void;
once<K extends AgentfootprintEventType>(type: K, listener: EventListener<K>, options?: Omit<ListenOptions, 'once'>): Unsubscribe;
once(type: WildcardSubscription, listener: WildcardListener, options?: Omit<ListenOptions, 'once'>): 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`).
*
* 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();
*/
shutdown(options?: {
readonly stop?: boolean;
readonly timeoutMs?: number;
}): Promise<void>;
/**
* 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.
* 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');
* });
*/
closeToolSessions(options?: {
readonly scope?: 'session';
readonly sessionId?: string;
readonly reason?: TeardownReason;
} | {
readonly scope: 'run';
readonly sessionId: string;
}): Promise<number>;
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<string, unknown>): void;
/**
* 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: string, payload: Record<string, unknown>, attribution: {
readonly sessionId: string;
readonly runId?: string;
}): 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;
}