@sema-agent/core
Version:
Stateless, task-oriented AI agent core
297 lines (296 loc) • 21.6 kB
TypeScript
/** 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. Non-finite `sentAt` ⇒ undefined ("unknown age ⇒ never reaped" — 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>;
}