UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

219 lines (218 loc) • 15.9 kB
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;