@sema-agent/core
Version:
Stateless, task-oriented AI agent core
282 lines (281 loc) • 21 kB
TypeScript
import type { RunnerSelfSeat } from "../core/runner/contracts.js";
import type { TaskSpec } from "../core/types.js";
import type { ExecutionEnv } from "../internal/harness-types.js";
import type { VerificationResult } from "./verify.js";
import type { Checkpoint } from "../core/checkpoint-store.js";
import { type OracleIsolationVerdict } from "../core/oracle-isolation.js";
/**
* SAFE-tier oracle self-repair loop (design/76 D1 命门, design/78 Slice-1). Given an implementation spec
* and an **injected, trusted oracle**, this runs the worker, grades it with the oracle, and — on failure —
* loops a fix turn (and a bounded clean-restart) until the oracle passes or the loop gives up. The terminal
* is then projected through the {@link terminalForTier} table, which **caps every PASS at `candidate_only`**:
* this layer NEVER auto-accepts. Auto-accept (`fixed`) requires the OFF Gate-1 out-of-process
* `oracleIsolation` boundary that this slice deliberately does not build — SAFE-tier escalates to a human
* (`needs_human_oracle`) or surfaces a candidate, but never clears its own work.
*
* Like {@link verifyCompleted}, this is a **thin composition** over `runner.runTask` (verify.ts's DEPLOYMENT POSTURE section)
* — it adds no Runner-core surface, touches no vendored code, and is called by a leader/profile (the sibling
* of {@link runWithVerification}). The oracle internals (which gate/judge/property-harness to compose) live
* in the PROFILE-injected {@link RepairOracle} closure; core only fixes the {@link OracleResult} shape and the
* read-only / identity contract (§1 裁决①). N-parallel candidate fan-out + a robustness-first selection ranker
* are PROFILE concerns (§3 裁决②) — core stays single-candidate.
*/
/**
* The provenance/strength tier of the oracle that produced a verdict. The control plane assigns this — it is
* NEVER derived from agent-visible state (not a {@link TaskSpec} field), mirroring design/44 §7 Q4. Drives the
* {@link terminalForTier} projection.
* - `trusted_hidden` — a hidden, spec-derived held-out oracle (strongest; still candidate_only here).
* - `trusted_visible` — a visible trusted oracle (must be paired with an anchor + no property-harness regression + L3).
* - `property_harness_weak` — derived property invariants only (a weak signal).
* - `l3_judge_advisory` — an L3 read-only judge that advises but does not clear.
* - `none` — no oracle available → only a human can adjudicate.
*/
export type OracleTier = "trusted_hidden" | "trusted_visible" | "property_harness_weak" | "l3_judge_advisory" | "none";
/**
* The terminal state the repair loop resolves to. SAFE-tier (this slice) can reach every value EXCEPT `fixed`:
* `fixed` is reserved for auto-accept, which needs the OFF Gate-1 out-of-process oracleIsolation boundary and
* is therefore unreachable here (terminal-by-tier §2 caps PASS at `candidate_only`).
* - `fixed` — auto-accepted (NOT reachable SAFE-tier; reserved for a future Gate-1 slice).
* - `candidate_only` — the oracle passed; surface the candidate for human acceptance, do not auto-accept.
* - `needs_human_oracle` — no usable oracle (`tier: "none"`) → only a human can adjudicate.
* - `gave_up` — attempts exhausted without a passing oracle (a first-class abstain, never "satisfy the test").
* - `conflict` — the spec and the oracle disagree (escalate; never rewrite the work to satisfy the test).
* Slice-1 core does NOT synthesize this (the test-tampering diff-monitor is §6-deferred to the profile —
* core honestly `gave_up`s rather than under-detect a conflict); it is in the vocabulary for the profile.
* - `oracle.unprotected` — fail-closed: the grader env was not isolated from the worker env (identity check).
*
* `needs_human_oracle` is disjoint from `needs_review` (a future dry-run gate) and `irreversible_ask` (shipped).
*/
export type RepairTerminal = "fixed" | "candidate_only" | "needs_human_oracle" | "gave_up" | "conflict" | "oracle.unprotected";
/**
* One oracle verdict. Produced by the injected {@link RepairOracle} closure. `flaky` records that the oracle
* was non-deterministic across `retries` (a flaky verdict is NEVER projected to `fixed` — §5 / §6 flakyK).
* `trace` is the raw failure output fed back into the next fix turn; it is treated as untrusted worker-adjacent
* data (delimited into context, sanitized into the objective) but its INNER text is NEVER rewritten (§5.5 #8a)
* — rewriting it would destroy the real-trace feedback lever.
*/
export interface OracleResult {
tier: OracleTier;
passed: boolean;
/** Raw failure trace (on a failed verdict). Untrusted; fed back verbatim into the fix turn (never inner-rewritten). */
trace?: string;
/** The oracle's verdict was non-deterministic across `retries` re-isolations. A flaky verdict never → `fixed`. */
flaky: boolean;
/** How many re-isolation retries the oracle ran to settle the verdict. */
retries: number;
/**
* HRD-AGO-6 — what producing this verdict COST (micro-USD, the oracle's own spend: judge/gate model calls,
* re-isolation retries, …). Optional: a mechanical oracle (an exec gate, a property harness) spends nothing
* and omits it. When supplied it counts against {@link RepairLoopConfig.costCeilingMicroUsd} exactly like the
* worker's spend — an LLM-judge oracle re-run per attempt is real money the ceiling used to be blind to —
* and is surfaced cumulatively as {@link RepairResult.oracleCostMicroUsd}. It is NOT folded into
* `TaskResult.stats` (those are the WORKER's, and the oracle runs outside `runner.runTask`).
*/
costMicroUsd?: number;
}
/**
* The PROFILE-injected oracle (§1 裁决①). The profile composes the actual grading inside this closure —
* `runExecGate(graderEnv, steps)` (core/exec-gate.ts, env is the FIRST param = L2 provenance bound to the
* grader) → `verifyCompleted` (agents/verify.ts, the read-only L3 judge) → `checkInvariants`
* (core/property-harness.ts, explore-only) — and maps the composite to an {@link OracleResult}. Core never
* composes these internals (so the oracle type can't grow a long discriminated union); it only fixes the
* `OracleResult` shape and the read-only/identity contract. `graderEnv` is the isolated grader env; `evidence`
* is the diff/results to judge (recompute it from the post-resume working tree on a resume — threat BUG5, the
* same freshness duty verify.ts states on its own `evidence` config field).
*/
export type RepairOracle = (graderEnv: ExecutionEnv, evidence: string | undefined) => Promise<OracleResult>;
/**
* The durable, JSON-safe repair state carried across an orthogonal durable suspend (§4). Serialized ONLY when
* an unrelated resource/HITL suspend interleaves the loop (happy-path is in-memory) and re-seeded on resume so
* `attemptCount` advances MONOTONICALLY (never reset). Contains NO functions/Dates (epoch ms if a timestamp is
* ever needed) so `structuredClone`/`JSON.stringify` round-trip it on the checkpoint. `baselinePassTests` is
* DELIBERATELY ABSENT (§4 MAJOR-C): the anchor is grader-computed out-of-process so a worker can't shrink it.
*/
export interface RepairBundle {
/** The latest failure trace (untrusted; fed back into the fix turn). */
failureTrace: string;
/** Reflexion-style diagnoses accumulated across attempts (≤3, Reflexion cap — §1). */
diagnostics: string[];
/** Hypotheses tried and rejected (so a re-seed doesn't re-explore them). */
rejectedHypotheses: string[];
/** The in-loop attempt counter — re-seeded MONOTONICALLY on resume, never reset/max/downscaled (§4 / §5). */
attemptCount: number;
/** The tier of the last oracle verdict (for the resumed loop's projection). */
oracleTier: OracleTier;
/** F1 (ruling 2026-08-02) — cumulative loop spend (micro-USD: worker + nested + oracle) at the last
* attempt boundary. Re-seeded on resume so a resumed loop CONTINUES the money account instead of
* restarting it at 0 (a resume must not wash `costCeilingMicroUsd`). Optional: bundles serialized
* before this field existed read as 0 — the pre-F1 behavior, monotonic direction only. Updated at
* attempt boundaries HERE; a mid-attempt park is topped up by the RUNNER (RB-481): the checkpoint mint
* folds the in-flight leg's spend into the serialized copy on a HUMAN-DECISION park (whose resume reports
* its own leg only), and deliberately does not on a resource-slice park (whose resume returns that money
* through its cumulative stats). Either way the parked account is complete, never washed. */
spentMicroUsd?: number;
/** F1 — cumulative ACTIVE loop wall-time (ms) at the last attempt boundary. ACTIVE means time the loop
* was actually running: the suspended interval (a human deciding a HITL gate for days) is excluded by
* construction, so `totalTimeoutMs` bounds loop work, not human latency. Same optionality/monotonic
* contract as {@link RepairBundle.spentMicroUsd}. */
activeElapsedMs?: number;
}
export interface RepairLoopConfig {
/** The PROFILE-injected, trusted oracle (§1 裁决①). Core calls it; it never composes the oracle internals. */
oracle: RepairOracle;
/**
* The ISOLATED grader env the oracle grades in. MUST be a distinct object from {@link workerEnv}: if they
* are the SAME reference the loop fails closed to `oracle.unprotected` BEFORE running anything (§5.1 —
* necessary-not-sufficient; the real out-of-process boundary is the OFF Gate-1).
*/
graderEnv: ExecutionEnv;
/**
* The worker's execution env (the env `runner.runTask` runs the impl in), passed by the trusted caller so
* the §5.1 identity check can run: `graderEnv === workerEnv` → fail-closed `oracle.unprotected`. `TaskSpec`
* deliberately has NO `executionEnv` (the worker env is wired on the Runner deps / `executionEnvFactory`, not
* reachable from `implSpec`), so the caller must supply the reference here for the check to be meaningful.
* Omit it only when the worker env genuinely can't collide with the grader (e.g. distinct factories) — the
* check is then skipped (no reference to compare) and isolation is the caller's deployment contract.
*/
workerEnv?: ExecutionEnv;
/**
* design/77 §1 Gate-1 (oracleIsolation): the paths the oracle/spec lives behind that the worker must NOT be
* able to corrupt (e.g. a hidden held-out test dir). When supplied **together with** {@link workerEnv}, the
* loop runs the FULL structural {@link assertOracleIsolation} (identity + structural-class + bash write-probe)
* instead of the bare reference-identity check, and surfaces {@link RepairResult.isolationClass}. Omit it (the
* default) to keep the existing necessary-not-sufficient reference-identity check (the merged Slice-1 posture).
*
* The real isolated grader env is SERVICE-provided (the trusted control plane mints it; the engine has no seat for it);
* `graderEnv` here is its `.env`, and the control-plane `provenance` brand is reattached internally for the
* assertion. The assertion only matters for the (mandate-OFF) auto-accept path: `isolationClass` caps a
* non-`out_of_process` grader at `candidate_only`. SAFE-tier never auto-accepts regardless.
*/
immutableOraclePaths?: string[];
/**
* In-loop attempt ceiling (the loop runs at most this many GENERATE/oracle attempts, PLUS the documented
* one-shot CLEAN_RESTART the state machine grants once at exhaustion). Profile validates this to 2-3; core
* only enforces it as a ceiling, it does not fence the value — except that a NON-FINITE one falls back
* to 1 and is reported on {@link RepairLoopConfig.onWarn} (CLS-A-3/A-7 class: a NaN ceiling makes every
* `>=` gate false, i.e. no ceiling at all). This counter is DISTINCT from
* `suspendCount`(maxSuspends) and `sliceCount`(maxSlices) — it never borrows those budgets (§5 / §7).
*
* HRD-AGO-4: enforced BEFORE each dispatch as well as after, so a loop entered with {@link resumeBundle}
* whose `attemptCount` already reached the ceiling dispatches NOTHING and returns `gave_up`
* (`errorCode: "repair.attempts_exhausted"`) instead of spending one more worker run (plus a restart).
*/
maxAttempts: number;
/**
* On a resume after an orthogonal durable suspend, the restored {@link RepairBundle} (from
* `PrepareResume.seed.repairBundle`). `attemptCount` is re-seeded from it MONOTONICALLY (§4) — never reset.
*/
resumeBundle?: RepairBundle;
/**
* Stop the loop once cumulative cost reaches this (verify.ts's `costCeilingMicroUsd` backstop). HRD-AGO-6 — "cumulative" is
* every leg the loop pays for: each attempt's own + nested `runTask` cost PLUS each verdict's
* {@link OracleResult.costMicroUsd} (an LLM-judge oracle is charged per attempt too).
*/
costCeilingMicroUsd?: number;
/** Overall ACTIVE wall-clock ceiling for the whole loop (verify.ts's `totalTimeoutMs` backstop). F1: both ceilings
* are CROSS-RESUME — the carried {@link RepairBundle.spentMicroUsd}/{@link RepairBundle.activeElapsedMs}
* re-seed the accounts, so a durable resume continues the budget rather than restarting it. */
totalTimeoutMs?: number;
/** Per-attempt callback (observability). */
onAttempt?: (info: {
attempt: number;
repairTerminal?: RepairTerminal;
oracle: OracleResult;
}) => void;
/**
* Operational-warning sink (observability). MINOR-2: the §5.1 isolation identity check is SKIPPED when
* `workerEnv` is undefined (no reference to compare). An ACCIDENTAL omission — `graderEnv` supplied but
* `workerEnv` forgotten — would then disable the check silently. When wired, that case is surfaced here
* once at loop start so the gap is observable; the posture stays necessary-not-sufficient (no hard-fail).
*/
onWarn?: (warning: Error) => void;
}
export interface RepairResult extends VerificationResult {
/** The projected REPAIR terminal (§2) — the loop's own word (fixed / candidate_only / gave_up / …), distinct
* from the inherited run terminal `terminal` (a {@link import("../core/types.js").TerminalCause}, WHY the
* worker's last leg ended). SAFE-tier never returns `fixed`. */
repairTerminal: RepairTerminal;
/** The final repair state (in-memory unless an orthogonal suspend serialized it — §4). */
bundle: RepairBundle;
/**
* design/77 §1 Gate-1: the structural isolation class of the grader env, when the full
* {@link assertOracleIsolation} ran (i.e. {@link RepairLoopConfig.immutableOraclePaths} + `workerEnv` were
* supplied). `"out_of_process"` is the ONLY class the (mandate-OFF) auto-accept path may consider;
* `"in_process_probe_only"` CAPS the run at `candidate_only`. Undefined when only the bare reference-identity
* check ran. SAFE-tier never auto-accepts regardless of this value.
*/
isolationClass?: OracleIsolationVerdict["isolationClass"];
/**
* HRD-AGO-6 — cumulative ORACLE spend (micro-USD) across every verdict this loop took, summed from
* {@link OracleResult.costMicroUsd}. Reported SEPARATELY from `stats` (the worker legs) because the oracle
* runs outside `runner.runTask`: `stats.costMicroUsd + stats.nested.costMicroUsd + oracleCostMicroUsd` is the
* loop's true total. `0` when no verdict reported a cost (a mechanical oracle).
*/
oracleCostMicroUsd: number;
}
/**
* design/77 §1 Gate-1 invariant — auto-accept (`fixed`) is UNREACHABLE here. The terminal-by-tier projection
* (§2) already caps every PASS at `candidate_only`, so `isolationClass` only ever matters for the (OFF)
* auto-accept path. This guard makes the invariant load-bearing rather than caller discipline: a non-isolated
* (`in_process_probe_only`) or unprotected verdict can NEVER carry a `fixed` terminal — at most
* `candidate_only` / `oracle.unprotected`. It is a pure assertion over the projected terminal; it never
* upgrades anything (auto-accept stays OFF — only a future Gate-1 slice running on an `out_of_process` grader
* may even consider it).
*/
export declare function isolationPermitsAutoAccept(verdict: OracleIsolationVerdict): boolean;
/**
* CONSUMER seam (design/78 Slice-1, MAJOR-3 wiring — the re-entry side of the round-trip). Read the durable
* {@link RepairBundle} a resumed checkpoint carries, so a leader/profile resuming a repair-interleaved
* suspend can re-seed a FRESH {@link runRepairLoop} call with it.
*
* ## The contract (core vs profile)
* - **CORE owns the persistence + restore.** When an orthogonal durable suspend (resource/HITL) interleaves a
* `runRepairLoop` attempt, the Runner serializes the live bundle onto the minted checkpoint
* (`prepareTask.serializeCheckpointState` sources `internals.repairBundle`), and on resume re-seeds it back
* into `prepareTask` from `cp.state.repairBundle`. So `cp.state.repairBundle` is round-trip-true after a
* `runner.resume(token, …)`: it survives the suspend with `attemptCount` intact (never reset — §4 / MAJOR-A).
* - **PROFILE owns the re-entry loop.** The leader that resumes the worker (`runner.resume`) is a PROFILE
* concern — core does NOT build the "resume → grade → re-seed `runRepairLoop`" loop. The profile reads the
* restored bundle (via this accessor) off the SAME checkpoint it resumed, and on its NEXT repair attempt
* passes it as {@link RepairLoopConfig.resumeBundle}. `runRepairLoop` then re-seeds `attemptCount`
* MONOTONICALLY from it (`config.resumeBundle` → the loop's `bundle`), so the repair ratchet is preserved
* across the durable suspend rather than starting the attempt budget over.
*
* Returns `undefined` when the checkpoint carries no repair state (a non-repair task, or a repair attempt that
* suspended on its very first GENERATE before any oracle verdict — the re-seed treats `undefined` as a clean
* start). The returned object is the checkpoint's own (already an independent `structuredClone` from the
* store); pass it straight into `resumeBundle` — `runRepairLoop` copies its arrays defensively on re-seed.
*/
export declare function repairBundleFromCheckpoint(cp: Checkpoint): RepairBundle | undefined;
/**
* The terminal-by-tier projection (§2) — a PURE function, the core invariant. **SAFE-tier caps every PASS at
* `candidate_only`**; `fixed` (auto-accept) is unreachable here because it needs the OFF Gate-1 out-of-process
* boundary. Enforced HERE (in the projection table), not as caller discipline, so no consumer can promote a
* PASS to `fixed`.
*
* - any tier, `passed: true` → `candidate_only` (trusted_hidden/trusted_visible/property_harness_weak alike).
* - `l3_judge_advisory` → advisory only; even a "pass" can't clear → `candidate_only` (it never self-clears).
* - `none` → `needs_human_oracle` (no usable oracle → only a human adjudicates).
* - not-passed with a usable tier → not a terminal yet (the loop continues / gives up); represented as
* `undefined` so the caller's state machine drives the `gave_up`/`conflict` path explicitly.
*/
export declare function terminalForTier(oracle: OracleResult): RepairTerminal | undefined;
/**
* Run the SAFE-tier oracle self-repair loop over `implSpec` (design/78 §1 state machine — a DETERMINISTIC
* switch, no LLM in the control flow):
*
* `ASSERT_ISOLATION` (identity check → fail-closed `oracle.unprotected`) → `GENERATE` (`runner.runTask`) →
* `ORACLE` (the injected closure) → { passed → terminal-by-tier projection } | { failed & attempt<max → REPAIR
* (a fix turn) → loop } | { failed & attempt==max → CLEAN_RESTART once, else `gave_up` } | { conflict → terminal }.
*
* `ASSERT_ISOLATION` is the design/77 §1 Gate-1 seam: when {@link RepairLoopConfig.immutableOraclePaths} +
* `workerEnv` are supplied it runs the FULL {@link assertOracleIsolation} (identity + structural-class + bash
* write-probe) and surfaces {@link RepairResult.isolationClass}; otherwise it runs the bare reference-identity
* check (the merged Slice-1 posture). Either way a failed assertion fails closed to `oracle.unprotected` before
* any work runs, and the result NEVER reaches `fixed` — `isolationClass` only gates the (mandate-OFF) auto-accept
* path that this SAFE-tier loop does not take.
*
* SAFE-tier: the projection caps every PASS at `candidate_only` — this NEVER auto-accepts. `runRepairLoop` is a
* SIBLING of {@link verifyCompleted}: it composes over `runner.runTask` and never touches the Runner core.
*/
export declare function runRepairLoop(runner: RunnerSelfSeat, implSpec: TaskSpec, config: RepairLoopConfig): Promise<RepairResult>;