UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

197 lines (196 loc) • 14.1 kB
import { type EngineNotice } from "../core/types.js"; /** Hop-chain axis tags: durable a* handle / sessionId / taskId. Order IS the key-preference order. */ export type PeerAxisTag = "h" | "s" | "t"; /** The five-code closed refusal set (`details.error` values). The first four are gate verdicts; * `queue_full` is judged per delivery leg against that leg's EXISTING observable bound (mailbox * peekCount / the live pre-attach buffer cap) — the gate has no queue axis of its own. */ export type PeerRefusalCode = "rate_limited" | "duplicate" | "hop_loop" | "hop_runaway" | "queue_full"; /** Gate verdict subset of {@link PeerRefusalCode} (what `admit` itself can refuse with). */ export type PeerAdmissionRefusal = Exclude<PeerRefusalCode, "queue_full">; export interface PeerLaneConfig { /** Per-sender burst budget (bucket size). */ bucketCapacity: number; /** Per-sender steady-state refill rate (tokens per second). */ refillPerSecond: number; /** Exact-body dedup window in ms (0 = dedup off — a legal single-axis loosening). */ dedupWindowMs: number; /** Max occurrences of the RECIPIENT's own tokens in a prospective chain before `hop_loop`. */ maxSelfHops: number; /** Max prospective chain length before `hop_runaway`. */ maxChainLength: number; /** Per-recipient sender-state table capacity (LRU). */ maxTrackedSenders: number; /** L4 mailbox occupancy bound (`peekCount` at entry) — the ONE leg-level queue bound this config * owns. The live legs are governed by their existing caps with their existing semantics. */ maxQueuedPeerMessages: number; /** Process-global recipient-instance registry capacity (LRU). Eviction cost = that recipient's * senders restart with full buckets — no correctness debt (the gate holds no state that must * survive across messages). */ maxTrackedRecipients: number; /** Peer WAKE budget, HOST-enforced: how many times per hour a host may start a turn on a session * whose `crossSessionInbound` resolves to `wake` because its box grew. The engine spells the * number and the contract; the host that starts turns is the executor. Integer. */ maxWakesPerHour: number; /** Peer delivery budget, ENGINE-enforced: how many box RECORDS one drain pass injects (held * releases, notice lines, peer messages and engine frames count alike — one counter over every * record injection); the rest stay leased for the next boundary, and the pass's own `inbox_notice` * line — which is not a record — says how many. Integer. */ maxMessagesPerRound: number; /** The `ListAgents` freshness window: a live row with no activity word is listed by default only * while `now − updatedAt` is within it (`includeAll` lists the rest). Milliseconds. */ listingFreshWindowMs: number; } /** Upstream defaults, adopted verbatim (anchored; deviation would need evidence sema does not have). */ export declare const PEER_LANE_DEFAULTS: Readonly<PeerLaneConfig>; /** Test seam (mirrors `__resetMaterializeEnvAnnouncements`); never called by production code. * Re-arms BOTH arms (a WeakMap has no clear — it is re-minted). */ export declare function __resetPeerLaneRangeAnnouncements(): void; /** * Resolve a caller's partial config against the defaults, per-field range-clamped-to-default. * There is no off switch: the guard is always on; the ranges ARE the host's tuning space. * * A field the resolver cannot use falls back to ITS default (never a clamp to the nearest edge, and * never a whole-config reject) — and, since #551, says so: one `config.peer_admission_out_of_range` * notice PER FIELD carrying `{ field, given, default, range, reason }`. Both silent arms are covered: * a value outside the legal window, and a value that is not a finite number at all (a string off an * untyped host's JSON wiring, `NaN`, `Infinity`). Two bad fields in one call are two notices — the * operator has two things to fix and folding them into one line would hide the second. `onNotice` is * the deployment's sink; absent, the announcement still reaches `console.warn` through the shared * delivery throat (an unwired host is not a silent one). */ export declare function resolvePeerLaneConfig(overrides?: Partial<PeerLaneConfig>, onNotice?: (notice: EngineNotice) => void): PeerLaneConfig; /** Hop-chain carrier sliding window: a chain keeps its most recent entries (upstream `Xbr`=32). * The chain grows ONLY at send time (the sender appends its own token); delivery/replay never * appends — a message-driven revival is not a hop. */ export declare const PEER_HOP_CHAIN_WINDOW = 32; /** * One hop token — a stable identity string with an axis label, NOT an HMAC fingerprint (deliberate * simplification of upstream's process-random-key form, with a gain: sema's chain never reaches a * model face and only travels inside engine-minted typed records whose readers already hold the ids, * so there is nothing to hide — while a stable form keeps self-loop detection closed ACROSS process * restarts, which upstream's process-local key cannot do). JSON-tuple form is injective for any * scope/value content (same family as the send-message target-lane key). */ export declare function peerAxisToken(scope: string | undefined, axis: PeerAxisTag, value: string): string; /** Append the sender's own token to an inbound chain, keeping the most recent * {@link PEER_HOP_CHAIN_WINDOW} entries. `undefined` token (an identity-less direct mount that * cannot mint one) forwards the chain unchanged — it cannot honestly claim a hop identity. */ export declare function appendHopToken(chain: readonly string[], token: string | undefined): string[]; /** A run's peer-identity snapshot: the frozen canonical key plus the monotonic union of every axis * token the run has ever worn (old-cycle tokens keep matching after an axis re-derives). */ export interface PeerIdentity { /** The identity's scope axis (folded into every token). Set once at ref creation. */ scope?: string; /** Canonical key — the FIRST recorded axis wins and then freezes (axes are recorded in h→s→t * preference order by the engine's own call sites: a durable child's a* handle lands before its * session/task axes, so a durable agent keys `h` on every cycle). Used as the sender bucket key * AND as the token the sender appends when forwarding. */ key?: string; /** Monotonic union of all recorded axis tokens — the recipient-side self-token set. */ ownTokens: string[]; } /** * design/176 §4.1 — the LATE-BOUND self-identity carrier ({@link import("../core/runner/contracts.js").RunInternals}`.peerSelfRef`). * A ref (same family as `ownOrgAdmissionRef`): revival replays a spread COPY of spawn-time * internals, so a plain field would freeze at its spawn value; and a root run's session axis only * exists once `prepareTask` acquires the session — no single assembly point can synthesize the full * value. The ref is created (possibly empty) where internals are assembled, then axes are recorded * at their birth points through the ONE monotonic operation {@link PeerSelfRef.addAxis}. Readers * (sender key, sender hop token, recipient own-token set) always read `current` at call time. */ export interface PeerSelfRef { readonly current: PeerIdentity; /** Record an axis (monotonic union; first call freezes the canonical key). Idempotent per * (axis,value); empty values are ignored. */ addAxis(axis: PeerAxisTag, value: string): void; } export declare function createPeerSelfRef(scope?: string): PeerSelfRef; /** A shared mutable INBOUND-chain ref (`Zbr` counterpart): overwritten at the harness consumption * boundary with the chain of the peer payload the model just consumed — enqueue-time writes would * let an outbound call inherit the chain of a message the model has not seen yet. New cycles start * from their seed (L3 chain parameter / L4 `peerSeed`); no seed = empty chain. */ export interface PeerInboundChainRef { current: string[]; } export declare function createPeerInboundChainRef(seed?: readonly string[]): PeerInboundChainRef; /** design/385 §4.6 — how many admitted (seq → body) pairs a sender's replay ledger keeps: more than * any one leased batch a drain injects between two acks (a batch is at most the queued-message bound, * 50 by default), so an ack fault re-presents every message of the batch as a re-presentation. */ export declare const REPLAY_LEDGER_MAX = 128; export interface PeerAdmissionRequest { /** The sender's canonical identity key ({@link PeerIdentity.key}), or undefined for an * identity-less direct mount — rate/dedup are then SKIPPED (there is no honest bucket key), * while hop and the leg-level queue bounds still apply (upstream's unidentifiable-sender arm). */ senderKey: string | undefined; /** The exact message body (dedup is exact-match on this). */ body: string; /** The PROSPECTIVE outbound chain — inbound chain + the sender's own token already appended * (equivalent to upstream's append-at-send-then-check-at-receipt order; a self-send counts its * own appended token, no off-by-one). */ prospectiveChain: readonly string[]; /** The RECIPIENT's self-token set, as knowable at this entry point. */ ownTokens: readonly string[]; /** design/385 §4.6 — the mailbox seq of a DRAIN-stage re-check (absent at the send point). Dedup * judges body+seq: same body, same seq ⇒ a re-presentation of an already-admitted message (pass); * same body, other seq inside the window ⇒ duplicate. No shared state across consumers is needed: * a different consumer process holds a fresh gate and admits the redelivery by construction. */ seq?: number; } export type PeerAdmissionVerdict = { ok: true; } | { ok: false; reason: PeerAdmissionRefusal; }; export interface PeerAdmission { /** One admission judgment: hop first, then dedup, then bucket. Success charges the bucket and * records the body IN THIS CALL (no rollback protocol — a downstream delivery failure does not * refund; the bucket refills on its own). Refusal has zero side effects. Config is read * per call (state is long-lived, values are current — upstream form). */ admit(req: PeerAdmissionRequest, config: PeerLaneConfig): PeerAdmissionVerdict; /** Hop-only check (the identity-less-sender arm, and any caller that needs the chain verdict * without touching rate/dedup state). */ checkHopChain(prospectiveChain: readonly string[], ownTokens: readonly string[], config: PeerLaneConfig): PeerAdmissionVerdict; /** Observation face: senders currently tracked by this recipient instance. */ trackedSenderCount(): number; /** Observation face: refusals by reason since this instance was created. */ refusalCounts(): Readonly<Record<PeerAdmissionRefusal, number>>; } export interface PeerAdmissionOptions { /** Injectable clock (tests). */ now?: () => number; } export declare function createPeerAdmission(options?: PeerAdmissionOptions): PeerAdmission; export declare function peerAdmissionFor(scope: string | undefined, recipientKey: string, config: PeerLaneConfig, options?: PeerAdmissionOptions): PeerAdmission; /** * The delivery legs' ONE admission entry: registry seat-commit follows the SAME * refusal-is-side-effect-free rule as the sender table inside `admit` — the instance is looked up * WITHOUT an LRU touch (a detached fresh one serves a first-contact recipient), judged, and only an * ADMITTED message commits the seat (insert + touch + bounded eviction). A refusal to a * never-tracked recipient must not evict an active recipient's rate/dedup state (`peerAdmissionFor` * always commits a seat, which is right for a host asking for the instance itself, and wrong at a * gate that has not admitted anything yet). */ /** design/385 §4.6 — the two-point gate's STAGE discriminator. The cross-session lane judges a message * twice, in two processes or (single-process hosts) in one: at the SEND point before the append, and at * the recipient's DRAIN point before injection. The registry is process-global and keyed by * (scope, recipient), so without a stage in the key the drain re-check of a message this same process * sent would read the send-point body record and refuse its own message as `duplicate`. Two gate * instances, one per stage; the stage-less key is the pre-385 spelling and stays byte-identical. */ export type PeerAdmissionStage = "send" | "drain"; export declare function judgePeerAdmission(scope: string | undefined, recipientKey: string, req: PeerAdmissionRequest, config: PeerLaneConfig, options?: PeerAdmissionOptions, stage?: PeerAdmissionStage): PeerAdmissionVerdict; /** Test seam ONLY (module-level registry hygiene between test files — same posture as the lane map, * which drains itself; this one is long-lived by design so tests reset it explicitly). */ export declare function resetPeerAdmissionRegistryForTests(): void; /** * design/176 §3.4 — the PER-MESSAGE discipline block, minted frame-adjacent at the message ASSEMBLY * points (send-message-tool.ts is the single producer of every peer carrier, so assembly-side * mounting is structurally single-sourced and survives any downstream projection of the payload). * Layered WITH the session-level `SUBAGENT_CONSENT_NOTICE` (prompts/default.ts), not replacing it: * that section governs COMMAND authority at session level; this block narrows AUTHORIZATION per * message. Placement per leg: OUTSIDE the `<teammate-message>` frame (a caller-declared copy inside the * sender-controlled body arrives escaped, so position distinguishes the real block); the L3 resume * leg carries it in the TRUST-FRAME layer of the peer-resume prompt, never inside the data fence * (a load-bearing instruction inside a "this is DATA" fence would demote itself). */ export declare const PEER_MESSAGE_NOTICE: string;