@sema-agent/core
Version:
Stateless, task-oriented AI agent core
219 lines (218 loc) • 15.9 kB
TypeScript
import type { RunInternals, RunnerSelfSeat } from "../core/runner/contracts.js";
import type { TaskSpec } from "../core/types.js";
/** design/122 D2 — per-child resume cap (default 8): a child can be revived at most this many times. */
export declare const SUBAGENT_RESUME_CAP = 8;
/**
* design/122 D1 — one RETAINED child ledger row. `specSnapshot` is a FROZEN plain value-copy of the spawn
* childSpec (r1-m5: retaining the live `ctx`/spec-builder closures would pin the parent run's whole prepare
* graph — harness ref, tool wrappers — in memory for up to ttl×max); `internalsSnapshot` is the spawn-time
* trusted internals copy (parentToolCallId / onForwardEvent / onSubagentSpawn — the grandchild chain stays
* unbroken on resume, D2). `release` captures the DELEGATION runner (deployment-scoped, allowed) and
* performs the unpin + explicit release that restores throwaway semantics.
*/
export interface SubagentRetainEntry {
childSessionId: string;
/**
* Dual-runner wake fix — the DELEGATION runner that OWNS the child session (its `sessions` store minted
* and holds `childSessionId`, and its run face executes resumes). The Agent tool can be CALLER-mounted
* with its own runner (e.g. a server's throwaway in-memory subRunner) while SendMessage/AgentTranscript
* are core-mounted with the HOST runner — two DIFFERENT session stores. A resume/transcript acquire on
* the host runner's (durable) store then fails `not_found` for a child that only ever lived in the spawn
* runner's store ("resumable:true 停机帧 → resume.session_not_found"). Resume MUST go through this runner
* when present; the mount runner is only the fallback for pre-existing rows. In-process pointer only —
* retain is NOT durable, so cross-process semantics are unchanged (the ledger dies with the process →
* resume.evicted, the 1.283 ruling). Lifetime: the entry's `release` closure already captures the same
* runner, so this adds no new retention class.
*/
runner?: RunnerSelfSeat;
agentName?: string;
/** fix-C5: PER-ENTRY retain TTL override (ms). A SESSION-scoped ledger is shared by two purposes with
* different windows (failure-evidence retain = FAILED_SESSION_RETAIN_TTL_MS vs a session-scoped bg
* child's opt-in retain = the run's configured ttl); the ledger-level `ttlMs` is fixed by whichever
* purpose CREATED the ledger first, which silently truncated (or stretched) the other's window.
* Expiry (lazy sweep + active timer) uses this when present, the ledger default otherwise. */
ttlMs?: number;
specSnapshot: Readonly<TaskSpec>;
internalsSnapshot: RunInternals;
/** True while the initial run OR a resume run is in flight (resume re-entry rejects `steering.still_running`). */
running: boolean;
/** True once the initial run settled at least once. */
settled: boolean;
/** Last settle timestamp (epoch ms) — the retain-TTL base. */
settledAt: number;
resumeCount: number;
/** design/144 §1.1 — the child's STOP-CYCLE number (207-equivalent per-stop notification identity).
* Minted 1 at registration (the spawn run's settle notifies cycle 1); every resume that actually
* LAUNCHES bumps it, so the resumed run's settle notifies 2, 3, … The background_agent notification
* producers ride it as `TaskNotificationPayload.seq`, which uniquifies the `task_id:status[:seq]`
* dedup key per stop cycle — without it a stop→wake→stop chain's second `completed` frame collided
* with the first and was silently swallowed by the SystemInjectionQueue/inbox/shell dedup layers. */
cycleSeq: number;
/** design/144 §1.2 (child gate) — the PARKED completion notification of this child's spawn settle:
* present while a `completed` frame sits deferred behind the child's still-running background tasks
* (owner key = childSessionId). `seq` is the parked frame's stop-cycle number (park does not change
* cycle semantics); `cancel` unsubscribes the registry quiescence watcher and voids the frame.
* RULING (recorded in design/144 §1.2): a resume/wake VOIDS the parked frame — the wake opens the
* next stop cycle, whose own settle produces this child's next frame; delivering the stale parked
* "completed (idle)" after the child is running again would break "one cycle, exactly one frame".
* 1.284 C2 RULING (宁发勿丢): entry EVICTION (TTL / LRU / abandon / disposeAll / session reap) voids
* the park PROMISE, not the frame — `flush` delivers the park-time snapshot IMMEDIATELY (summary
* undecorated, honest ungated degradation) and unsubscribes the quiescence watcher; the pre-fix
* leak left the watcher alive (a late quiescence poke delivered a ZOMBIE notification for an entry
* long gone, and a never-settling grandchild pinned watcher + captured frame forever). */
deferredNotify?: {
seq?: number;
cancel: () => void;
flush: () => void;
};
/** The in-flight resume run's abort controller (parent-terminal cleanup aborts it — r1-M2). */
activeAbort?: AbortController;
/** Unpin + explicitly release the retained session (best-effort; the TTL reaper is the backstop). */
release: () => Promise<void>;
}
/**
* HRD-AGO-5 — which DETACHED (fire-and-forget) cleanup failed. The ledger frees slots synchronously and
* releases the session in the background; every one of those background calls can reject, because
* {@link SubagentRetainEntry.release} is caller-supplied and reaches a session store / pin.
*/
export type RetainEvictionTrigger = "ttl_sweep" | "ttl_timer" | "lru_overflow" | "session_ledger_lru" | "session_reap";
/** HRD-AGO-5 — deployment-side sinks for the retain ledger's background failures. */
export interface RetainLedgerHooks {
/**
* A detached eviction's `release` (unpin + session release) FAILED. The row is already gone from the ledger
* (containment is synchronous), so this is not recoverable state — it is the honest signal that a retained
* session may still be pinned in the session store, which was previously either an `unhandledRejection` on
* the host process or nothing at all. A throwing sink is swallowed (it cannot strand the reaper).
*/
onEvictionError?: (info: {
parentToolCallId?: string;
trigger: RetainEvictionTrigger;
error: Error;
}) => void;
}
/**
* design/122 D1 — the parent-run-scoped RETAIN ledger (`TaskSpec.retainSubagentSessions`). Created by
* `prepareTask` when the spec opts in, threaded to delegation tools as the TRUSTED `ctx.subagentRetain`,
* and disposed by the Runner in the task's terminal `finally` (D4: abort in-flight resumes + unpin +
* release every retained session — retain is NOT durable; resume is reachable only while the parent run
* lives). Leaks are double-bounded by `max` + `ttlMs`.
*/
export declare class SubagentRetainLedger {
readonly ttlMs: number;
readonly max: number;
private entries;
/** Tombstones: ids that WERE (or would have been) retained but got evicted — their resume rejects `resume.evicted`.
* fix-C2: capped FIFO (see `tombstone`) — a long-lived SESSION ledger kept producing unique failed
* toolCallIds, so an uncapped set grew without bound for the session's whole life. */
private evictedIds;
/** fidelity R4-1: per-entry unref'd TTL timers, armed at `markSettled` — ACTIVE expiry enforcement.
* The lazy `sweepExpired` used to be the only reaper, so a ledger nobody touched again (no later
* spawn/resume on it) kept its pinned sessions until process end. Cleared on evict/abandon/dispose;
* the lazy sweep stays as the backstop (and the only reaper for absurd over-clamp TTLs). */
private ttlTimers;
private isDisposed;
private readonly hooks;
private failedEvictions;
private lastFailure;
/** RB-463: the shared host-callback isolation scope (this ledger's own `onEvictionError` sink lives on it).
* The EVICTION counters above are a different quantity and stay: they count evictions that failed, not
* invocations of the sink that threw. */
private readonly notifier;
constructor(config: true | {
ttlMs?: number;
max?: number;
}, hooks?: RetainLedgerHooks);
/** HRD-AGO-5 — how many DETACHED evictions failed to release their session on this ledger. Non-zero means
* some retained session may still be pinned in the session store. Observable even with no sink wired. */
get evictionFailureCount(): number;
/** HRD-AGO-5 — the most recent detached-eviction failure (see {@link evictionFailureCount}). */
get lastEvictionError(): Error | undefined;
/** RB-463 diagnostics — how many times the deployment's `onEvictionError` SINK itself threw (distinct from
* {@link evictionFailureCount}, which counts the evictions being reported). */
get evictionSinkFailureCount(): number;
/**
* HRD-AGO-5 — record a failure from a DETACHED call this ledger made (or that a module-level helper made on
* its behalf, e.g. the session-ledger LRU disposal). Counts it, keeps the last error, and offers it to the
* sink; a throwing sink is swallowed so a broken observer cannot strand the reaper.
*/
noteDetachedFailure(trigger: RetainEvictionTrigger, error: unknown, parentToolCallId?: string): void;
/**
* HRD-AGO-5 — the ONLY way this class issues a background eviction. `evict` removes the row + tombstones it
* synchronously and only the `release` is async, so the caller never waits; but a bare `void evict(...)` made
* a rejecting `release` an `unhandledRejection` on the host process (a library killing its embedder over a
* best-effort cleanup) and left no trace that a session went unreleased. Every detached site routes here.
*/
private evictDetached;
/** HRD-AGO-5 — `disposeAll` for a caller that cannot await it (the module-level session-ledger LRU). Same
* contract as {@link evictDetached}: containment is synchronous, the failure is reported, never thrown. */
disposeAllDetached(trigger: RetainEvictionTrigger): void;
/** fix-C2: hard cap on tombstones. A tombstone is only an anti-resurrection sentinel (an evicted id's
* resume reads the honest `resume.evicted` instead of the generic not-retained text) — dropping the
* OLDEST one merely degrades that id's error text, so FIFO eviction is a safe bound. */
private static readonly TOMBSTONES_MAX;
/** fix-C2: record an eviction sentinel, FIFO-bounded (re-tombstoning an id refreshes its position). */
private tombstone;
/** fix-C5: an entry's own retain window when it carries one, the ledger default otherwise. */
private effectiveTtlMs;
get disposed(): boolean;
get size(): number;
get(parentToolCallId: string): SubagentRetainEntry | undefined;
wasEvicted(parentToolCallId: string): boolean;
/** LAZY TTL sweep: evict every settled idle entry whose retain TTL
* elapsed (unpin + release + tombstone), so an expired session is freed at the NEXT ledger touch
* (every spawn-registration and resume entry call this) rather than only when its own resume is tried.
* fidelity R4-1: no longer the only reaper — `markSettled` also arms a per-entry ACTIVE timer, so
* expiry fires by time even on a ledger nobody touches again. This sweep stays as the backstop.
* The parent-terminal `disposeAll` stays the final backstop. `evict` removes the row + tombstones
* synchronously; the release itself is fire-and-forget best-effort. */
sweepExpired(now?: number): void;
/** Register a child at SPAWN (running until `markSettled`). Over `max`: the oldest settled idle entry is
* evicted (unpin+release); when every slot is still running, the NEW child is NOT retained (tombstoned →
* its resume rejects `resume.evicted`) and `undefined` is returned — the caller falls back to throwaway. */
register(parentToolCallId: string, entry: Omit<SubagentRetainEntry, "running" | "settled" | "settledAt" | "resumeCount" | "cycleSeq" | "activeAbort" | "deferredNotify">): SubagentRetainEntry | undefined;
/** Flip a child to settled (idle, resumable) — called when the initial run's stream drains.
* fidelity R4-1: also (re-)arms the entry's own TTL timer, so the retain window expires by TIME,
* not only at the next ledger touch. */
markSettled(parentToolCallId: string): void;
/** fidelity R4-1 — arm/re-arm the ACTIVE per-entry TTL reaper (unref'd: never keeps the process
* alive). Idempotent with the lazy sweep: the callback re-checks the same settled/idle/elapsed
* predicate, so a resume in flight (or an entry already evicted/re-settled) is left alone. */
private armTtlTimer;
private clearTtlTimer;
/** 1.284 C2 — an entry leaving the ledger takes its park promise with it: deliver the parked frame
* NOW (undecorated; 宁发勿丢 ruling — see the deferredNotify field doc) and unsubscribe the registry
* quiescence watcher. Without this, EVERY release path (TTL timer / lazy sweep / LRU register
* overflow / abandon / disposeAll / session reap) leaked the watcher: a late quiescence poke then
* delivered a zombie notification for a long-gone entry, and a never-settling grandchild pinned the
* watcher + captured frame forever. */
private flushDeferredNotify;
/** Drop a row WITHOUT releasing (durable-pause posture: a committed checkpoint owns the session/pin —
* releasing here would orphan it, mirroring the delegation tool's own no-release guard). */
abandon(parentToolCallId: string): void;
/** Evict one row: flush its parked notification (C2), abort its in-flight resume (if any), unpin +
* release the session, tombstone the id. */
evict(parentToolCallId: string): Promise<void>;
/** design/122 D4 — parent-run terminal cleanup: abort EVERY in-flight resume + unpin + release EVERY
* retained session (throwaway semantics restored; no orphan run burns tokens — same posture as
* `abortBackgroundAgentsForOwner`). Idempotent; further register/resume attempts are refused. */
disposeAll(): Promise<void>;
}
/** Look up (never create) the session-scoped retain ledger for `sessionId` — SendMessage's read side.
* fix-C1: a read hit refreshes `touchedAt` (TRUE LRU) — a session kept alive ONLY by SendMessage resumes
* otherwise looked stale to the cap eviction below and got its ledger disposed (aborting in-flight
* resumes) while 63+ other sessions churned. */
export declare function getSessionRetainLedger(sessionId: string): SubagentRetainLedger | undefined;
export declare function getOrCreateSessionRetainLedger(sessionId: string, config: {
ttlMs: number;
max: number;
}, hooks?: RetainLedgerHooks): SubagentRetainLedger;
/** Anchor ② — dispose the session's retain ledger (abort in-flight resumes, unpin + release every retained
* child session) and drop it. Idempotent; wired to `TaskRegistry.onSessionReap` at retain time. */
export declare function releaseSessionRetainLedger(sessionId: string): Promise<void>;
export declare function ensureSessionReapHook(registry: import("../core/task-registry.js").TaskRegistry): void;
/**
* design/122 D2 — the SINGLE resume-prompt builder: a constant TRUSTED frame core owns (operator authority +
* an unpredictable correlation marker) around the operator content, which stays fenced DATA (same posture as
* steer — content is never authority, and `delimitUntrusted` neutralizes fence/tag break-out sentinels).
*/
export declare function createResumePrompt(marker: string, content: string, origin?: "operator" | "peer"): string;