UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

96 lines (95 loc) • 6.53 kB
/** * Host-callback isolation, one primitive. * * The invariant "a callback the HOST handed us must never take the engine down" was implemented by hand at * ~20 sites across 10 modules (observer taps, cascade/verify observability hooks, roster eviction sinks, the * retain ledger's eviction reporter, task-registry reap/quiescence/terminal notifiers, compaction observers, * the workflow event sink, the tool gate's deny observers). Every one of them was a correct swallow, but the * DISCLOSURE grade differed per site: the retain ledger counted its failures and offered them on a dedicated * channel, while the cascade and the roster store swallowed in total silence — so a deployment whose observer * is broken had no way to find out, and WHICH sites would tell it was a matter of which module it happened to * touch. * * This module is the single implementation those sites now share. Semantics: * · SWALLOW — the callback's throw never reaches the engine's control flow (unchanged at every site); * · COUNT — every failure is counted per site, readable for diagnostics ({@link SafeNotifier.failuresAt}); * · BOUNDED DISCLOSURE — the FIRST failure of each site is offered to an optional `onError`, later ones are * counted only. A hot path whose sink fails on every call therefore reports once, never floods. * * WIRING STATUS (RB-473, batches 1/2a/2b landed 2026-08-02): the disclosure channel is now WIRED at the * owners a deployment can reach — TaskRegistry offers `notifierFailureCounts()` (read face; it is * constructed before any deployment sink exists), the delegation tool routes into `opts.onObserverError` * (site attached), auto-compaction / cascade / verify / roster-store take an `onNotifyError` option, the * tool gate surfaces failures as `observer.notify_failed` trace events, and the workflow options + the * SendMessage resume face take `onNotifyError`. Remaining unwired scopes (retain ledger `failuresAt` * read-face only, observer taps, misc subagent internals) disclose via counts. * * State discipline: no module-level mutable state. Counters live on the notifier instance the owner created * (per class instance, or per call for the run-scoped functions), so two runs — and two tests — can never see * each other's counts. * * WHY A THUNK and not `(fn, ...args)`: almost every site reads its callback off a host-owned object * (`config.onRung?.(info)`, `opts.onEvicted?.(e)`). `?.()` on a property calls it with THAT OBJECT as the * receiver, so a host that passed a method using `this` works today. Handing the extracted function value to * a helper would silently re-bind the receiver to `undefined` — an invisible behavior change at ~20 sites. * The thunk keeps the call expression (and its `?.`) at the site, byte-for-byte what it was. * * Scope note (deliberate parity): a callback typed `=> void` that instead returns a REJECTING promise is not * contained by {@link SafeNotifier.notify} — it is synchronous and never touches the return value, exactly * like the hand-written try/catch it replaced. Sites that genuinely await their callback use * {@link SafeNotifier.notifyAsync}. */ /** One contained failure, as offered to {@link SafeNotifierOptions.onError}. */ export interface SafeNotifyFailure { /** The owner-chosen site name (e.g. `"cascade.onRung"`) — a stable literal, never derived from host input. */ readonly site: string; /** The thrown value, normalized to an `Error`. */ readonly error: Error; /** This site's cumulative failure count including this one (always 1 for a disclosed failure). */ readonly failures: number; } export interface SafeNotifierOptions { /** * Optional disclosure channel: called with the FIRST failure of each site (bounded — later failures of the * same site are counted only). A throw from `onError` itself is swallowed: the disclosure channel is host * code too, and it must not become a second fault source. */ readonly onError?: (failure: SafeNotifyFailure) => void; } /** The sentence of a contained failure, for a consumer that puts it in front of a person. Total by the * same rule as {@link containedError}: an error object handed in from elsewhere (a `lastErrorAt` * reading, a rejection an adapter wrapped) may still carry a hostile `message`, and the disclosure a * site composes from it must not be the thing that faults. */ export declare function safeErrorMessage(error: unknown): string; export interface SafeNotifier { /** * Run one host-callback invocation, containing any throw. Returns `true` when it completed, `false` when it * threw. The thunk normally holds the original optional call (`() => config.onRung?.(info)`), so an absent * callback is a plain no-op that never touches the failure bookkeeping. */ notify(run: () => void, site: string): boolean; /** {@link notify} for a site that AWAITS its callback — contains a rejection as well as a synchronous throw. */ notifyAsync(run: () => unknown, site: string): Promise<boolean>; /** Total contained failures across all sites of this notifier. */ readonly failures: number; /** Contained failures at one site (0 when that site never failed). */ failuresAt(site: string): number; /** The most recent contained error at one site, if any. */ lastErrorAt(site: string): Error | undefined; /** The sites that have failed at least once (diagnostics; empty on the healthy path). */ failedSites(): readonly string[]; } /** * Create an isolation scope for host callbacks. Cheap: the failure bookkeeping is allocated lazily, so a * notifier whose callbacks never throw costs one object and nothing per call beyond the `try`. */ export declare function createSafeNotifier(opts?: SafeNotifierOptions): SafeNotifier; /** * Observe a `=> void` host callback's RETURN value for the async dialect: TypeScript accepts an * `async` function at a void seat, and a rejected one would escape {@link SafeNotifier.notify} * (which deliberately ignores return values — see the scope note above) as an unhandled rejection. * Call this with the callback's return value INSIDE the notify thunk: a thenable's rejection is * routed back through the same notifier/site, so async and sync failures share one count and one * bounded disclosure, and neither can fault the engine's control flow. */ export declare function observeThenableRejection(r: unknown, notifier: SafeNotifier, site: string): void;