UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

282 lines (281 loc) • 21 kB
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>;