@sema-agent/core
Version:
Stateless, task-oriented AI agent core
296 lines (295 loc) • 18.2 kB
TypeScript
import { type MailboxCrossProcessVerdict, type MailboxStore } from "../core/mailbox-store.js";
import { type SessionBoxBinding, type SystemInjectionPriority, type TaskNotificationPayload } from "../core/task-notification.js";
import { type FrameSettlement } from "../core/frame-settlement.js";
import { type EngineNotice, type RunnerDeps } from "../core/types.js";
import { type PermissionModeClass } from "./cross-session-envelope.js";
import { type CrossSessionInboundSettingLayers } from "./cross-session-judge.js";
import { type PeerLaneConfig } from "./peer-admission.js";
import { type PeerDirectory } from "./peer-directory.js";
import { createListAgentsTool, type ListAgentsReadSeats } from "./list-agents-tool.js";
import { foldPermissionModeClass } from "./cross-session-judge.js";
import type { AskEffective } from "../core/wiring-manifest.js";
import type { SendMessageToolOptions } from "./send-message-tool.js";
import { type PeerClock, type PeerHeldQueue } from "./peer-held-queue.js";
import { type PeerIdleMachine } from "./peer-idle.js";
/** design/385 §1.2④ — the drain lease TTL. Same sizing law as the tier-3 revive lease: well under the
* store's stale-row floor, long enough to cover one turn (a longer turn renews on the same owner at
* its next boundary; an expired lease re-serves the batch — at-least-once). */
export declare const PEER_DRAIN_LEASE_TTL_MS: number;
/** The lane-mount refusal for a run whose own session id cannot be spelled as a peer address. */
export declare const PEER_SESSION_ID_UNGRAMMATICAL_CODE = "peer.session_id_ungrammatical";
/** The lane's mount verdict: the mailbox declaration's verdict, or this leg's own address refusal. */
export type PeerLaneMountVerdict = MailboxCrossProcessVerdict | {
ok: false;
code: typeof PEER_SESSION_ID_UNGRAMMATICAL_CODE;
reason: string;
};
/**
* design/385 §1.2⑥ / §5.1 — may the cross-session lane mount on this deployment? `undefined` = no
* directory seat (no lane, nothing to say). A directory with no mailbox, or over a mailbox that does not
* declare cross-process safety, is REFUSED with a named reason (bad-value loudness: several terminals
* sharing one box on luck is exactly what the declaration exists to forbid). With a `sessionId`, the
* leg's OWN address is judged too: a host-minted session id outside the peer address grammar has no
* session box to drain and no address a peer could send to, so the lane refuses by name — the
* alternative was `peerSessionBoxHandle` throwing out of prepare and failing the whole run over an
* optional feature.
*/
export declare function judgePeerLaneMount(deps: {
peerDirectory?: PeerDirectory;
mailboxStore?: MailboxStore;
}, sessionId?: string): PeerLaneMountVerdict | undefined;
/**
* May THIS leg drain its own session box, and where is that box? The ONE mount judgment of the
* session-box drain, read by two seats: the wiring station binds the drain on it, the notification
* lane binds the park seat on it ({@link SessionBoxBinding}) — so a frame is parked into a box exactly
* when a leg of that session drains it. Independent of the peer DIRECTORY (the directory adds the
* discovery faces; the box is also where a child's `SendMessage("main")` waits while its host session
* is idle). A DELEGATED CHILD never drains one: its addressable box is its `a*` handle (the tier-3
* revival lane), and a drain per child on a box nobody writes would cost a lease claim per turn on
* every background agent. `undefined` on every refusal — silently: no host asked for a box here.
*/
export declare function judgeSessionBoxDrain(deps: Pick<RunnerDeps, "mailboxStore" | "onError" | "sessionBoxEngineFrames">, leg: {
sessionId: string;
scope: string;
isDelegatedChild: boolean;
}): SessionBoxBinding | undefined;
/** What one pull of the box handed back: the records read (already rendered frames), and what is still waiting. */
export interface PulledInbox {
delivered: TaskNotificationPayload[];
/** Records still leased in the box after this pull (the next pull, or the next boundaries, read them). */
pending: number;
/** How many of the waiting records their sender marked urgent. */
urgent: number;
}
/** The disposition arms of a `peer.inbound_disposition` notice (closed set; `detail.disposition`).
* #1036 adds `resupply_exhausted`: a record whose frame a compaction summarized away, was
* re-supplied once, and was summarized away AGAIN. It settles (leaves the box) — but as a DISCLOSED
* loss, never as "the model read it". The cap is a bound on delivery attempts; it does not
* manufacture a consumption proof. */
export type PeerInboundDisposition = "refused" | "held" | "admission_refused" | "notice_unrouted" | "notice_user_only" | "resupply_exhausted";
/**
* #1036 — how many times a record whose frame was summarized away is handed over again before the
* drain gives up and discloses. `1` = one repair, i.e. at most two occurrences of one record in one
* run. A cap exists because repair that cannot converge is a loop; it is this small because the
* second occurrence already says everything the first did. RUN-SCOPED (the ledger is in memory): a
* crash or a takeover re-serves the record at `attempt: 1`, the at-least-once posture this lane has
* always had, not a new hole.
*/
export declare const PEER_RESUPPLY_CAP = 1;
export interface PeerSessionDrainOptions {
mailbox: MailboxStore;
/** This session (the box owner) and this leg's cycle id (the lease owner). */
sessionId: string;
runId: string;
/** The registry/mailbox scope this run mounts in. */
scope: string;
/** The run's own notification injector (the task-notification lane; `next` tier). */
inject: (notification: TaskNotificationPayload, opts?: {
priority?: SystemInjectionPriority;
}) => void;
/** The peer lane's knob table (admission gate + delivery budgets + listing window; the same seat SendMessage reads). */
lane?: Partial<PeerLaneConfig>;
/** The recipient's self-token set for the hop check, read live (the run's peer identity ref). */
ownTokens: () => readonly string[];
/** The recipient's `crossSessionInbound` layers, read at every drain. */
settingLayers: () => CrossSessionInboundSettingLayers | undefined;
/** The recipient's own permission-mode class, read at every drain (the manifest's ask fold). */
selfModeClass: () => PermissionModeClass | "unknown";
/** This session's display name (its directory row's name), for receipts and idle notices it authors. */
selfName?: () => string | undefined;
/** Awaited ONCE before the first pass (a directory lookup that fills `selfName`, say); a rejection is swallowed. */
warmup?: () => Promise<unknown>;
/** The deployment's `dialogExpiry` (raw; resolved at bind — an out-of-vocabulary value is announced
* once through `onError` and the default applies). Absent ⇒ the default (5 m). */
dialogExpiry?: () => unknown;
/** The last turn's text, for the idle notice's one-line detail (CC `getLastTurnText`). */
lastTurnText?: () => string | undefined;
/** Whether the run is parked on a pending ask / review at its end (a parked run is `blocked`, not idle). */
parked?: () => boolean;
onNotice?: (notice: EngineNotice) => void;
onError?: (error: unknown, ctx: {
phase: "degraded";
sessionId: string;
classification: string;
}) => void;
/** #1036 — the LEG's settlement seam: the receipts the harness records when a frame reaches
* the transcript and the effects the compaction commit beat records. Absent ⇒ the drain mints a
* private one, which is honest but inert: nothing fills it, so every delivered record waits for a
* receipt that never comes and stays parked (at-least-once). Every production binding supplies it. */
settlement?: FrameSettlement;
leaseTtlMs?: number;
/** Tests: a virtual clock for the held deadlines and idle timers (honored when this process first sees the session). */
clock?: PeerClock;
}
export interface PeerSessionDrain {
/**
* One drain pass (serialized: a boundary never overlaps a still-running pass).
*
* @contract cross_session.settled_means_committed_and_not_summarized — a session-box record is acked
* only after a frame carrying it has been committed to the transcript and no compaction applied up to
* that settlement beat summarized that entry away. A summarized frame is re-supplied once (a new
* occurrence of the SAME record: same `task_id`, same `seq`, `attempt: 2`, `resupplied: true`), and a
* re-supplied occurrence summarized again settles as `resupply_exhausted`, disclosed — never as seen.
* OBSERVABLE: a `compacted` frame counting a not-yet-acked delivered record
* (`summarizedCrossSessionMessages >= 1`) is followed, in the same boundary's delivery half, by a
* re-supply frame for it or by a `resupply_exhausted` disposition — never by silence; a record
* committed and never summarized is never shown twice; no compaction implies no re-supply.
*/
drain(): Promise<void>;
/**
* The PULL face (the `ReadInbox` tool): one pass over the same box, same cursor, same judgments —
* but the records are RETURNED instead of injected, up to `limit` (default = the pass budget). A
* record a boundary already handed over is never returned again, and a record returned here is
* never injected later: one cursor (`settledUpTo`), two readers. Never acks (the next boundary does).
*/
pull(limit?: number, toolCallId?: string, charBudget?: number): Promise<PulledInbox>;
/**
* #1036 — THE TURN BOUNDARY's pass, called by the boundary lane as its tail so it runs AFTER that
* boundary's compaction (a hook of its own would have been registration-order luck). The settlement
* half always runs; the delivery half only when `continues` — a boundary this leg will not follow
* with another request cannot produce a transcript receipt for anything handed over there.
*/
boundaryRound(o: {
continues: boolean;
}): Promise<void>;
/** #1036 — "is this engine frame's record still parked in this box?" The terminal sweep's
* per-session pend must skip those: the box re-serves them itself, and a process-local copy beside
* a parked record is two sources of truth for one message. */
isBoxBacked(payload: unknown): boolean;
/** #1036 — "does this leg hold a record a landed compaction could still break?" The compaction
* commit beat asks before recording its effect, so a run that will never read one records none. */
wantsCompactionEffects(): boolean;
/** Run end: settle what reached the transcript, then release the lease so the session's next cycle
* claims at once. What did NOT reach it stays parked (at-least-once, next run re-serves it). */
finish(): Promise<void>;
/** The run began working (cancels a pending idle fire). */
busy(): void;
/** The run ended with nothing queued and no pending ask: the one-shot idle fire is scheduled (§5.2). */
idle(finishedAt: number): void;
/** This session's held queue (process-level; the host review face reads/settles through it). */
readonly held: PeerHeldQueue;
/** This session's idle machine (process-level). */
readonly idleMachine: PeerIdleMachine;
}
export declare function createPeerSessionDrain(opts: PeerSessionDrainOptions): PeerSessionDrain;
/** The lane's late-bound facts: the ask fold resolves after the manifest derivation, the mount runs
* before it — both read this one cell. */
export interface PeerLaneRefs {
askEffective?: AskEffective;
/** The session's EFFECTIVE permission mode, read live (it follows a mid-run mode change); absent on a leg
* that carries no mode fact. Paired with `askEffective` in the one mode-class fold. */
permissionMode?: () => Parameters<typeof foldPermissionModeClass>[0];
/** The bound drain's pull face (filled by {@link bindPeerLaneDrain}); the `ReadInbox` tool reads it late. */
pull?: (limit?: number, toolCallId?: string, charBudget?: number) => Promise<PulledInbox>;
/** #1036 — the bound drain's BOUNDARY pass (filled by {@link bindPeerLaneDrain}); the leg's
* prepared seat copies it out so the turn-boundary lane can tail-call it. */
boundaryRound?: (o: {
continues: boolean;
}) => Promise<void>;
/** #1036 — the bound drain's box-backed predicate (filled by {@link bindPeerLaneDrain}). */
isBoxBacked?: (payload: unknown) => boolean;
}
/**
* The mount verdict + its loud half: `true` = the lane mounts on this leg; `false` = no seat, or the
* seat was refused — the refusal is announced ONCE per leg as `config.peer_lane_unmounted` naming the
* reason (never mounted on luck, never silently inert).
*/
export declare function announcePeerLaneMount(deps: {
peerDirectory?: PeerDirectory;
mailboxStore?: MailboxStore;
onNotice?: (n: EngineNotice) => void;
}, leg: {
sessionId: string;
runId: string;
}): boolean;
/** The SendMessage seats of the lane (design/385 §2): the directory (address arm + last name rung) and
* this session's identity/attestation, whose mode class folds LIVE off the manifest's ask derivation. */
export declare function peerLaneSendMessageSeats(args: {
peerDirectory: PeerDirectory;
sessionId: string;
scope: string;
name?: string;
refs: PeerLaneRefs;
listingMounted?: boolean;
}): Pick<SendMessageToolOptions, "peerDirectory" | "peerSelfSession" | "peerListingMounted">;
/**
* design/385 §5.1 — THE ListAgents mount predicate, single source: the built-in mounts only when the
* face does not exclude it and no caller tool owns either spelling (`ListAgents` / `ListPeers`) as its
* name OR as an alias. Every reader of "is ListAgents on this roster?" — the mount itself and the
* SendMessage face's listing phrase — calls this one predicate, so a face can never name a tool the
* roster does not carry (a second, inline copy of the rule drifted on exactly the alias arms).
*/
export declare function listAgentsMountable(face: {
exclude: readonly string[] | undefined;
specTools: ReadonlyArray<{
name: string;
aliases?: readonly string[];
}>;
}): boolean;
/** design/385 §5.1 — the ListAgents mount (alias ListPeers): read-only, concurrency-safe, on the peer
* seat's own arm beside SendMessage; a caller-supplied tool of the same name wins (skip, not throw). */
export declare function mountListAgents(args: {
/** The directory when the peer lane is up; absent on a delegation-only mount (agent lanes list, no peer rows). */
peerDirectory?: PeerDirectory;
/** The resolved listing freshness window (the peer lane's knob table). */
freshWindowMs?: number;
sessionId: string;
scope: string;
hostTaskId: string;
/** The delegated child's trusted parent pair (RunInternals), when this run is a child. */
parentTaskId?: string;
parentSessionId?: string;
registry: ListAgentsToolRegistry;
roster?: NonNullable<Parameters<typeof createListAgentsTool>[0]["roster"]>;
/** The continuation reach of the SendMessage beside it — what a recorded agent's label may promise (see the tool option). */
continuationTier?: NonNullable<Parameters<typeof createListAgentsTool>[0]["continuationTier"]>;
specTools: ReadonlyArray<{
name: string;
aliases?: readonly string[];
}>;
toolEffects: Map<string, "read" | "write" | "idempotent">;
/** The read faces' store seats, handed to the tool unchanged (see {@link ListAgentsReadSeats}). */
readSeats?: ListAgentsReadSeats;
}): ReturnType<typeof createListAgentsTool> | undefined;
type ListAgentsToolRegistry = NonNullable<Parameters<typeof createListAgentsTool>[0]["registry"]>;
/** The harness surface the binding needs (structural — the machine itself never sees the harness). */
export interface PeerDrainHarness {
on(type: "turn_boundary" | "before_agent_start", handler: () => Promise<undefined>): unknown;
subscribe(listener: (event: {
type: string;
nextTurnCount?: number;
undrainedSteerCount?: number;
undrainedFollowUpCount?: number;
}) => void | Promise<void>): unknown;
}
/**
* Bind the drain to a leg: the run's own injector at the `next` tier, run open (the loop's first
* steering drain follows the `before_agent_start` hook) + every turn boundary, and the run-end
* ack/release on `agent_end`. Slice 4: the harness's `settled` account (the frame after `agent_end`)
* is the idle judgment's seat — "finished its turn with nothing queued" = no next-turn frames, no
* undrained user inputs, and (through `opts.parked`) no pending ask/review; the held queue's own
* emptiness is the machine's to check.
*/
export declare function bindPeerSessionDrain(harness: PeerDrainHarness, opts: PeerSessionDrainOptions): PeerSessionDrain;
/**
* The leg-side spelling of {@link bindPeerSessionDrain}: the deployment seats the drain reads
* (`mailboxStore` / `peerLane` / `crossSessionInbound` / `crossSessionDialogExpiry` / the two
* sinks) come straight off deps, the recipient's mode class folds off the lane refs — one binding call
* per leg, no per-seat plumbing.
*/
export declare function bindPeerLaneDrain(harness: PeerDrainHarness, args: {
deps: Pick<RunnerDeps, "mailboxStore" | "peerDirectory" | "peerLane" | "crossSessionInbound" | "crossSessionDialogExpiry" | "onNotice" | "onError">;
sessionId: string;
runId: string;
scope: string;
inject: PeerSessionDrainOptions["inject"];
ownTokens: () => readonly string[];
refs: PeerLaneRefs;
/** #1036 — the leg's settlement seam (receipts + compaction effects), minted by the
* orchestrator and shared with the harness sink and the compaction commit beat. */
settlement?: FrameSettlement;
selfName?: () => string | undefined;
parked?: () => boolean;
lastTurnText?: () => string | undefined;
}): PeerSessionDrain;
export {};