agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
272 lines (271 loc) • 14.3 kB
TypeScript
/**
* EventDispatcher — the central event bus (one per runner).
*
* Pattern: Observer (GoF) + Pub/Sub over a typed discriminated union.
* Role: Single flat dispatcher for every event emitted during a run.
* Replaces DOM-style bubbling — we have a central bus by
* construction, so tree propagation is unnecessary.
* Emits: N/A — this IS the emitter. It ROUTES events to listeners.
*
* Semantics:
* - Observers are ALWAYS fire-and-forget (inherited from footprintjs's
* recorder contract). Promise returns are never awaited.
* - Listener errors are caught; they become `agentfootprint.error.fatal`
* events with stage:'observer'. The run continues.
* - Dispatch is O(1) hash lookup by event type.
* - Zero allocation when no listener for an event type AND no wildcard.
* - Dev-mode wraps listeners to warn on async listener Promise return.
* - Lifecycle: subscriptions release via the returned Unsubscribe or an
* AbortSignal (`{ signal }`); `removeAllListeners()` is the bulk
* escape hatch for long-lived server consumers; `listenerCount()` is
* the leak diagnostic. Every removal path prunes emptied buckets and
* detaches abort handlers, so listener storage is bounded by LIVE
* subscriptions — never by subscription history.
*/
import type { EventServing } from '../redaction/served.js';
import type { AgentfootprintEvent, AgentfootprintEventMap, AgentfootprintEventType } from './registry.js';
export type EventListener<K extends AgentfootprintEventType> = (event: AgentfootprintEventMap[K]) => void;
export type WildcardListener = (event: AgentfootprintEvent) => void;
export interface ListenOptions {
readonly once?: boolean;
readonly signal?: AbortSignal;
}
export type Unsubscribe = () => void;
export type DomainWildcard = 'agentfootprint.composition.*' | 'agentfootprint.agent.*' | 'agentfootprint.stream.*' | 'agentfootprint.context.*' | 'agentfootprint.memory.*' | 'agentfootprint.tools.*' | 'agentfootprint.skill.*' | 'agentfootprint.permission.*' | 'agentfootprint.validation.*' | 'agentfootprint.reliability.*' | 'agentfootprint.credential.*' | 'agentfootprint.map.*' | 'agentfootprint.integrity.*' | 'agentfootprint.fallback.*' | 'agentfootprint.cost.*' | 'agentfootprint.eval.*' | 'agentfootprint.error.*' | 'agentfootprint.resilience.*' | 'agentfootprint.pause.*' | 'agentfootprint.checkin.*' | 'agentfootprint.middleware.*' | 'agentfootprint.embedding.*' | 'agentfootprint.artifacts.*' | 'agentfootprint.ontology.*' | 'agentfootprint.answer.*';
export type AllWildcard = '*';
export type WildcardSubscription = DomainWildcard | AllWildcard;
/**
* Central event bus. One per executable runner.
*
* Zero-alloc fast path: if `hasListenersFor(type)` is false AND there are
* no wildcards, `dispatch` returns immediately without iteration.
*/
export declare class EventDispatcher {
private readonly byType;
private readonly domainWildcards;
private readonly allWildcards;
/**
* What the current run's events are served as (`src/redaction/served.ts`),
* installed by the runner at the start of each run. Absent until a run
* opens one (then the instance's declaration serves, `instanceServing`) —
* and a run with no policy installs the identity, so every listener
* receives the very object that was dispatched, as before.
*/
private serving;
/**
* Each run's serving by its `runId` — so a fact a run dispatches AFTER the
* next run opened (a late artifact fact, a tool session's teardown report, a
* host's attributed fact; each stamped with its own run's id) is still served
* under ITS run's policy, never the one in force now. An event stamped with
* no run's id (`'consumer-scope'`, a resume's reply) is served under the
* current one. Bounded to the most recent runs ({@link RUN_SERVINGS_KEPT}).
*/
private readonly servingsByRun;
/**
* The serving for an event of NO run dispatched before any run opened one —
* a consumer's `emit`, a host's fact — resolved from what the runner
* DECLARES (`RunnerBase`): its policy, positively none, or `REFUSE` when the
* declaration is unknown. A dispatcher no runner owns carries no run's
* records and declares none: it serves as dispatched.
*/
private instanceServing;
/**
* Whether a run id names a RUN (as opposed to an address that names none —
* a consumer's own emit, an event before the first run), supplied by the
* runner, which owns the id format (`RunnerBase · isMintedRunId`). A fact
* stamped with a run this dispatcher does not hold a serving for (past the
* bound, or never opened here) is served as the placeholder — never under a
* DIFFERENT run's policy.
*/
private namesARun;
/**
* The REAL-value path — the library's own mechanisms that run on events
* (the crash checkpoint, the window's token reading, the reply a host
* streams). Never a record: see `src/redaction/runRedaction.ts`. Separate
* from every consumer bucket on purpose, so `removeAllListeners()` — a
* consumer's lifecycle call — cannot take the agent's machinery with it.
*/
private readonly realListeners;
/**
* Fast-path check. Returns true when at least one listener would fire
* for this type. Used by emitters to skip event-object allocation.
*/
hasListenersFor(type: AgentfootprintEventType): boolean;
/**
* Subscribe a typed listener for a specific event type.
*
* The listener signature is `(event) => void` by design — Promises are
* NOT awaited. See dispatch() for details.
*/
on<K extends AgentfootprintEventType>(type: K, listener: EventListener<K>, options?: ListenOptions): Unsubscribe;
/** Subscribe to a domain wildcard ('agentfootprint.context.*') or '*'. */
on(type: WildcardSubscription, listener: WildcardListener, options?: ListenOptions): Unsubscribe;
/**
* Subscribe a one-shot listener. Fires at most once and then auto-removes.
* Equivalent to `on(type, listener, { once: true })`. Accepts `{ signal }`
* for AbortSignal auto-cleanup, same as `on()`.
*/
once<K extends AgentfootprintEventType>(type: K, listener: EventListener<K>, options?: Omit<ListenOptions, 'once'>): Unsubscribe;
once(type: WildcardSubscription, listener: WildcardListener, options?: Omit<ListenOptions, 'once'>): Unsubscribe;
/**
* Shared subscribe path for on()/once(). The public overloads constrain
* `type` to either typed keys or wildcards; internally the dispatcher's
* bucket logic accepts any string and classifies by shape.
*/
private subscribe;
/**
* Remove a specific listener for a type. Prefer AbortSignal for auto-cleanup.
*
* Because listeners are wrapped in dev mode, identity is preserved via a
* WeakMap in addListener — consumers pass the original function.
*/
off<K extends AgentfootprintEventType>(type: K, listener: EventListener<K>): void;
off(type: WildcardSubscription, listener: WildcardListener): void;
/**
* Lifecycle escape hatch — drop EVERY listener (typed, domain-wildcard,
* and `'*'`) in one call. For long-lived server consumers that reuse one
* runner across many requests: when you can't thread an AbortSignal or
* keep every Unsubscribe handle, call this between requests to guarantee
* the dispatcher holds zero subscriptions.
*
* Safe to call mid-dispatch: the bucket currently being iterated
* finishes its already-taken snapshot (same semantics as `off()` during
* dispatch), buckets the in-flight dispatch has NOT yet reached deliver
* nothing (DOM-like "stop now"), and every SUBSEQUENT event sees no
* listeners. Abort handlers registered on consumer AbortSignals via
* `{ signal }` are detached too. Previously returned Unsubscribe
* handles become harmless no-ops.
*/
removeAllListeners(): void;
/**
* Diagnostic — how many listeners the dispatcher currently retains.
*
* - `listenerCount()` — TOTAL across every bucket (typed + domain
* wildcards + `'*'`). The number long-lived consumers watch to verify
* per-run subscriptions are being released (leak detection).
* - `listenerCount(type)` — listeners registered under that exact
* subscription key (`'agentfootprint.agent.turn_start'`,
* `'agentfootprint.context.*'`, or `'*'`). NOTE: counts the bucket
* only — a typed count does NOT include wildcard listeners that would
* also fire for that type. "Would anything fire?" is
* `hasListenersFor()`.
*/
listenerCount(type?: AgentfootprintEventType | WildcardSubscription): number;
/**
* Route an event to all matching listeners (typed + domain-wildcard + all).
*
* Under a run's redaction policy every listener receives the SERVED event —
* its payload and the identity on its meta through the run's rule
* (`src/redaction/served.ts`); with no policy, the very object dispatched.
*
* Fire-and-forget: any returned Promise is IGNORED. Listener exceptions are
* caught (and logged in dev mode); the run continues regardless.
*/
dispatch(event: AgentfootprintEvent): void;
/**
* Install the serving for events of no run dispatched before any run opened
* one (`instanceServing`) — read at dispatch time, so a declaration made
* after the dispatcher exists still applies. `REFUSE` when the runner's
* declaration is unknown.
*
* @internal
*/
useInstanceServing(serving: () => EventServing | typeof REFUSE): void;
/**
* Install the recogniser of run ids (`namesARun`): a fact stamped with a run
* this dispatcher holds no serving for is then refused — served as the
* placeholder — instead of served under the run in force.
*
* @internal
*/
useRunIdRecogniser(namesARun: (runId: string) => boolean): void;
/**
* Install what this run's events are served as. Called by the runner when a
* run opens (`RunnerBase · openRunRedaction`) and before a resume's reply.
*
* @param serving the run's serving — every run has one: covered by a
* policy, or positively none (the identity, which still
* serves the marks a run makes)
* @param runId the run's id, to serve its late facts by
* @internal
*/
useServing(serving: EventServing, runId?: string): void;
/**
* Dispatch a fact the runner files about ONE run after its executor returned
* (its pause request) — served under THAT run's serving
* (`runRedaction.ts` · `servingOfExecutor`), never the run in force now: on
* an instance whose runs overlap, the last run opened is another run. A fact
* whose run has no serving (`undefined` — an executor no run of this library
* opened) is refused: served as the placeholder. The real-value path
* receives it as made.
*
* @internal
*/
dispatchForRun(event: AgentfootprintEvent, serving: EventServing | undefined): void;
/**
* Dispatch an event whose PAYLOAD was already served at its source — the
* emit helper serves it before `$emit` (`src/redaction/runRedaction.ts` ·
* `emitServed`), so every footprintjs channel and this one share one served
* payload. Only the meta's identity is left to serve here. The real-value
* path already had this event from the source, so it is not fed again.
*
* @internal — the EmitBridge's.
*/
dispatchServed(event: AgentfootprintEvent): void;
/**
* Subscribe to the REAL-value path: every event as its producer made it,
* never served. For the library's own mechanisms only — what the agent
* computes on or hands back to its caller (see the field note). Listener
* errors are isolated like any other.
*
* @internal
*/
onRealEvent(listener: (event: AgentfootprintEvent) => void): Unsubscribe;
/** @internal — lets the emit helper skip building an event nobody reads. */
hasRealListeners(): boolean;
/**
* Deliver one event to the real-value path only.
*
* @internal — the emit helper's, and `dispatch`'s.
*/
deliverReal(event: AgentfootprintEvent): void;
/**
* Route one event to every consumer bucket (typed + domain + all), served
* once — and only when some bucket would receive it, so an event nobody
* listens to costs no walk.
*/
private fanOut;
/** The event as its resolved serving serves it — the same object when nothing was selected. */
private served;
/**
* What `event` is served under, resolved to one of three states — never
* inferred from an absent value (`redaction/coverage.ts`):
*
* - a fact of a run this dispatcher HOLDS: that run's serving (covered, or
* positively none);
* - a fact of a run it does not hold — evicted past
* {@link RUN_SERVINGS_KEPT}, or never opened here (another instance's,
* an earlier process's): UNKNOWN, refused, whatever the instance
* declares;
* - a fact of NO run (a consumer's emit, a resume's reply): the run in
* force, else what the runner declares (`instanceServing`).
*/
private servingFor;
private addListener;
/**
* Bounded-leak guarantee: a bucket emptied by ANY removal path is
* deleted from its Map so `byType` / `domainWildcards` never retain
* empty Sets for event types subscribed once and released. The
* identity check (`get(...) === bucket`) guards stale Unsubscribe
* closures — they must never delete a NEWER bucket re-created under
* the same key after this one was pruned. (`allWildcards` is a stable
* field, not a Map entry — nothing to prune.)
*/
private pruneBucket;
private ensureBucket;
private bucketFor;
private fireBucket;
private domainKey;
}
/** The resolution of an event whose run's state is UNKNOWN: refuse it (`refusedEvent`). */
export declare const REFUSE: unique symbol;