agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
472 lines (471 loc) • 22 kB
TypeScript
/**
* Strategy interface types for the v2.8 grouped-enabler architecture.
*
* Pattern: Strategy + Bridge + Hexagonal port. See the design memo
* `docs/inspiration/strategy-everywhere.md`.
*
* Four groups, four typed strategy interfaces. Each follows the same
* shape (one canonical contract, locked at the type level):
*
* 1. `name: string` — registry key for auto-registration
* 2. `capabilities: {...}` — what this strategy supports
* 3. the hot-path method — sync, side-effect-only. Named per
* group: `exportEvent` (observability),
* `recordCost`, `renderStatus`,
* `renderGraph`. There is no `onEvent`.
* 4. `flush?(): Promise<void>` — optional batch flushing
* 5. `stop?(): void` — optional teardown
*
* Design constraints (from the panel review):
* - **PASSIVE / non-blocking by construction.** Strategies are
* observers — they NEVER block the agent loop. Async work
* (HTTP shipment, disk I/O, batching) is the STRATEGY's internal
* concern: buffer in `exportEvent` (sync), drain in `flush()`
* (async OK). The dispatcher never awaits the hot-path call.
* - `exportEvent` MUST be sync `void`. MUST NOT throw. Errors caught +
* routed to `_onError` at the dispatch layer; one bad strategy
* never breaks the agent loop.
* - Idempotent registration — registering the same `name` twice
* replaces, doesn't double-fire.
* - `stop()` is idempotent — halts everything that strategy enabled,
* nothing else, calling twice is a no-op. Since 8.12.0 the framework
* also guarantees it is DELIVERED at most once, however many places
* share the strategy.
* - `flush()` is optional, may be sync OR async — strategies that
* don't batch can omit it. Flush is the ONLY async path; the hot
* path is always sync.
*
* **Who calls `flush()` / `stop()` (8.12.0).** Three doors, and no
* hidden fourth:
*
* - `const telemetry = agent.enable.observability({...})` returns a
* handle. It is still the `Unsubscribe` function it always was —
* `telemetry()` detaches, and detaching still never stops your
* strategy — and it now also carries `telemetry.flush()` and
* `telemetry.stop()`.
* - `await agent.shutdown()` drains and releases everything enabled
* on that agent, in the one correct order.
* - `standingAgent({...})`'s `handle.close()` flushes by default
* (`shutdown: 'flush'`), because the last batch reaching the sink
* when a server stops is what everyone already assumed happened.
*
* `agent.run()` still never flushes: telemetry timing is not the agent
* loop's business. Opt into a per-run drain with
* `enable.observability({ flushOn: 'run-end' })`, which fires a flush
* when a run ends but never gates the run on it.
*/
import type { Unsubscribe } from '../events/dispatcher.js';
import type { AgentfootprintEvent, AgentfootprintEventType } from '../events/registry.js';
import type { StepGraph } from '../recorders/observability/FlowchartRecorder.js';
import type { StatusState } from '../recorders/observability/status/statusTemplates.js';
/**
* What every `enable.*` strategy call returns (8.12.0).
*
* **It is still the `Unsubscribe` function.** `telemetry()` detaches the
* subscription and nothing else, exactly as before — every call site written
* against the old return type keeps compiling and keeps behaving identically.
* What is new is that the function also carries two methods, so the object
* that knows which strategy is attached is the object you can drain:
*
* ```ts
* const telemetry = agent.enable.observability({ strategy: cloudwatch });
*
* await telemetry.flush(); // ship what is buffered; keep exporting
* telemetry(); // detach; strategy keeps running (unchanged law)
* telemetry.stop(); // release the strategy — timers, clients, buffers
* ```
*
* Or let the scope do it, which cannot be got wrong:
*
* ```ts
* await using telemetry = agent.enable.observability({ strategy: cloudwatch });
* ```
*
* `flush()` enforces the shutdown ORDER internally, which is the part no
* consumer could do from outside: it drains the detach hop first (events
* scheduled on a `detach` driver have not reached your strategy yet), then the
* strategy's own buffer. `stop()` is refcounted — see `BaseStrategy.stop`.
*/
export type StrategyHandle = Unsubscribe & AsyncDisposable & {
/**
* Ship everything this subscription has produced: first the events still
* queued on a `detach` driver, then the strategy's own buffer. Safe to
* call repeatedly and concurrently; never throws at you (a failing
* exporter reports through its own `_onError`).
*/
flush(): Promise<void>;
/**
* Release this subscription AND, if it was the last one pointing at the
* strategy, the strategy itself — timers cleared, clients closed. A
* strategy another runner still shares is left running; stopping is
* delivered at most once, ever.
*
* Does not flush first: call `flush()` before it, or use
* `agent.shutdown()` / `await using`, which do.
*/
stop(): void;
};
/** `StrategyHandle` under the name the observability door returns it. */
export type ObservabilityHandle = StrategyHandle;
/**
* Common base every strategy carries. Per-group strategies extend this
* with their own typed hot-path method (`exportEvent`, `recordCost`,
* `renderStatus`, `renderGraph`) + capability shape.
*/
export interface BaseStrategy {
/** Registry key. Conventionally lowercase-kebab: `'datadog'`,
* `'agentcore'`, `'cloudwatch'`. Used to look up the strategy from
* config + de-dupe registrations. */
readonly name: string;
/** Optional batch flush — drain whatever the hot path buffered.
* Returns `void` for sync sinks (Pino-style) OR `Promise<void>` for
* async sinks (Datadog HTTP batch, OTel BatchSpanProcessor).
*
* **Called for you on shutdown (8.12.0), never during a run.** The
* handle returned by `enable.observability()` carries `.flush()`,
* `await agent.shutdown()` calls it for everything enabled on that
* agent, and a `standingAgent` flushes on `close()` by default.
* Before 8.12.0 nothing called it, and a batching exporter
* (CloudWatch, X-Ray) dropped its final batch whenever the process
* exited inside its flush window.
*
* Write it to be safe to call at any time, including twice
* concurrently and after your own `stop()` — the shipped AWS
* adapters are (a stop cancels the timer and stops accepting; it
* does not discard what was already accepted).
*
* ```ts
* const telemetry = agent.enable.observability({ strategy });
* // …
* await telemetry.flush(); // ship what is buffered, keep running
* await agent.shutdown(); // drain + release everything enabled
* ```
*
* What still does NOT call it: `agent.run()`. Awaiting an exporter
* inside a run would make telemetry a run-latency term. Opt into a
* per-run drain with `enable.observability({ flushOn: 'run-end' })`
* — it fires when a run ends, and does not gate the run. */
flush?(): void | Promise<void>;
/** Optional teardown — close clients, clear timers, release handles.
* Idempotent: calling twice is a no-op. Strategies that open no
* external resources can omit this.
*
* **Terminal**: after it, events are dropped and there is no
* restart. So the framework is careful about who may call it:
*
* - The `Unsubscribe` — `telemetry()` — NEVER stops your strategy.
* It releases one subscription, exactly as it always did. This is
* what lets one strategy instance be enabled, unsubscribed and
* enabled again (the audit-export pattern) without losing the
* second half of its record.
* - `telemetry.stop()` and `agent.shutdown()` DO stop it — but only
* once the last subscription pointing at it has been released,
* so one runner's shutdown can never blind another runner that
* still shares the strategy.
* - Delivered AT MOST ONCE per strategy instance, whoever asks.
*
* Flush first, then stop — both doors above already do. */
stop?(): void;
/**
* Optional event-type filter. When set, the dispatcher only forwards
* events whose `type` is in this set — saves the strategy from
* filtering itself + reduces hot-path allocations.
*
* Per AWS CloudWatch panel review: storage cost scales with size,
* strategies need to declare what they consume so the framework
* doesn't force them to inspect everything.
*/
readonly relevantEventTypes?: readonly AgentfootprintEventType[];
/**
* Optional config validator. Called ONCE at registration time —
* throws if the strategy's options are invalid (wrong API key shape,
* missing peer dep, unreachable endpoint). Saves customer-support
* "why is my dashboard empty?" tickets.
*
* Per New Relic panel review.
*/
validate?(): void;
/**
* Optional error sink. Called when this strategy itself errors —
* e.g., HTTP 401 from Datadog, malformed config in pino. Default
* dispatcher behavior is to swallow + log to console (so one bad
* exporter doesn't kill the agent loop). Consumers wire this when
* they want to surface vendor errors in their own tooling.
*
* **This is a property you ASSIGN AFTER CONSTRUCTION, not a
* constructor option**, because it lives on the strategy object
* rather than in any factory's options:
*
* ```ts
* const strategy = cloudwatchObservability({ logGroupName: '/agent' });
* strategy._onError = (err, event) => myLogger.warn(err, event?.type);
* ```
*
* Since 8.11.0 the AWS adapters (`cloudwatchObservability`,
* `agentcoreObservability`, `xrayObservability`, `otelObservability`)
* also accept an `onError` option in their factory options — that is
* the preferred door, since it is wired before the first delivery can
* fail. `_onError` stays for strategies you write yourself and for
* adapters that take no options.
*
* Per New Relic panel review.
*/
_onError?(error: Error, event?: AgentfootprintEvent): void;
}
/**
* Capabilities a strategy declares — matches OTel's 4-signal model
* (events / logs / traces / metrics). A strategy can opt into any
* subset. `compose([...])` ORs the children's capabilities.
*
* - `events: true` → wide structured events (Honeycomb / OTel
* events / Datadog wide events). agentfootprint
* events are this shape natively — most
* strategies should default to `events: true`.
* - `logs: true` → flat log records (pino, console, CloudWatch
* Logs). The strategy reduces a wide event to
* a single log line.
* - `traces: true` → strategy maps events to spans (parent/child
* via `runtimeStageId`).
* - `metrics: true` → strategy aggregates counters / gauges
* (CloudWatch metrics, Mimir, Prometheus).
*/
export interface ObservabilityCapabilities {
readonly events?: boolean;
readonly logs?: boolean;
readonly traces?: boolean;
readonly metrics?: boolean;
}
/**
* The single hot-path entry every observability strategy implements.
* Receives every typed agent event. MUST be sync + side-effect-only +
* non-throwing.
*
* Strategies that batch should buffer in `exportEvent` and drain in
* `flush()` — which the consumer, not the framework, must call.
*/
export interface ObservabilityStrategy extends BaseStrategy {
readonly capabilities: ObservabilityCapabilities;
/**
* Translate the typed agentfootprint event into the vendor's wire
* format and ship it to the destination (Datadog API, OTel exporter,
* pino stream, CloudWatch PutLogEvents, etc.).
*
* MUST be sync `void`. Buffer internally; drain in `flush()`.
*/
exportEvent(event: AgentfootprintEvent): void;
}
/**
* What a cost strategy receives every time the cost recorder fires.
* Carries enough info for the strategy to decide whether to ship to
* billing, log a warning, trigger a circuit breaker, etc.
*/
export interface CostTick {
readonly cumulativeInputTokens: number;
readonly cumulativeOutputTokens: number;
readonly cumulativeCostUsd: number;
readonly recentInputTokens: number;
readonly recentOutputTokens: number;
readonly recentCostUsd: number;
readonly model: string;
/**
* The provider that billed it, by `LLMProvider.name` — the other half of
* attribution beside {@link model}, since one model id can be served by
* several providers at different prices (Bedrock and Anthropic both answer
* to a Claude model id).
*
* Optional because `CostTickPayload.provider` is: a window/compaction
* strategy reports summarizer spend through `WindowStrategyResult.spend`,
* and the provider is read from that strategy's `billing` declaration, which
* the seam leaves optional. Absent means "the strategy that billed this did
* not say", never "the agent's provider" — so a billing sink must not
* substitute one.
*/
readonly provider?: string;
/** Loop turn this spend belongs to, from the event envelope (`meta.iterIndex`). */
readonly iteration?: number;
/** footprintjs stage key this spend belongs to, from the event envelope. */
readonly runtimeStageId?: string;
}
export interface CostCapabilities {
/** Strategy supports per-tick streaming. `false` for batch-only sinks. */
readonly streaming?: boolean;
/** Strategy supports budget enforcement (will throw / break the loop
* when budget exceeded). Most strategies are observe-only. */
readonly enforcement?: boolean;
}
export interface CostStrategy extends BaseStrategy {
readonly capabilities: CostCapabilities;
/**
* Translate the cost tick into the vendor's wire format and ship it
* (Stripe billing API, accounting webhook, internal metrics sink).
*
* MUST be sync `void`. Buffer internally; drain in `flush()`.
*/
recordCost(tick: CostTick): void;
}
/**
* What a status strategy receives every time `selectStatus`
* returns a new state. The renderer has already resolved templates to
* a final string; strategies decide where to send it.
*/
export interface StatusUpdate {
/** Rendered status line (already template-resolved). */
readonly line: string;
/** Underlying state for strategies that want to format their own
* view (e.g., emit different colors per state in a TUI). */
readonly state: StatusState;
}
export interface LiveStatusCapabilities {
/** Strategy supports streaming partial tokens (vs only state
* transitions). */
readonly streaming?: boolean;
}
export interface LiveStatusStrategy extends BaseStrategy {
readonly capabilities: LiveStatusCapabilities;
/**
* Render the rendered status line to the strategy's destination
* (chat bubble callback, stdout, webhook).
*
* MUST be sync `void`.
*/
renderStatus(update: StatusUpdate): void;
}
/**
* What a Lens strategy receives — the live StepGraph each time the
* boundary recorder fires an event that changes the visible structure.
* Strategies decide how to render: DOM (browser), TUI (CLI), JSON
* (capture for replay).
*/
export interface LensUpdate {
readonly graph: StepGraph;
/** Whether this is the FINAL update (run finished). Strategies that
* buffer for animation can flush here. */
readonly final: boolean;
}
export interface LensCapabilities {
/** Strategy renders to a UI (browser DOM, TUI). */
readonly interactive?: boolean;
/** Strategy serializes for replay / export. */
readonly serializable?: boolean;
}
export interface LensStrategy extends BaseStrategy {
readonly capabilities: LensCapabilities;
/**
* Render the live StepGraph to the strategy's destination (DOM,
* TUI, JSON serializer).
*
* MUST be sync `void`.
*/
renderGraph(update: LensUpdate): void;
}
/**
* Discriminated union for the `compose([...])` combinator and the
* registry. Lets the registry hold one Map<name, AnyStrategy> while
* preserving type narrowing per-group via the `kind` discriminator.
*/
export type AnyStrategy = ({
readonly kind: 'observability';
} & ObservabilityStrategy) | ({
readonly kind: 'cost';
} & CostStrategy) | ({
readonly kind: 'liveStatus';
} & LiveStatusStrategy) | ({
readonly kind: 'lens';
} & LensStrategy);
export type StrategyKind = AnyStrategy['kind'];
/**
* Cost-of-on knob (per Datadog panel review). Each tier is a soft
* suggestion — strategies decide what to do per tier (e.g., a
* `pino` strategy might gzip on `firehose`, an OTel strategy might
* raise its `BatchSpanProcessor` interval).
*/
export type ObservabilityTier = 'minimal' | 'standard' | 'firehose';
/**
* Detach mode — controls whether the strategy's hot-path call
* (e.g. `exportEvent`) runs sync inside the agent loop or is deferred
* onto a `footprintjs/detach` driver so the loop never blocks.
*
* Three semantics:
*
* - `'forget'` — `detachAndForget`. Discard the handle. Pure
* fire-and-forget telemetry. Errors land on the
* (discarded) handle and go silent unless the
* strategy's own `_onError` surfaces them. Use for
* high-volume exports where dropping a single event
* is acceptable.
*
* - `'join-later'` — `detachAndJoinLater`. The driver returns a
* `DetachHandle`; we deliver it to your
* `onHandle` callback so you can `await` later
* (graceful shutdown, tests, backpressure).
*
* - omitted (default sync) — strategy hot-path runs inline, same as
* every release before v2.8.
*
* For graceful shutdown — `await handle.flush()` (or `agent.shutdown()`).
* Since 8.12.0 that drains the events this subscription scheduled on the
* driver BEFORE flushing the strategy, which is the order that matters and
* the one you could not write yourself: a detached event has not reached your
* strategy's buffer yet, so flushing the strategy alone ships nothing.
* `flushAllDetached()` (from `'footprintjs/detach'`) remains the
* process-wide hammer for detached work this library did not schedule.
*/
export interface DetachOptions {
/** The driver to schedule on. Required — there is no library
* default. Pick by environment: `microtaskBatchDriver` (cross-
* runtime, default for in-process), `setImmediateDriver` (Node),
* `setTimeoutDriver` (cross-runtime, configurable delay),
* `sendBeaconDriver` (browser, survives page-unload), etc. */
readonly driver: import('footprintjs/detach').DetachDriver;
/** `'forget'` discards the handle (pure telemetry). `'join-later'`
* delivers the handle to `onHandle` for later awaiting. Default
* `'forget'`. */
readonly mode?: 'forget' | 'join-later';
/** Required when `mode === 'join-later'`. Receives every minted
* handle. Push to a closure-local array if you want
* `Promise.all(handles.map(h => h.wait()))` later, or keep a
* rolling window for backpressure. */
readonly onHandle?: (handle: import('footprintjs/detach').DetachHandle) => void;
}
/**
* Common options every group accepts. Per-group enablers extend with
* their own keys (e.g., `templates` for liveStatus, `budget` for cost).
*/
export interface CommonStrategyOptions {
/**
* Strategy implementation. Defaults differ per group:
* - observability → `console()`
* - cost → `inMemorySink()`
* - liveStatus → consumer-provided callback
* - lens → `browser()` (when in DOM) / `noop()` (else)
*/
readonly strategy?: BaseStrategy;
/** 0..1 — fraction of events to forward. 1.0 = all, 0 = none.
* Per-Datadog panel review: every observability enabler accepts
* this. */
readonly sampleRate?: number;
/** Opt-in detach. When set, the strategy's hot-path call (e.g.
* `exportEvent`) is scheduled on the given driver instead of
* running inline — agent loop never blocks on slow exporters.
* See `DetachOptions` for the three semantics. */
readonly detach?: DetachOptions;
/**
* When to flush automatically (8.12.0). Default `'manual'` — the framework
* flushes on shutdown (`handle.flush()`, `agent.shutdown()`, a
* `standingAgent` closing) and at no other time.
*
* `'run-end'` additionally fires a flush when a run finishes: an Agent's
* `agent.turn_end`, a composition's `composition.exit`. Use it for scripts
* and short-lived processes (a Lambda, a cron job) where "the process may
* vanish right after the answer" is the normal case.
*
* **It fires the flush; it does not gate the run.** `run()` resolves when
* the run is done, exactly as it does today — the drain happens alongside,
* and a process that exits in the same breath can still outrun it. The
* honest one-liner for that case is `await handle.flush()` (or
* `await agent.shutdown()`) after the run; this option shrinks the window,
* it does not close it. Nothing was made to await inside `run()` because
* telemetry must never become a term in run latency.
*/
readonly flushOn?: 'manual' | 'run-end';
}