UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

170 lines (169 loc) • 11.9 kB
import type { CorruptReadInfo } from "../stores/file/corrupt-read-sink.js"; /** One persisted name→agent binding (the CC roster `members[{name, agentId}]` shape + sema access axes). */ export interface RosterEntry { /** The verbatim spawn name (display form; resolution runs over {@link normalizeAgentName}). */ name: string; /** The background agent's task id (`a…`). */ agentId: string; /** The retained child session id, when known — the cross-run resume address. */ sessionId?: string; /** The spawn's parent tool-call id (retain-ledger key), when known. */ toolUseId?: string; /** the ROOT host session of the delegation tree at spawn (recovery-face grouping * key; equals the spawner's session at depth 1). Stored verbatim; no predicate arm consumes it * yet (enumeration/recovery is the reader). */ rootSessionId?: string; /** design/151 S3b — the model RECORD KEY at spawn (revival lookup key; display/model-routing hints * only, never a serialized spec). NOT the resolved model id: a spawn that named a model in WORDS * records the caller's spelling VERBATIM (a catalog key, tier word or CC alias), because a revival * re-resolves this value against the catalog in force at WAKE time and a resolved id would only * ever resolve again on deployments whose catalog keys happen to equal model ids. A spawn that * carried a Model OBJECT (definition/tool-level/inherited ref) records that object's id, which on * such a deployment is not a catalog key and degrades softly on the revival lane. */ model?: string; /** Ruled 2026-08-05: the spawn requested a model word that did not bind — the row runs on its * inherited default. Closed set, single member today; absent = bound normally (or no word). */ modelFallback?: "inherit_no_tier_binding"; /** Spawn owner (task/session id) — consumers re-apply access checks against these two axes. * A-2: REQUIRED (default-deny predicate; an axis-less row would be unreachable). */ owner: string; /** Spawn principal scope. A-2: REQUIRED (`"default"` is the single-tenant spelling). */ scope: string; /** mirrors the live registry's explicit session-scoping flag: session-ID owner matching * is permitted ONLY when the spawn was session-scoped (TaskRegistry.canAccess parity — without * the flag, a task-scoped agent that fell out of the live registry would become visible through * the durable fallback to same-session callers the registry itself rejects). */ sessionScoped?: boolean; /** Registration wall-clock ms (latest-wins tiebreak, matching registry layer 0). */ createdAt: number; } /** * The caller's access axes for a roster read — the SAME predicate the live task registry applies * (`canAccess`): scope mismatch = invisible; then owner match, or session match. (S1 * review): resolution MUST filter the candidate pool by access BEFORE the latest-wins reduce — * reduce-then-check lets one tenant's newer same-name entry shadow (and thereby suppress) another * tenant's older authorized binding. Implementations (incl. pg/tidb) apply this in the query. */ export interface RosterAccess { owner?: string; scope?: string; sessionId?: string; } /** Ruled 2026-07-22: DEFAULT-DENY both axes (TaskRegistry.canAccess byte-parity — * the polarity split was the defect). Entries are written with both axes (spawn chain guarantees * it; {@link RosterEntry} requires them), so a missing axis is a broken row, answered with a miss. */ export declare function entryAccessible(e: RosterEntry, access: RosterAccess): boolean; /** * The roster persistence seam. All methods MAY be async (a pg-backed store is); core's two bundled * implementations are synchronous. Implementations are ADVISORY infrastructure: a throwing store * must never fault a spawn — callers wrap writes in try/catch and treat read errors as a miss. * * v1 honesty contract (RB-23): writes are FIRE-AND-FORGET from the spawn path — a named-spawn * receipt does NOT acknowledge roster persistence, and a failed/lost async write degrades to a * later `not_found`/`roster_only` miss (never a wrong answer). Acknowledged writes (outbox) are the * DEPLOYMENT'S half: they need a durable spool + retry scheduling, which belongs to whoever owns * the database connection lifecycle — core's advisory contract above is the library-side final form. * * RB-23② release semantics (settled): a roster row's lifetime follows ADDRESSABILITY — the durable * session data behind `sessionId` — NOT the agent's running state and NOT the parent-run retain * ledger. Core deliberately wires NO terminal-cleanup or retain-evict call into {@link * RosterStore.releaseAgent}: retain is a parent-run-scoped cache (disposed with the run), while the * roster exists precisely to outlive it; releasing at terminal would kill cross-run resolution, the * store's whole purpose. The correct trigger is durable session DELETION, which happens on the * deployment side — a deployment's session GC calls `releaseAgent` (or joins roster rows against * session liveness in SQL). A stale row left behind costs only an honest downstream failure on * resume (miss / `resume.evicted`-class error — never a wrong answer); {@link RosterGcOptions} * `maxAgeMs` is the bounded-staleness backstop for deployments that want one (RB-23③), and * {@link RosterGcOptions.maxEntries} the bounded-SIZE one (HRD-AGO-11). Both are OFF by default: a bundled * store keeps every live (name, owner, scope) binding forever, so a deployment that spawns under a fresh owner * per run grows one permanent row per spawn until it opts into a bound. That default is deliberate — an * evicted row is a durable ADDRESS a caller can no longer reach — but it is the deployment's to change. */ export interface RosterStore { /** Record (or supersede — latest-wins per normalized name is applied at READ time) a binding. */ record(entry: RosterEntry): void | Promise<void>; /** Resolve a name for a CALLER: filter by {@link RosterAccess} FIRST, then latest `createdAt` * wins within the authorized pool (filter-before-reduce). Undefined = miss. */ resolve(name: string, access: RosterAccess): RosterEntry | undefined | Promise<RosterEntry | undefined>; /** All live entries (diagnostics / deployment listing; order unspecified). */ list(): RosterEntry[] | Promise<RosterEntry[]>; /** Drop every binding for an agent id (agent stopped and unaddressable / deployment GC). */ releaseAgent(agentId: string): void | Promise<void>; } /** * RB-23③ (settled): optional bounded-staleness GC for the bundled stores. `maxAgeMs` is a * DEPLOYMENT knob because the right value is the deployment's durable-session retention horizon * (align the two and a roster row can never outlive the address it points at) — core cannot know * it, so the default is NO TTL (current behavior; the file store's upsert already bounds growth by * live name cardinality, not spawn count). Semantics: an expired row is a MISS on every read * (resolve/list) and is opportunistically dropped on the next write — same honesty class as every * other degradation here (expired ⇒ miss, never a wrong answer). A pg-backed store implements the * same two arms in SQL (`WHERE created_at > now() - ttl` + periodic delete). */ export interface RosterGcOptions { /** RB-473 — disclosure channel for CONTAINED host-callback failures in this scope (the RB-463 * isolation primitive's bounded first-failure per site). Absent ⇒ failures are counted only. */ onNotifyError?: (failure: import("../core/safe-notify.js").SafeNotifyFailure) => void; maxAgeMs?: number; /** * HRD-AGO-11 (opt-in) — hard cap on live rows in a bundled store. **Default: UNBOUNDED**, and deliberately * so: the upsert domain is (normalized name, owner, scope), which bounds growth by LIVE NAME CARDINALITY * only — a deployment that spawns `worker` under a FRESH owner per run (a per-task owner id is the common * shape) accumulates one permanent row per spawn, and the file store rewrites the whole document on every * write, so the cost is O(n²) over a deployment's lifetime. A blind default cap would be worse than the * leak: a roster row IS a durable ADDRESS, and silently evicting one turns a live agent unaddressable * (`not_found` on a name the caller legitimately holds). So the bound is the DEPLOYMENT's call — it is the * only party that knows how many live addresses it needs — and this is how it says so. * * Policy when set: after each write, while the row count exceeds `maxEntries`, drop the row with the * SMALLEST `createdAt` (oldest binding first; the row just written is by construction the newest, so a * write never evicts itself). Every dropped row is reported to {@link onEvicted} — an evicted address is a * capability the deployment loses, so it is never dropped in silence. Reads are unaffected (an evicted row * is simply gone: an honest miss, never a wrong answer — the same honesty class as `maxAgeMs`). * * A pg/tidb-backed store implements the same shape in SQL (a windowed delete keyed on `created_at`). */ maxEntries?: number; /** HRD-AGO-11 — called with each row dropped by the {@link maxEntries} cap (never for a normal upsert * supersede). A throwing sink is swallowed: roster writes are advisory and must never fault a spawn. */ onEvicted?: (entry: RosterEntry) => void; } /** In-memory roster (the default): cross-turn within one process, gone on restart. */ export declare class MemoryRosterStore implements RosterStore { private entries; private readonly maxAgeMs; private readonly gc; constructor(opts?: RosterGcOptions); record(entry: RosterEntry): void; resolve(name: string, access: RosterAccess): RosterEntry | undefined; list(): RosterEntry[]; releaseAgent(agentId: string): void; } export declare class FileRosterStore implements RosterStore { private readonly path; private readonly maxAgeMs; private readonly gc; private readonly onCorruptRead; constructor(path: string, opts?: RosterGcOptions & { onCorruptRead?: (info: CorruptReadInfo) => void; }); /** RB-482 #25 (baggage class-4 ruling): the LENIENT read grade's swallow stays (a store fault must read as a * miss on resolve/list — advisory layer), but it is no longer SILENT: an IO failure or corrupt * document was indistinguishable from an honestly empty roster. Never fires on plain ENOENT. */ private discloseCorrupt; /** two read grades: MISSING file (ENOENT) is an honest empty roster, but a corrupt or * transiently unreadable file must THROW on the mutation path — treating it as empty would let * the next `record` atomically REPLACE the existing file with just one row (silent data loss). * Read paths (resolve/list) degrade the throw to a miss via `lenient`. */ private read; private write; record(entry: RosterEntry): void; resolve(name: string, access: RosterAccess): RosterEntry | undefined; list(): RosterEntry[]; releaseAgent(agentId: string): void; } /** design/147 S1c — advisory durable-roster write for a NAMED background spawn: never throws and never * unhandled-rejects (sync or async store alike); a roster fault must not fault the spawn. * design/157 A7 — moved from `agents/subagent.ts`: the semantics are roster's, not the delegation * tool's (the signature already value-imports `RosterStore`/`RosterEntry` from this module). */ export declare function recordRosterSpawn(roster: RosterStore | undefined, entry: RosterEntry, onError?: (err: unknown) => void): void;