@sema-agent/core
Version:
Stateless, task-oriented AI agent core
197 lines (196 loc) • 14.1 kB
TypeScript
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;