@sema-agent/core
Version:
Stateless, task-oriented AI agent core
170 lines (169 loc) • 11.9 kB
TypeScript
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;