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