UNPKG

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
/** * 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;