UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

270 lines (269 loc) • 22.6 kB
import { type Model } from "../../internal/llm.js"; import { type CheckpointToken, type ResumeOutcome } from "../checkpoint-store.js"; import { type TaskOutcome } from "../task-outcome.js"; import { type SwappableDeps } from "../swappable-deps.js"; import { type SideQuerySpec, type SideQueryResult } from "../side-query.js"; import type { SessionStore } from "../session.js"; import type { AgentDefinition, ModelRef, ModelRoles, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream } from "../types.js"; import { type ContextSettings, type ContextWindowOverlay } from "../context-settings.js"; import type { ResumeRun, ResumeTaskConfig, RunInternals, RunnerSelfSeat } from "./contracts.js"; export type { ResumeTaskConfig } from "./contracts.js"; /** * A stateless task runner. Holds shared deps (the external brain, model catalog) and an * in-memory session store so that passing a `sessionId` continues a prior conversation. */ export declare class Runner implements RunnerSelfSeat { private deps; readonly sessions: SessionStore; /** Per-sessionId serialization so two tasks never mutate one session concurrently. */ private sessionLocks; /** Per-session read-file-state seats (CC parity: readFileState is session-scoped; * sema's map stays per-task, so the session scope lives here). A bounded CACHE, never an * authority: a durable resume's checkpoint seed wins on its leg, and a miss (cross-process, * evicted) falls back to the transcript-replay rung. See {@link SessionReadFileStates}. */ private readonly sessionReadStates; private readonly sessionModeSeats; /** task notifications born BETWEEN turns (run torn down / harness already idle), parked * per session and drained into the session's next run at its first boundary. Runner-lived (outlives any * single run, like the registry handles that produce into it); bounded + drop-disclosing, see * {@link PendingSessionNotifications}. Not checkpointed — the producing handles are process-local. */ private readonly pendingSessionNotifications; /** Parent effective-policy inheritance (same-process resume convenience): checkpoint token → the OPAQUE * `parentConstraints` the suspended leg ran under. Registered by `runTaskStream`'s result hook whenever a * run spawned WITH `internals.inheritedGate.parentConstraints` ends `suspended` (process-local closures * parked while the park is live — the same lifecycle posture as the subagent retain ledger's * `internalsSnapshot`). `resumeStream` consults it when the caller supplied no constraints, so a * same-process `runner.resume()` on a delegated child's checkpoint auto re-supplies the chain; a * CROSS-process resume (registry miss — a different Runner/process) still requires an explicit * `resumeStream(..., internals)` and fail-louds otherwise (`resume.parent_constraint_missing`). * Retention: entries are kept across an `env_failed` reopen (the same token is retried), evicted on an * observed `already_resolved` and by the FIFO cap — an evicted entry only ever degrades to the explicit * re-supply requirement (fail-loud), never fail-open. */ private readonly parentConstraintRegistry; /** Same-process suspended-env reap handles by token: the fence contract is * "only the expire WINNER destroys the paused env", and the terminal resume-preflight arm * (resumeStream) can now BE that winner — without this registry the losing stream-side * reapSuspended returned early and the paused env this process holds leaked forever. Registered * when a suspend publishes its reap handle; retired when the stream's own reap runs, when a * resume wins the resolve CAS (the restore path owns the env from there — a later reopen keeps * the ORIGINAL stream's closure as the reaping owner, deliberately), and when the terminal arm * wins. Bounded by live suspended streams (the closures hold the same handles). */ private readonly suspendedEnvReaps; /** SAME-RUNNER claim marks for {@link suspendedEnvReaps}: a token appears * here from the moment an in-process actor ATTEMPTS its settling CAS (the terminal preflight's * expire; a resume's resolve) until that actor's duty decision is done. The losing stream-side * reap preserves a record ONLY while a local claim is in flight — a CROSS-Runner settlement * leaves no local claim, so the loser retires the record (drops the strong ref; physical cleanup * belongs to the winning owner, unprovable from here) instead of retaining env handles without * bound in a long-lived Runner. A stale claim's failure direction is retention (the status quo), * never a wrong destroy. */ private readonly locallyClaimedTokens; private static readonly PARENT_CONSTRAINT_REGISTRY_CAP; /** The lanes' LIVE view of `deps` ({@link RunnerDepsSeat}): a getter, never a captured object, because * `swapDeps` replaces the object and a rung reading after its own await must see the replacement. */ private readonly depsSeat; constructor(deps: RunnerDeps); /** Fire `RunnerDeps.onTaskOutcome` through the single swallow-guarded chokepoint * ({@link emitTaskOutcome}: mechanical-tier only, throwing sink never breaks the caller). Public so * thin compositions OVER the Runner (`runGoal`) can emit at their terminal state without reaching * into private deps. */ emitTaskOutcome(outcome: TaskOutcome): void; /** Whether a child this Runner runs can be placed in another tree: {@link RunnerSelfSeat.canRerootChild}. */ canRerootChild(): boolean; /** The deployment agent catalog (RunnerDeps.agents/builtinAgents), exposed READ-ONLY * so the Agent-tool lane (`createSubagentTool`) defaults to the SAME registry the workflow lane * consumes (single source; the types.ts "pass the same array" follow-on made structural). The array * is a fresh copy — a caller mutating it never rewrites this Runner's own deps; the definitions * themselves are the shared objects (frozen by convention, same as gateBaseline's posture). */ get agentCatalog(): { agents?: AgentDefinition[]; builtinAgents?: boolean; models?: Record<string, Model>; roles?: ModelRoles; }; /** The deployment's per-model context-window overlay as declared ({@link RunnerDeps.contextSettings}); a fresh * read every call, the overlay objects frozen at their gate. */ get contextSettings(): ContextSettings | undefined; /** The overlay a leg on `ref` reads RIGHT NOW — the one lookup ({@link contextOverlayFor}, tier aliases * followed) against the seat and catalog in force, so a `swapDeps` reaches legs already running. */ contextOverlayOf(ref: ModelRef | undefined): ContextWindowOverlay | undefined; /** The deployment-level gating baseline, exposed READ-ONLY so thin * assemblers over the Runner (`runSpec`) can COMPOSE with it. The task-level engine semantic for the policy is a * WHOLE-SLOT override (`spec.toolPolicy ?? deps.toolPolicy` — prepare-task), so any assembler that sets * `spec.toolPolicy` without folding this in silently shadows the deployment baseline. (The mods bus needs no * baseline: its two seats run side by side, so an assembler's task registrations never shadow `deps.mods`.) * Getter only — nothing here is writable from outside. */ get gateBaseline(): { toolPolicy?: RunnerDeps["toolPolicy"]; }; /** Acquire the lock for a sessionId; returns a release fn. The one lock every leg holds on its session, from the moment it * knows the id to the leg's end (through `holdSessionLock` in `runTaskStream`, one per leg). */ private acquireSessionLock; /** * Hot-swap the deployment seats a running Runner may change without a restart — the ONE door for every * hot-swappable seat (the closed set {@link SWAPPABLE_DEP_SEATS}, each seat's meaning beside it), and the ONLY sanctioned way to * change any of them after construction. It replaces the former `swapModels` verb (retired, no alias): * one verb per seat restated the same laws with a list of verbs as the only record of what is swappable. * * The laws, stated once (see the vocabulary module for the seat-by-seat meaning): * - **Screened first, written once — atomic.** The candidate is screened whole * ({@link screenSwappableDeps}: the key set is the closed one, at least one seat, `tiers` rides with * `models`, each seat's own value gate — both face seats through the very `assertContainmentFaceValue` every * prepare door applies) and the catalog is tier-expanded on the candidate (an illegal binding throws * exactly like the constructor's boot error) BEFORE the deps object is replaced. On any throw the * current deps stay in force, byte-identical. * - **In-flight legs keep their snapshot.** A running task read `this.deps` at its prepare and holds * that object: its `Model`, its resolved read face, everything. The swap changes what FUTURE prepares * (and auto-sourced agents' string refs — {@link agentCatalog} copies per read) resolve. Same-name * redirects never re-route or re-price a task mid-run; a read-face change never re-fences a mounted * band; the divergence window is exactly the in-flight legs' lifetime, by design. * - **Presence is the instruction.** A key present on the candidate is replaced (an explicit * `undefined` clears an optional seat: `tiers: undefined` ⇒ no bindings, a face seat `undefined` ⇒ the * engine default); a key absent is kept. `tiers` omitted beside `models` ⇒ the current bindings are * re-applied over the new catalog. * - **Announced per seat.** `config.models_swapped` (models/tiers counts — key material only, never the * catalog itself) and * `config.read_face_swapped` / `config.write_face_swapped` (`{ from, to, occurrenceId }`, the seat's * previous and new value, `null` = no declaration) — the table {@link SWAP_SEAT_NOTICE} binds every * seat to its line. */ swapDeps(next: SwappableDeps): void; /** one-shot brain-routed utility query (see {@link runSideQuery} for the full contract): * preserves system/multi-turn messages/tool DEFINITIONS, routes through the deployment's brain with * the same model resolution as tasks, returns real usage/model. No session, no tool execution, no * policy gate, no accounting — caller-trusted. */ sideQuery(spec: SideQuerySpec): Promise<SideQueryResult>; /** Run a task and stream live events; await `.result()` for the final TaskResult. `resume` (internal) * drives a durable resume through the same loop instead of a fresh objective prompt. * `internals` (internal) is a TRUSTED run-scoped channel for live per-task state the * Runner cannot see from `spec` (today: a `runRepairLoop` attempt's live `repairBundle`) — NOT a * `TaskSpec` field; undefined on the public path. * **Eager:** the task starts executing the moment this stream is constructed (fire-and-forget), NOT lazily * on first iteration — you do NOT need to pull events for the run (and its `finish()`/env teardown) to * happen. Iterating just observes events (the buffer is backpressure-free); `.result()` awaits completion. */ runTaskStream(spec: TaskSpec, resume?: ResumeRun, internals?: RunInternals): TaskStream; /** * The run body as ONE driver method: `runLocked`'s signature, the lane order, and the two assemblies * the driver keeps (the harness handlers, the turn boundary). The lanes' products are threaded under the names they hand * back; the two continuations the S6 driver spelled as private methods (`runSeatLanes` / `runAssembliesAndLegs`, joined by * a `RunSeatsHandoff` re-spelling of two lanes' Results) are the `next` closures of the two async lanes they follow — the * notification lane's (R0) and the attachment seats' (R3) — because a closure sees what the lanes before it bound, so * nothing is re-spelled and nothing is re-destructured. Neither this method nor either closure is `async`: each lane's * own promise IS this method's, so a prepare that throws reaches the run IIFE's catch / finally (the failure backstop, * the session lock's release) on the tick it did, and each successor is entered on the tick its lane's last await * resolved on (the S6 TICKS pin holds unchanged: no first host read moved off the tick the one-function body read it on). */ private runLocked; /** Run a task to completion and return a machine-readable result. */ runTask(spec: TaskSpec, internals?: RunInternals): Promise<TaskResult>; /** * Resume a task suspended at a durable approval gate. The token came back as * `TaskResult.checkpointToken` from a `status:"suspended"` run; `outcome` carries the human/external * decision (`allow`/`deny`, with an optional `updatedInput` arg rewrite or `reason`); `taskConfig` * **re-supplies** the same tools / model / policy the task ran with (a token cannot reconstruct tool * implementations or the hand band — the session stores neither). * * Flow (§2.1): `get` the checkpoint → validate the outcome arm matches the gate (council #3) → **atomic * CAS `resolve`**. *Winning the CAS is the sole trigger to execute the pending action* — so a double / * concurrent resume of the same token loses the CAS and is rejected (`checkpoint.already_resolved`), * never re-running the side-effecting tool. The winner rewinds the session to the suspension point, * resolves the gated call (execute on `allow`, inject a denial on `deny`), closes the rest of the * suspended batch as deferred, and re-enters the run loop with a continuation — returning a normal * {@link TaskResult} (which may itself be `suspended` again with a fresh token). * * v1 serves only the `human`/`policy_ask` gate (F4). A `task_done` checkpoint (1C Path A) is not * resumable here — the caller orchestrates that one and reads its handle directly. * * **🔒 Authorization boundary — token-as-auth, scope is store-level * isolation, NOT caller authorization.** `resume` is TOKEN-AS-AUTH: whoever presents a valid, still-`pending` * checkpoint token resolves it. The scope passed to the store CAS is the checkpoint's OWN `cp.scope` (read * off the row), so the store's scope-WHERE is store-level multi-tenant DATA isolation (one tenant's reaper / * mis-scoped resolve can't touch another's row) — it does **not** verify that THIS CALLER is authorized for * that scope. Authorizing the operator's principal against the checkpoint (the "tenant-B holding tenant-A's * token must not resolve it" property) is the **caller's** responsibility: the BFF / supervisor service mints * the principal, scopes the approval inbox to it, and only calls `resume` for a checkpoint that principal * owns. The token's secrecy + single-use HMAC binding (D-G) is what keeps it from reaching the wrong tenant. * A direct (non-BFF) client is BLOCKED until D-G's cryptographic principal binding — never trust a * client-supplied principal/scope here. * * @throws {@link CheckpointError} `not_found` (no store / unknown token), `gate_mismatch` (outcome arm * ≠ gate, or an unsupported gate), `invalid_outcome` (decision-action binding failed — boundCallId/ * boundInputHash mismatch, or a deny reason with a `</system-reminder>` tag), `reopen_revote` (an * env_failed re-resume supplied a decision ≠ the persisted winner), `reopened_concurrently` (the * optimistic-concurrency `rev` changed under a concurrent resolve/reopen — re-resume against current * state), `unsupported_version` (checkpoint newer than this worker / remote handle with no factory), * `already_resolved` (lost the CAS — idempotent no-op), `walltime_axis_retired` (pre-CAS: the * persisted ledger carries the retired wall-clock budget axis — a worker of the previous release can * still finish it), `resume_aborted` (the caller's signal was already aborted at entry, or aborted * during the resume — pre-CAS/edit-re-adjudication legs leave the checkpoint pending and resumable, * the post-claim leg leaves it consumed; the message says which), `reopen_failed` (an aborted resume * needed to reopen the checkpoint and the store refused, or the reopen failed in flight — the message * distinguishes terminally-consumed from state-unprovable), `config_refused` (pre-CAS: the re-supplied * config or a mode the row recorded is one prepare would refuse — `detail.code` is prepare's own code, * `detail.field` says whether the config or the row carries it; the checkpoint stays pending and * unchanged). Each code's full contract is on {@link CheckpointError.code}. */ resume(token: CheckpointToken, outcome: ResumeOutcome, taskConfig: ResumeTaskConfig, /** Same TRUSTED run-internals seam as {@link resumeStream} — the convenience wrapper * FORWARDS it (it used to silently drop a 4th arg, so a cross-process re-supply through resume() * looked wired but always failed `resume.parent_constraint_missing`). */ internals?: RunInternals): Promise<TaskResult>; /** * Streaming form of {@link resume}: runs the identical pre-CAS guards + atomic CAS, then returns the live * {@link TaskStream} of the resumed run (its events + `result()`) instead of draining it to a final * {@link TaskResult}. Use this when the resumed segment's tool/turn events must reach an event sink for * observability parity with the original `runInBackground` stream (service [15]); `resume()` is the * convenience wrapper that drains it. The guards + CAS run FIRST, so this rejects with the same * {@link CheckpointError}s as `resume` *before* any stream is returned (winning the CAS is still the sole * trigger to execute the pending action — a lost CAS rejects, never returns a re-running stream). * * **Eager (like {@link runTaskStream}):** once this resolves, the resumed run is already executing — the * pending action runs exactly once and the run-loop tail's `teardownOwnedEnv` tears down the * rebuilt remote env even if you never iterate the stream. So abandoning the returned stream is safe (no leak, * no hang); it only means you * don't observe the resumed segment's events. */ resumeStream(token: CheckpointToken, outcome: ResumeOutcome, taskConfig: ResumeTaskConfig, /** the TRUSTED run-internals seam, symmetric with {@link runTaskStream} — a resume leg * otherwise has no `onForwardEvent`席位, so a subagent's task_progress ticks (child-isolated stream, * only exit = the forward sink) were unreachable on resume. Deployment-owned, never a TaskSpec field. * Parent effective-policy inheritance CONTRACT: a checkpoint whose `state.inheritedGate. * requiresParentConstraint` is true was suspended under inherited OPAQUE parent-policy constraints * (live ToolPolicy/onAsk closures — non-serializable); this is the ONLY seam that re-supplies them * (`internals.inheritedGate.parentConstraints`). Omitting them for such a checkpoint rejects pre-CAS * with `resume.parent_constraint_missing` (fail-loud; the checkpoint stays pending) — never a silent * resume with the ancestors' gate dropped — unless this SAME Runner spawned the leg (the spawn-time * registry auto re-supplies them; see `parentConstraintRegistry`). The re-supplied chain's LENGTH must * match the checkpoint's recorded `parentConstraintCount` (`resume.parent_constraint_mismatch` * otherwise). Most entries are policies the deployment authored and can hand straight back; a * hook-wired ancestor also contributes a PreToolUse SCREENING entry (issue #33) whose policy the * engine mints — rebuild that one with the exported `createPreToolUseConstraintPolicy(hook, env)`. The DATA half (ancestorRules/shellGate) rides the checkpoint itself and needs no * re-supply; on the resumed leg the CHECKPOINT snapshot is the trust floor — live internals may only * TIGHTEN it (per-entry rev-monotonic ancestorRules replacement, max-rank shellGate fold), never * displace it wholesale. TRUST BOUNDARY: `internals` is the DEPLOYMENT trust channel (the same tier as * `inheritedSkillGrants` and every other RunInternals field) — the library caller is trusted to * re-supply the genuine chain; a caller able to invoke resumeStream already holds full Runner * authority, so constraint substitution here is outside the enforced threat model (the count check * catches shape MISTAKES, not a untrusted trusted caller). */ internals?: RunInternals): Promise<TaskStream>; /** * The resume decision ladder after admission (E3–E10), driving the eight `resume-*` lanes in * the order the one-function ladder had: the review / park twins, the approval twin and its bindings, the * checkpoint screen, the internals-and-config rungs (whose one window await is taken HERE, where the ladder * took it), then the three async rungs after it — apply, preflight, claim — each entering the next in its own last * continuation, and the post-claim hand-off to the resumed leg. Every rung throws its typed refusal pre-CAS; * the first `return` is the leg's stream. */ private resumeLadder; /** * The ladder's POST-CLAIM rung (E11), the driver's own: the post-consume hook, the answering * face minted from the decide's answer, the resumed leg's spec (identity continuity, the existing-session * requirement, the stripped pre-aborted preempt signal), the reopen compensation and the execution-record * verb bound to the row, the two post-CAS abort re-checks, and the hand-off to `runTaskStream` with the plain * twin. Entered in the claim rung's last continuation, so its first statement runs on the tick the win was read. */ private resumeHandoff; } /** Convenience: one-shot run with explicit deps (creates a throwaway Runner). */ export declare function runTask(spec: TaskSpec, deps: RunnerDeps): Promise<TaskResult>; export type { TaskEvent, TaskStream };