UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

327 lines (326 loc) • 20.6 kB
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 `admission_refused` cause of a principal-inbox record the delivery gate did not * mint (no `peer_message` kind, or a missing `fromScope` / `gateReceiptId` anchor). */ export declare const PRINCIPAL_INBOX_UNMINTED_CAUSE = "not_minted_by_delivery_gate"; /** Does a terminal leg drain its scope's principal inbox? Exactly when the declared * consumer is its terminals ({@link principalInboxConsumerOf}). The one predicate the drain binding and the * `ReadInbox` origin share, so the face is external exactly where the lane exists. */ export declare function terminalsDrainPrincipalInbox(deps: Pick<RunnerDeps, "principalInboxConsumer">): boolean; /** 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; /** * Also drain this scope's PRINCIPAL INBOX ({@link PRINCIPAL_INBOX_HANDLE}) as a * second lane beside the session box: its own lease, ledger, ack prefix and held queue; the same pass * budget, injector, settlement seam and pull face. Set only when the scope's consumer is its terminals * ({@link principalInboxConsumerOf}); with an assistant configured the lane is never minted. */ principalInbox?: boolean; /** * The leg's external-content accounting for the INJECTION path: called whenever a letter * from another principal (a record carrying the gate's `fromScope` anchor) is injected into the run, so the * session is marked as having taken external content up exactly as a web fetch marks it. The PULL path is * accounted by the `ReadInbox` tool's own declared class (its call is the mark), so a pulled letter is not * marked twice. Absent ⇒ nothing to mark (no memory session on this leg). */ markExternalContent?: (reason: string) => void; /** 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 held queue for the PRINCIPAL INBOX lane (present iff the drain * has that lane): cross-principal letters a setting holds, for the same approval face. Its entries' * `box` is {@link PRINCIPAL_INBOX_HANDLE}; their receipts reach no sender (another principal). */ readonly principalHeld?: 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 * leg's tool valve leaves it (`leaves`: the valve's one reading, `valveLeaves`) 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: { leaves: (tool: { readonly name: string; readonly aliases?: readonly string[]; }) => boolean; 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" | "principalInboxConsumer" | "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; /** The leg's external-content mark for an injected letter from another principal ({@link PeerSessionDrainOptions.markExternalContent}). */ markExternalContent?: (reason: string) => void; }): PeerSessionDrain; export {};