UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

130 lines (129 loc) • 9.2 kB
/** * design/96 §C (S2) — **Goal 模式**:LLM 自报完成 + core 校验续跑(CC goal 对标:目标 + 完成判定 + 续跑)。 * `runGoal` 是 `runRepairLoop`/`verifyCompleted` 的 SIBLING —— thin composition over `runner.runTask`,零 * Runner core 改动。详 `design/96-references/GOAL-IMPLEMENTER-SPEC.md`(v3+r2 异源证伪式对照收敛)。 * * 🔴 **完成判定双闸(reward-hack 红线)**:① LLM 自报完成 = 调内置 `declare_done` 工具(或 `submit_output`,有 * outputSchema 时)= **机器信号**,非泛化 `completed`;② core `doneCheck`(机械/oracle 校验)。**AND**:仅二者 * 都满足才停 —— LLM 自报但 doneCheck 否决 → 续跑(把 reason 围栏喂回),这是防"模型说我完成了就算数"的核心。 * * 🔴 **G1 脱钩(不可绕)**:`status:"achieved"` = 双闸过 → **停迭代 + SURFACE**,`accepted` 恒 false。runGoal * **无任何 commit/accept 副作用**(纯 loop,同 runRepairLoop SAFE-tier 永不 `fixed`)。要 accept,caller 自走 * design/77 Gate-1 out-of-process oracle 隔离边界,不在 goal helper 开后门。 */ import type { RunnerSelfSeat } from "../core/runner/contracts.js"; import type { CheckpointToken } from "../core/checkpoint-store.js"; import type { GateKind, PAUSE_REGISTRY } from "../core/pause-registry.js"; import type { PausedCause, TaskResult, TaskSpec } from "../core/types.js"; /** Read-only snapshot a `doneCheck` sees after one iteration. */ export interface GoalTurnState { /** This iteration's full {@link TaskResult}. */ readonly result: TaskResult; /** 1-based iteration index. */ readonly iteration: number; /** The session threaded through the whole goal (so a doneCheck can read the session/working tree). */ readonly sessionId: string; /** Cumulative tokens (own + nested) across iterations — observe-only (the budget gate is in the engine). */ readonly cumulativeTokens: number; } export interface GoalVerdict { /** core verification passed? (AND-ed with the LLM self-report — the double gate). */ readonly done: boolean; /** When not done: the specific feedback fed back to the LLM (fenced as untrusted data). */ readonly reason: string; } /** The caller's HONEST declaration of how `doneCheck` verifies — surfaced on the result so a * `self_report_only` "achieved" is never mistaken for a mechanically-verified one (G1, reward-hack honesty). */ export type GoalVerificationKind = "mechanical" | "self_report_only"; export interface GoalSpec { /** The goal (injected as iteration 1's objective + GOAL_COMPLETION_GUIDANCE via the trusted goalMode flag). */ objective: string; /** * 🔴 The CORE completion gate (REQUIRED). The LLM self-report (gate 1) is necessary but NOT sufficient: the * goal stops as `achieved` only when gate 1 AND `doneCheck.done`. Provide a PURE read-check (it may read the * session/working tree but must have no side effects — it can be cancelled/timed out). A throw/reject * fail-closes to `failed`/`goal.donecheck_error` (never treated as achieved). Core ships no default — * supplying it forces the caller to confront the reward-hack red line. */ doneCheck: (state: GoalTurnState, signal: AbortSignal) => Promise<GoalVerdict>; /** Honest declaration of `doneCheck`'s substance (§3.2). `self_report_only` ⇒ surfaced on the result. */ verificationKind: GoalVerificationKind; /** Hard iteration cap (≥1) — exceeding without the double gate → `max_iterations` (NOT achieved). */ maxIterations: number; /** Per-iteration base spec (model/tools/toolPolicy/principal/env/outputSchema/…). `objective` is overridden * per iteration; `sessionId` is minted to thread the whole goal if absent; `signal` is set by the engine. */ taskSpec: Omit<TaskSpec, "objective">; /** Cumulative token ceiling (own + nested); reaching it stops with `budget`. */ budgetTokens?: number; /** Whole-goal wall-clock ceiling (ms); reaching it stops with `budget`. */ totalTimeoutMs?: number; /** Cancels the whole goal (folded into each iteration's signal + the doneCheck). */ signal?: AbortSignal; /** * design/73 §1 — aggregation key of the `TaskOutcome` emitted at the goal's terminal state (only when * `verificationKind:"mechanical"` — the red line). Default: `goal:<sha256(objective) first 16 hex>`, * deterministic per objective. Set it when the "same task" spans differently-worded objectives. */ taskSignature?: string; } export type GoalStatus = "achieved" | "max_iterations" | "budget" | "blocked" | "suspended" | "needs_review" | "failed" | "aborted"; /** * #111 — which ceiling ended a `status:"budget"` run. The status set stays CLOSED (a consumer * switching on it keeps compiling); this is the terminating FACTOR beside it, because the two * ceilings ask the caller for opposite corrections: `"tokens"` = the allowance is spent (narrow the * objective / raise `budgetTokens`), `"walltime"` = `totalTimeoutMs` elapsed (give it more time). */ export type GoalBudgetCause = "tokens" | "walltime"; /** * The gates a pause can carry when the status word reads `suspended`: the pause registry's rows whose `taskStatus` column is `suspended` — * the column {@link pauseFamily} projects the status word from. The type reads the same table the status word does, so a row added to the * table moves both, and a gate that reaches the mint below with that word is always one of these. Of the four kinds, a run's own pause is * `human`, `irreversible_ask` or `resource_limit`; `task_done` is the handle a deployment parks, which the engine mints in no run but which * a custom runner seat may return as a paused terminal, and which the table reads `suspended` too. The review kinds are `needs_review`. */ type SuspendedGate = Extract<PausedCause["gate"], { kind: { [K in GateKind]: (typeof PAUSE_REGISTRY)[K]["taskStatus"] extends "suspended" ? K : never; }[GateKind]; }>; export interface GoalResult { readonly status: GoalStatus; /** Last iteration's result; absent on a preflight-terminate (a stop BEFORE iteration 1 ran). */ readonly result?: TaskResult; readonly iterations: number; /** The last `doneCheck` verdict (present once a doneCheck ran). */ readonly lastVerdict?: GoalVerdict; /** The caller's declared verification kind (always present — replaces the ambiguous "verifiedBy"). */ readonly verificationKind: GoalVerificationKind; /** 🔴 G1 explicit: runGoal NEVER accepts the artifact. Always false. `achieved` = "stopped + awaiting * review"; the caller decides acceptance (via a Gate-1 boundary), never runGoal. */ readonly accepted: false; readonly cumulativeTokens: number; /** Carried up on suspended/needs_review so the caller can resume the underlying task. */ readonly checkpointToken?: CheckpointToken; /** Set on `failed` (incl. `goal.donecheck_error`). */ readonly errorCode?: string; /** #111 — present IFF `status === "budget"` (the KEY is absent otherwise, so presence alone reads * as "stopped on a ceiling"). See {@link GoalBudgetCause}. */ readonly budgetCause?: GoalBudgetCause; /** Present IFF `status === "suspended"` — every suspended result carries it, no other carries the KEY: WHY the run is parked, as the * gate it parked at (the checkpoint's own gate, the same object as `result.terminal.gate`; its words are the closed sets of * {@link import("../core/checkpoint-store.js").CheckpointGate}, narrowed to the kinds whose status word is `suspended`: `human`, * `irreversible_ask`, `resource_limit` — and `task_done`, the handle a deployment parks, which a run of this engine never mints but a custom * runner seat may return). The cause is read from the gate alone, never from the caller's signal: * `{ kind: "resource_limit", reason: "usage_window" }` waits for quota (`resumeAfterMs`); `reason: "preempt"` is the host's own resumable * stop (`TaskSpec.preemptSignal`); `human` / `irreversible_ask` wait on an approval; another `resource_limit` reason waits on an * allowance. A caller's abort that ended the run is `status: "aborted"` (no checkpoint); a park that won the race with it is that park. */ readonly suspendGate?: SuspendedGate; } /** Reserved name of the goal-completion signal tool (injected by `runGoal` when there is no outputSchema). */ export declare const DECLARE_DONE_TOOL_NAME = "DeclareDone"; /** * Run a goal to a double-gated completion (or a bound). Iterates `runner.runTask` on ONE session: each * iteration the LLM works and signals completion (declare_done / submit_output); `doneCheck` then verifies. * Only `declare_done` AND `doneCheck.done` → `achieved` (surfaced, NEVER auto-accepted, G1). A rejected * doneCheck feeds its reason back (fenced) and continues. Bounded by maxIterations / budget / wall-clock / * cancel; a durable pause (suspended/needs_review) is a hard boundary that surfaces the checkpointToken. */ export declare function runGoal(runner: RunnerSelfSeat, spec: GoalSpec): Promise<GoalResult>; export {};