UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

298 lines (297 loc) • 21.8 kB
/** One parked message. `seq` is minted by the store, monotonic per (scope, handle), stable across * lease/expiry cycles (a re-leased message keeps its number — dedup/ordering key). */ export interface MailboxMessage { seq: number; /** Sender display name, when the sender had one (SendMessage `senderName` — display only). */ from?: string; content: string; sentAt: number; /** design/176 — the peer-message forwarding chain (engine-minted hop tokens; typed side channel, * never model-facing text). Presence — even an empty array — marks the record as written through * the sema send gate; an absent field means a pre-176 or FOREIGN-engine record (a shared CC inbox * has other writers): such records deliver with an empty chain and are never retro-admitted — * cross-engine records sit outside the guard's promise domain by ruling. */ hopChain?: string[]; /** design/385 — typed peer metadata the drain point's authoritative judgments read (from-mode parity, * reply routing, admission sender key, record kind, the cross-principal trust anchors). Typed side * channel, never model-facing text — encoding it into `content` would let text reach authority. * ABSENT = a pre-385 or foreign record (same semantics as an absent `hopChain`): the drain treats it * as foreign, never retro-admits it. Validated at `append` ({@link readMailboxPeerMeta}). */ peerMeta?: MailboxPeerMeta; } /** design/385 — the typed peer-metadata record on a parked message. ONE optional object, one-time * contract extension; every field optional. Bundled backends persist it verbatim and hand back a * detached copy. */ export interface MailboxPeerMeta { /** The sending session's id (reply routing). */ fromSession?: string; /** The delegation-tree ROOT the sender belongs to, stamped by the park seat of a session that * received the frame through its own child uplink — never by the sender. A recipient whose * session IS this root reads the record as a teammate's words (same tree = same admission as the * live uplink: the admission gate runs, the cross-session parity hold does not). */ fromRoot?: string; /** The sender's permission-mode class at send time — the drain judges parity against the RECEIVER. */ fromMode?: MailboxPeerFromMode; /** The admission gate's sender key (rate/dedup axis). */ senderKey?: string; /** Record kind: a peer message runs the parity judgment; notices bypass it and land on the notice face. */ kind?: MailboxPeerRecordKind; /** Cross-principal delivery only — the SENDER's scope, the typed trust anchor for attribution. */ fromScope?: string; /** Cross-principal delivery only — the delivery gate's receipt id (audit back-reference). */ gateReceiptId?: string; /** Notice-kind records only (design/385 §4.4 / §5.2): the typed notice the requester's drain renders * — a delivery receipt's state (`kind: "delivery_notice"`) or an idle notice's kind * (`kind: "idle_notice"`). The record's `content` is a human-readable mirror; THIS is the authority. */ notice?: MailboxPeerNoticeMeta; /** `peer_message` only — the SENDER marked it urgent. The recipient's drain presents an urgent record * ahead of the non-urgent ones of the same pass and counts it on the inbox notice; it never changes * WHEN the recipient reads (the recipient's setting decides that, never the sender). */ urgent?: boolean; } /** design/385 — the typed body of a notice-kind record. `state` is drawn from the receipt states on a * `delivery_notice` (`held|denied|expired|delivered|refused|dropped`) and from the idle kinds on an * `idle_notice` (`idle|exited|unavailable|expired`); the validator admits the union, the drain checks * the pairing (a state that does not fit its kind is settled `notice_unrouted`, never rendered). */ export interface MailboxPeerNoticeMeta { state: string; /** delivery_notice: the box seq of the SENDER'S message this receipt is about. */ refSeq?: number; /** delivery_notice: the recipient's display label (as its directory row spells it). */ recipient?: string; /** delivery_notice `dropped`: the ingress guard's reason. */ dropReason?: string; /** idle_notice `idle`/`exited`: when the target finished its turn (epoch ms). */ finishedAt?: number; /** idle_notice `idle`: the target's one-line detail (already bounded by the producer). */ detail?: string; /** idle_notice `expired` (the requester answering ITSELF): the label the ask carried for the target * that never answered — the record's author is the requester, so the target must be named here. */ target?: string; } export declare const MAILBOX_PEER_FROM_MODES: readonly ["bypass", "prompting"]; export type MailboxPeerFromMode = (typeof MAILBOX_PEER_FROM_MODES)[number]; /** * design/385: `peer_message` runs the parity judgment; `idle_subscription` registers the sender as an * idle subscriber of the box owner (§5.2, the store form of CC's `notify_when_idle` frame); * `idle_notice` / `delivery_notice` land on the notice face (rendered, never enveloped as a peer's words). * * @contract mailbox.engine_frame_is_not_a_peers_words — an `engine_frame` record is the ENGINE's own * task-notification frame parked durably (a child's terminal, a monitor batch, a workflow settle), * written by the park seat of the session that owns the box and only over a deployment that vouches * every drainer of the box reads the codec (`RunnerDeps.sessionBoxEngineFrames`). Its `content` is the * versioned codec text (`encodeEngineFrame`), never a message: the drain decodes it and re-injects the * frame through the engine's own notification lane — no parity judgment, no admission gate, no peer * envelope — and a codec version this engine does not read STOPS the drain at that record (the box * waits for an engine that reads it) rather than rendering engine speech as a peer's words. Only the * engine's own lanes (`background_agent` / `monitor` / `workflow`) may be parked this way; an * `external` event never is. */ export declare const MAILBOX_PEER_RECORD_KINDS: readonly ["peer_message", "idle_notice", "delivery_notice", "idle_subscription", "engine_frame"]; export type MailboxPeerRecordKind = (typeof MAILBOX_PEER_RECORD_KINDS)[number]; /** The `notice.state` vocabulary the validator admits (the union of both notice families; the drain * checks the kind↔state pairing). Spelled here, beside the record kinds, so the store contract is * self-contained — the lane's own closed sets re-derive from these. */ export declare const MAILBOX_PEER_NOTICE_STATES: readonly ["held", "denied", "expired", "delivered", "refused", "dropped", "idle", "exited", "unavailable"]; export type MailboxPeerNoticeState = (typeof MAILBOX_PEER_NOTICE_STATES)[number]; /** The `append` refusal code for a malformed `peerMeta` (design/385): a garbage record is refused up * front with this code, never silently stripped or stored — a drain that reads a half-typed record * would judge on fabricated inputs. */ export declare const MAILBOX_INVALID_PEER_META_CODE = "mailbox.invalid_peer_meta"; /** The lane-mount refusal code: the cross-session lane asked for a backend that does not declare * cross-process safety ({@link mailboxCrossProcessMountVerdict}). */ export declare const MAILBOX_CROSS_PROCESS_UNSAFE_CODE = "mailbox.cross_process_unsafe"; /** * Validate + detach an appended `peerMeta`. `undefined` ⇒ `undefined` (absent record). Anything else * must be a plain object whose keys are all known, whose present values are non-empty strings, with * `fromMode`/`kind` drawn from their closed sets; an `undefined`-valued key counts as absent. Any other * shape throws {@link MailboxStoreError} with {@link MAILBOX_INVALID_PEER_META_CODE} — every bundled * backend calls this BEFORE touching storage, so a refused append has zero side effects. Unknown keys * are refused on purpose: the object is the contract's one typed slot, and a key nobody declared is * either a typo or a newer schema this engine cannot judge on — both are loud, not silent. */ export declare function readMailboxPeerMeta(raw: unknown): MailboxPeerMeta | undefined; /** A detached copy of a persisted record's `peerMeta` (absent stays absent) — the read-side twin of * {@link readMailboxPeerMeta}, so a lease consumer's mutation never reaches the stored record. */ export declare function cloneMailboxPeerMeta(meta: MailboxPeerMeta | undefined): MailboxPeerMeta | undefined; /** The enqueue refusal code of the pre-delete clause (see {@link MailboxStore} and * {@link MailboxStoreError}) — the ONE place it is spelled, so an out-of-repo store twin imports it * instead of value-copying the string (same posture as `STALE_RUNNING_REAP_ATTRIBUTION`: a shared * symbol makes drift impossible rather than merely caught). */ export declare const MAILBOX_TOMBSTONED_RECIPIENT_CODE = "mailbox.recipient_tombstoned"; /** * Typed store error so callers branch on `code` (mirrors `BackgroundAgentStoreError`). * * `recipient_tombstoned` is the ENQUEUE refusal a backend raises when the recipient it addresses is * in a deployment's PRE-DELETE state — the window a retention/deletion cascade opens when it has * decided a session (and every row hanging off it) is going away but has not finished removing the * rows. An `append` accepted in that window returns a seq to the sender — a durable receipt — for a * message the cascade then deletes before any consumer could ever lease it. The refusal is what * turns that into a fact the sender can act on. * * A backend may raise the same code with a plain `Error` carrying `.code`; consumers branch on the * string, not on this class (a cross-process/out-of-repo store cannot hand back an instance). */ export type MailboxStoreErrorCode = typeof MAILBOX_TOMBSTONED_RECIPIENT_CODE | typeof MAILBOX_INVALID_PEER_META_CODE | typeof MAILBOX_CROSS_PROCESS_UNSAFE_CODE; export declare class MailboxStoreError extends Error { readonly code: MailboxStoreErrorCode; constructor(code: MailboxStoreErrorCode, message: string); } /** A leased batch: the messages a claim winner owns for delivery, plus the ack cursor. */ export interface MailboxLease { messages: MailboxMessage[]; /** Ack everything ≤ this seq once the revived cycle has durably received the batch. */ maxSeq: number; } /** REF-D18: one appended mailbox message — the SINGLE shape the interface method and both the * In-Memory and File implementations reference. */ export interface MailboxAppendMessage { from?: string; content: string; sentAt: number; /** design/176 — see {@link MailboxMessage.hopChain}. */ hopChain?: string[]; /** design/385 — see {@link MailboxMessage.peerMeta}; validated by {@link readMailboxPeerMeta}. */ peerMeta?: MailboxPeerMeta; } /** * The pluggable mailbox seam (design/151 §7.1). Contract notes for implementations (file/pg): * - `append` is durable-first (a returned seq means the message survives a crash); seq is monotonic * per (scope, handle) and never reused WITHIN a box's lifetime (across lease/ack cycles the * high-water mark persists). `drop` ENDS the lifetime — a later rebuild restarts at 1 (server[1523] * 定谳: the row died with the box, dedup keys carry the handle, and a* handles are 64-bit-random). * RB-90 (2026-07-25, red probe) SPLITS `reap` back out of that sentence: the two were grouped, but * only `drop` is preceded by the row's death. `reap` is an age-based sweep that runs while the * recipient is alive and may hold a lease, so restarting seq there let a stale ack (up to the * pre-sweep high-water) delete a brand-new message no consumer had ever been shown — the "a parked * message is never lost" promise at the top of this file, broken silently. `reap` therefore CLEARS a * box and KEEPS its high-water mark; both bundled impls do so. * - `claimLease` atomically marks every currently-visible (unleased or lease-expired) message as * leased by `owner` until `now + ttlMs`, returning them in seq order. Empty mailbox → null. * A second claim by a DIFFERENT owner while a live lease exists returns null (single consumer * per window — the row claim-CAS already guarantees one revival winner; this is the belt); the * SAME owner re-claiming RENEWS the lease and picks up any newer messages (the crash-retry form * — server[1523] 定谳: both bundled impls behave so, the contract follows the implementations). * - `ack` deletes every message with seq ≤ upToSeq (the winner calls it at ATTACH success, never * before — §7.1: ack-late is what makes a crashed revival lossless). * - `releaseLease` (best-effort) returns a lease early (spawn preflight failed before delivery). * - `reap` ages out whole mailboxes by newest-message age (explicit policy). It is the BACKSTOP, not * the only reclaim path: the winning-delete arm of `reapDurableAgents` calls {@link MailboxStore.drop} * for the row it deletes, in the same sweep (F-13 — the row's mailbox dies with the row). That call * is ADVISORY: a drop fault never blocks the reap, and a box orphaned that way still ages out here. * `drop` therefore has a real caller — an implementation that stubs it strands mailboxes until the * age policy catches them. * - PRE-DELETE STATE (optional capability, additive contract extension): a backend that can SEE its * recipient's lifecycle — one whose rows live alongside the session rows a retention/deletion * cascade removes — must refuse `append` for a recipient the cascade has already tombstoned, * CODED: an `Error` whose `code` is `"mailbox.recipient_tombstoned"` ({@link MailboxStoreError} is * the bundled shape; consumers branch on the string). Accepting is the failure this clause names: * `append` is durable-first, so the returned seq is a receipt for a message the cascade deletes * before any consumer can lease it — a delivery promised to the sender and kept to nobody, which * no other face reports. The refusal is per RECIPIENT and enqueue-only: a tombstone belongs to one * `(scope, handle)` — the same two-part identity every other method here is keyed by, so it says * nothing about a sibling handle NOR about the same handle under another scope — and it is not a * wipe (whatever is already parked stays under the same lease/ack rules until the cascade removes * the box). A backend with NO view of that * lifecycle (the two bundled ones, the CC inbox adapter) has nothing to refuse and keeps accepting * — the clause fixes the SPELLING of the refusal, so a deployment reads one code instead of a * per-backend dialect. Acceptance kit: `mailboxTombstonedRecipientContract`. * - PEER METADATA (design/385, additive): `append` takes an optional typed `peerMeta`; a backend * persists it and hands it back on `claimLease` byte-for-byte (a detached copy), absent stays absent, * and a malformed value is REFUSED with `"mailbox.invalid_peer_meta"` before any side effect * (`readMailboxPeerMeta` is the one validator; bundled backends call it first). T1 cases in * `mailboxStoreContract` cover the pass trip, the refusal and the aliasing. * - CROSS-PROCESS SAFETY (design/385, optional capability declared on the store): the session-box * drain shares ONE box between several OS processes. A backend that is correct under that sharing — * seq minted once across processes, lease/ack never crossing, one process's housekeeping never * discarding another's durable append — declares `crossProcessSafe: true` AFTER passing * `mailboxCrossProcessContract` (the T2 kit that pins the whole set, not "has a lock"). A backend * that does not declare it is refused by the lane at mount time with a named reason * ({@link mailboxCrossProcessMountVerdict}) — never silently mounted on luck. */ /** #1036 — what {@link MailboxStore.ack} answers: did this call actually consume the prefix? * `false` = the lease fence refused it (this caller does not hold the box), so nothing was deleted * and the records are still parked for whoever does. */ export interface MailboxAckResult { readonly acked: boolean; } export interface MailboxStore { /** design/385 — cross-process safety declaration (see the interface notes). `true` = this backend * passed `mailboxCrossProcessContract` over real OS processes; `false` = deliberately process-local * (the in-memory reference); absent = undeclared (the CC inbox adapter). Read by * {@link mailboxCrossProcessMountVerdict}; any other value is a malformed declaration and refuses. */ readonly crossProcessSafe?: boolean; /** Durably park one message. Refuses `"mailbox.recipient_tombstoned"` when the backend can see * that its recipient is in the deployment's pre-delete state (see the interface notes above). */ append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>; claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>; /** * Consume the leased prefix up to `upToSeq` (the records are DELETED). Fenced by the current lease: * a caller that does not hold it consumes nothing. * * #1036 — the fence's verdict is REPORTED (`{acked:false}`), not silent. The fence itself is * older (MB1): it exists so a stale owner whose lease a live rival already took over cannot delete * the rival's messages. What was missing is that a no-op and a success were the same `void` to the * caller, so a drain that had lost its lease would clear its own watermark as if the store had * obeyed it — the one shape where "the records stay parked" turns into "the records are forgotten". * Implementations report `{acked:true}` only when they actually consumed the prefix. */ ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<MailboxAckResult>; releaseLease(scope: string, handle: string, owner: string): Promise<void>; /** Observation face (the "N pending" receipt figure) — count of currently-visible messages. */ peekCount(scope: string, handle: string): Promise<number>; /** Drop the WHOLE mailbox for (scope, handle) — the row-deletion companion (F-13). */ drop(scope: string, handle: string): Promise<void>; /** Age out mailboxes whose newest message BY `sentAt` (not by array/insertion order — RB-250②) * is older than maxAgeMs. Returns mailboxes dropped. */ reap(scope: string, now: number, opts?: { maxAgeMs?: number; }): Promise<number>; } /** The lane-mount verdict on a backend's cross-process declaration. */ export type MailboxCrossProcessVerdict = { ok: true; } | { ok: false; code: typeof MAILBOX_CROSS_PROCESS_UNSAFE_CODE; reason: string; }; /** * design/385 — may the cross-session lane (several OS processes draining one session box) mount over * this backend? `ok` only for an explicit `crossProcessSafe: true`. Every other shape refuses with a * named reason so the host can print it: absent (undeclared), `false` (process-local by design), or a * value of the wrong type (a malformed declaration is refused, not read as "probably fine"). */ export declare function mailboxCrossProcessMountVerdict(store: MailboxStore): MailboxCrossProcessVerdict; /** RB-250② (2026-07-28) class fix — the box age for `reap` is the MAX `sentAt`, not the * last array element: `sentAt` is caller-supplied, so append order need not be time order, and taking * the tail let a box whose freshest message was mid-array be swept — both bundled backends discarded * a brand-new message while the CC adapter (stores/cc/mailbox-store.ts, the in-package reference) * already reduces over MAX. Non-finite `sentAt` ⇒ undefined ("unknown age ⇒ never reaped", the CC * adapter's documented posture — treating it as very old would let a malformed row delete a live * box). Module-level export only; NOT re-exported from src/index.ts. */ export declare function newestSentAt(messages: readonly MailboxMessage[]): number | undefined; /** A lease-facing copy of a stored record: every mutable member (hopChain, peerMeta) detached, so a * consumer's mutation can never reach the record a redelivery serves. Shared by the bundled backends. */ export declare function detachMailboxMessage(m: MailboxMessage): MailboxMessage; /** * In-process reference implementation (single-instance / tests). Same posture as * {@link import("./background-agent-store.js").InMemoryBackgroundAgentStore}: not default-mounted, * detached copies at both boundaries, single-event-loop atomicity. */ export declare class InMemoryMailboxStore implements MailboxStore { /** Process-local by construction (a Map): never a cross-process box, said so explicitly. */ readonly crossProcessSafe = false; private boxes; private key; private box; append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>; claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>; ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<MailboxAckResult>; releaseLease(scope: string, handle: string, owner: string): Promise<void>; peekCount(scope: string, handle: string): Promise<number>; drop(scope: string, handle: string): Promise<void>; reap(scope: string, now: number, opts?: { maxAgeMs?: number; }): Promise<number>; }