docs(rfc): propose harness-level goal-based loop

Introduce packages/loop as a capability seam covering goal-based (and
naturally proactive) loops around the existing agent-loop, deferring
time-based scheduling to a future dsh-schedule RFC. Four cordis service
seams — loop-as-session, pluggable Evaluator/Budget with EvaluatorSpec
tiers and protectedPaths, RoundHandoff, GoalReflector — plus a driver-
enforced Default-FAIL contract and reuse of packages/fs policy gate for
reward-hacking defense. Bilingual pair.
This commit is contained in:
NI0317
2026-07-16 17:06:16 +08:00
parent 4139e093dd
commit 8a33e7cd00
4 changed files with 649 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [SQLite FTS5 session search](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 |
| [Stream workflow progress through tool calls](proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md) | 2026-07-13 |
| [Developer-owned SDK projects](proposed/feature/2026-07-14-sdk-developer-projects.md) | 2026-07-14 |
| [harness-level goal-based loop](proposed/feature/2026-07-16-harness-level-loop.md) | 2026-07-16 |
### Simplification
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-16-harness-level-loop.md: 8bc4ecf3734a7e9d82d1f844b3d7186fa6ccafed
2026-07-16-harness-level-loop.zh.md: 4e1b677259b310171098cc78dd174b55c8d69970
@@ -0,0 +1,321 @@
# RFC: harness-level goal-based loop
Status: proposed
English | [中文](2026-07-16-harness-level-loop.zh.md)
## Problem
`packages/core/agent-loop` runs only the inner loop: reasoning plus tool calls within one turn, ending when the model returns `end_turn`. Its README explicitly writes "No built-in turn budget"—budget is a gap it acknowledges itself. Cross-round scheduling falls on the harness layer: iterating until tests all pass, revising drafts against a rubric, splitting a PRD into beads and driving them one by one, running unattended for a whole night. None of these tasks has a first-class implementation today.
The existing code offers three "just enough to run" alternatives, none of them adequate:
| Alternative | Problem |
|---|---|
| A `packages/workflow` script expressing `while (!done)` | The README explicitly writes "No token-budget vocabulary" and "No journaling or resume"; the parent turn blocks until the script settles. Fine for orchestration lasting minutes, unusable for tasks lasting hours |
| An external shell `while :; do dsh …; done` | Ralph-style scheduling can be written this way today. It lacks a shared vocabulary for stop condition, budget, and evaluator, so every user reinvents them; the loop itself has no durable object for post-hoc diagnosis or recovery |
| The `sendMessage`/`resume` capabilities on the `packages/subagent` seam | The README explicitly writes "Runtime steering and continuation are seam-only capabilities". There is no model-facing consumer, so the model can only start a fresh subagent |
Three typical use cases. **Automated fix**: a failing test suite in front of you, and you want a process to keep modifying code, running tests, and modifying again against the failure messages, until everything is green or the budget cap is hit. **Rubric-driven iterative revision**: a document, code, or translation must meet a set of scoring criteria; the loop repeatedly adjusts, an independent evaluator scores, and the loop stops when the criteria are met or the round budget is exhausted. **Unattended long runs**: for example, porting a repository from one tech stack to another overnight, kicked off before leaving work and reviewed the next morning, with the budget as the only safety net. Common shape across all three: minutes to hours, evaluator decides success, budget is a hard constraint, and post-run review and recovery are required.
## Proposal
**Loops come in four trigger shapes**, distinguished by who starts a round and when:
| Shape | Who triggers | When | Existing comparable | This RFC |
|---|---|---|---|---|
| **turn-based** | The user sends a message in the session | Every user reply | `packages/core/agent-loop`'s existing reasoning-plus-tools cycle within one turn | Not covered; already implemented |
| **goal-based** | The user or the agent specifies "run until some condition" | One start, evaluator decides when to stop | Claude Code's `/goal`, Codex's `/goal`, the Ralph family | **This RFC covers it** |
| **time-based** | A scheduler | On cron or fixed interval | Claude Code's `/loop` (periodic), `/schedule` | Deferred to a `dsh-schedule` RFC |
| **proactive** | The agent itself | When the agent realizes during reasoning that a loop is needed | The proactive tier in Anthropic ClaudeDevs's four-way taxonomy | **Naturally included** (an agent calling the `loop` tool is already proactive) |
This RFC only **adds a capability seam `packages/loop/`** for the goal-based shape. Proactive reuses the same `loop` tool—an agent invocation is a trigger by itself, with no extra machinery. Time-based needs an independent scheduler package and belongs to a separate RFC; this RFC only reserves a hook on the cordis leaf trigger surface for the future `dsh-schedule` integration.
Three packages:
- `@deepseek-ai/dsh-loop`: types, the `LoopDriver` service, the `StopCondition` discriminated union, four built-in service definitions (`Evaluator` / `BudgetPolicy` / `RoundHandoff` / `GoalReflector`), and the event schema
- `@deepseek-ai/dsh-loop-driver`: the default driver implementation
- `@deepseek-ai/dsh-loop-tool`: the model-facing `loop` tool plus the CLI `dsh loop`
The design is organized around four concrete problems, each addressed by an independent cordis service seam:
1. A long-running loop that goes wrong leaves no systematic diagnosis or recovery. **Loop as an independent session** addresses this.
2. Whether the PASS at loop end is trustworthy determines whether hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face. **Making Evaluator and Budget into service seams** addresses this.
3. Short and long tasks need opposite memory strategies; hardcoding one mode makes the other class of scenario unusable. **Making RoundHandoff into a service seam** addresses this.
4. The user's initial goal is not always correct. An agent stubbornly pursuing a wrong goal exhausts the budget doing wrong work. **Making GoalReflector into a service seam** addresses this.
Beyond the four seams, one principle threads through the whole document: **one loop handles one atomic goal**. Large goals should be split into several small loops chained in sequence, not stuffed into one loop with the evaluator judging multiple things. A rule of thumb for whether granularity is right: if you cannot say what a finished loop actually accomplished, granularity is too large and should be split. Phase 2 adds a `loop_split` model-facing tool so the agent can split an oversized goal itself.
Terminology: **inner loop** refers to the existing per-turn reasoning-and-tools cycle in `packages/core/agent-loop`; **harness loop** refers to the outer scheduler introduced by this RFC, iterating around the inner loop. This RFC does not modify `agent-loop`, matching AGENTS.md's "Plugins, not loop changes".
`StopCondition` is a discriminated union with `assertNever` closing the switch:
```ts
interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] }
type StopCondition =
| { kind: 'goal-met'; evidence: EvaluatorReport }
| { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds' }
| { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' }
| { kind: 'approval-required'; reason: string }
| { kind: 'user-cancel' }
export {}
```
### Loop as an independent session
Once a long-running loop goes wrong, the user has no systematic diagnostic method. A failure hours in leaves only scattered log files to sift through. Discovering that some middle round went off track and wanting to roll back to re-run means starting over from scratch. An agent wanting to consult its own experience from past loops has no API to reach it.
The driver opens an independent loop-session (a new session id) for each loop. Every round's inputs, inner-loop results, evaluator reports, and stop decisions are persisted as session events, reusing the SQLite backend from `packages/session-persistence`. This yields three capabilities.
- **Resume from any round**: discover round 78 went off, restart from round 77 with a different prompt or evaluator, no need to start over
- **Post-hoc diagnosis**: through [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md), query "which round did the evaluator start hanging on the same criterion" to locate the stuck point
- **Meta-loop learning**: before starting a new loop, the agent queries its own experience from past loops of the same kind—"have I fixed a similar bug before? Which round did it fail on?"
Claude Code's and Codex's `/goal` are one-off objects: discarded when the run ends, so the agent starts from zero when facing a similar problem again.
**Storage and dependency**. A few KB of events per round, roughly 100500 KB per 100-round loop; thousands of loops reach GB scale. Mitigated by the `logDetail: 'summary' | 'full'` config, defaulting to `full` with long-run users able to switch to `summary`. Persisting all intermediate state also writes generated keys, passwords, and similar secrets to disk—the same class of risk as an ordinary session but amplified 10100×, and the README calls this out clearly. **The most critical point**: this section's capabilities have a hard dependency on the not-yet-landed [sqlite-session-query-provider RFC](2026-07-10-sqlite-session-query-provider.md). If that RFC does not land, arbitrary-round resume and query capability degrade to "just grep the JSONL files". If Phase 1 ships before that RFC merges, Phase 1 only guarantees correct event shape and defers the query surface to Phase 2.
### Pluggable Evaluator and Budget
A loop's value ultimately depends on whether the final PASS is trustworthy. If the evaluator can be hacked or hallucinates PASS, hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face: the model has the means to talk itself into PASS. Even letting an independent subagent be the evaluator only mitigates the problem; as long as the evaluator is still an LLM, it retains a systematic bias for the same class of content—an independent subagent is a mitigation, not a cure.
Truly trustworthy evaluation must be a fully non-LLM hard check: shell exit code, static analysis, an external service. The LLM physically cannot touch the evaluation process. But only the user knows which hard check to run: `pytest` commands differ by project, companies have private compliance checkers, some teams also run internal lint. No number of built-in evaluators can cover them all. Evaluator therefore must be a seam the user can plug into.
Budget is the same story: product-level spending guardrails are opaque, and cannot be adjusted for team policy (personal card, team splitting, per-PR settlement).
`Evaluator` and `BudgetPolicy` are both exposed as cordis service seams, with users injecting them as plugins. `Goal` must carry an `EvaluatorSpec` at an explicit tier; the driver refuses to start a loop without a paired evaluator—vague goals ("write good code") cannot enter the loop system:
```ts
interface RubricItem { name: string; description: string }
interface EvaluatorContract { readonly name: string }
type EvaluatorSpec = {
tier:
| { kind: 'single-metric'; check: string } // "pytest -q && ruff check"、"exit code == 0"
| { kind: 'rubric'; criteria: RubricItem[] } // 若干独立 criterion,各自 pass/fail + evidence
| { kind: 'contract'; interface: EvaluatorContract } // 结构化合约(如 API sig 校验)
| { kind: 'llm-judge'; rubric: string; model: string } // 兜底档,仅软目标
/**
* 主 agent 不可写的路径(通常是 evaluator 会读的测试文件、评估配置)。
* 违规写会被 packages/fs policy gate 拒绝,记 loop/hack-attempt session event。
* 这是防 reward hacking 的核心机制——把「改测试让 evaluator 通过」这条路封死。
*/
protectedPaths?: readonly string[]
}
export {}
```
**Why tiers instead of letting the user pass any function?** Tiers force the user, at start time, to declare "which strength of judgment I'm using". A free function looks flexible, but in practice it lets evaluator strength quietly regress—the user thinks they're doing strict judgment when they've actually written a fuzzy LLM-judge-style semantic match. In long-run scenarios the cost is hours wasted.
The four tiers descend in trustworthiness from top to bottom. The top three are fully non-LLM hard checks. `llm-judge` is the weakest tier, only for soft goals where there truly is no hard signal (revision, translation quality). The documentation explicitly notes "this tier cannot defend against targeted adversarial input; long unattended runs require at least one hard-check tier alongside".
The driver enforces five hard constraints, not delegated to Evaluator implementations. They address three distinct attack surfaces:
**Preventing "the same agent both generates and self-evaluates"**:
1. **fresh subagent**: the evaluator opens a fresh subagent (via `dsh-subagent`'s `spawn`) each round, not inheriting the main loop's context
**Preventing the main agent from reward-hacking by modifying evaluator inputs**:
2. **`protectedPaths` hard isolation**: paths declared in `EvaluatorSpec` (typically test files and evaluator config) are rejected for main-agent writes by the existing policy gate in `packages/fs`, and blocked writes are recorded as `loop/hack-attempt` session events. This is Phase 1's real defense against reward hacking—it seals off the "modify the tests so the evaluator passes" path directly
**Preventing the evaluator subagent itself from being subverted**:
3. **read-only tool set**: the evaluator's tool set is stripped by the driver to reads only, with write tools disabled (reusing `SubagentProvider`'s `toolFilter`). It defends not against main-agent reward hacking, but against the evaluator being prompted-injected via code embedded in the code being evaluated—so it cannot be tricked into modifying state
**Preventing the evaluator report itself from deceiving the driver**:
4. **PASS can only flip via an evaluator report**: the `goal-met` StopCondition can only come from the evaluator; the driver and the main agent cannot construct it directly
5. **Default-FAIL**: the driver maintains each criterion's pass state at `false` internally; only an evaluator report with non-empty evidence is allowed to flip it to `true`. The evaluator cannot get the driver to accept a `{pass: true}` return with no evidence
Together, the five decide that evaluator conclusions can only be driven by evidence—not by confidence, and not by the main agent quietly modifying tests.
**Phase 1 ships three backends**:
- `loop-evaluator-shell` implements `single-metric`: runs a shell command, `exit 0` = pass
- `loop-evaluator-rubric-judge` implements `llm-judge`: a prewritten rubric plus LLM scoring, soft goals only
- `loop-budget-preflight`: before each round starts, estimate `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`; refuse to start if the estimate exceeds `perRoundUsd`. The estimation model comes from MartinLoop `policy.ts:551-596`
A `PricingProvider` service injects the pricing table; a test seam can override it, and it is not hardcoded into the driver (AGENTS.md "No hardcoded tunables in plugins"). The `rubric` and `contract` tiers get built-in implementations in Phase 2; Phase 1 only exposes the types so third-party plugins can integrate first.
**Limitation**: the "read-only tools" the evaluator subagent receives are still shell and fs reads within the same process, and could theoretically be bypassed by prompt injection. Defending against targeted adversarial input requires the two-container approach (the evaluator's definition files are entirely inaccessible to the main agent, the route Anthropic patch.py takes), which is a Phase 3 item. See Risks.
### Pluggable RoundHandoff
How context passes between rounds is a dilemma. Preserving the full prior conversation (continue) reads more coherently, but the conversation keeps growing and eventually hits the context ceiling, and errors from a prior round contaminate every subsequent round. Starting each round from scratch (fresh) avoids the contamination, but has to re-understand context every time. A 3-round revision loop and an 80-round overnight bug-fix loop need opposite strategies. Claude Code and Codex both hardcode one mode, so users cannot switch by task type.
Made a service seam:
```ts
interface RoundContext { loopId: string; round: number }
interface NextRoundSpec { mode: 'fresh' | 'continue' }
interface RoundHandoff {
buildNextRound(prev: RoundContext): NextRoundSpec
}
export {}
```
Phase 1 ships three backends:
| Backend | Scenario | Mechanism |
|---|---|---|
| `handoff-fresh-with-summary` (default) | Long runs, unattended | Open a fresh subagent each round, injecting only a progress summary as a system prompt append |
| `handoff-continue-with-compaction` (recommended middle) | Medium length, 520 rounds | Retain the full conversation up to a token threshold; over the threshold, reuse [`packages/compact`](../../../../packages/compact/README.md) to compress into a summary, using summary + last K rounds as the starting point |
| `handoff-continue-raw` (advanced) | ≤5 rounds, short tasks, testing | Plain continuation without truncation |
**Why default to fresh?** Every long-run loop that actually succeeded (repomirror, Kimi ralph-loop, autoresearch) uses fresh. Placing important loop state outside the context window under driver management is the correct posture for long runs. `handoff-continue-raw` violates this experience, and the README explicitly notes it is not suitable for long runs.
**Why is only this repo able to build the middle tier?** `handoff-continue-with-compaction` depends on a compaction seam—the competitors don't have one; only this repo's `packages/compact` provides that infrastructure.
**Why a seam rather than a three-choice flag?** Users can write 20-line plugins expressing hybrid strategies like "continue for the first 5 rounds, then fresh", or "auto-compact once when context hits 50%", without waiting for main-library support.
**Limitation**: `continue-with-compaction` depends on the compression quality of `packages/compact`; compression itself may write hallucinated information into the summary and propagate it forward. The README recommends fresh for long runs. The three backends' boundaries may confuse new users about which to pick; the `dsh loop` CLI defaults to fresh, so users don't have to understand the differences before hitting a concrete problem.
### Pluggable GoalReflector
The goal the user gives at loop start is not always accurate. It may be based on a wrong assumption (asking the agent to implement a feature with a since-deprecated API), it may not be clear enough (the agent discovers a clarification is needed only mid-work), or it may be invalidated by later information. Current loop-execution frameworks treat the goal as a contract frozen at start; the agent can only push down the original path, and the result is exhausting the budget on the wrong direction.
Made a service seam, with responsibility separated from `Evaluator`: the evaluator asks "did we reach the goal", the reflector asks "is the goal still the same goal".
```ts
interface RoundContext { loopId: string; round: number }
interface GoalConcern { concern: string; severity: 'low' | 'medium' | 'high' }
interface GoalReflector {
reflect(ctx: RoundContext, concerns: GoalConcern[]): Promise<GoalReflection>
}
type GoalReflection =
| { kind: 'continue' } // goal 仍有效
| { kind: 'revise'; newGoal: string; why: string } // 建议修正 goal
| { kind: 'stop-for-human'; reason: string } // 需要人拍板
export {}
```
**Concerns have three sources**, and Phase 1 ships the first two:
- **Agent-initiated**: via the model-facing tool `loop_flag_concern({ concern, severity })`. An agent that realizes during investigation that "the library the user assumed has been deprecated" can raise directly
- **Driver heuristic**: when budget passes 50% and zero criteria have passed, the driver auto-raises a `no-progress-toward-goal` concern
- **Periodic reflector subagent** (Phase 2): every N rounds, run an independent read-only subagent to re-audit goal validity, following the same isolation approach as the evaluator
**Response strategy is controlled by the `onGoalConcern` config**. The four settings correspond to different philosophies about loop use; users choose by their team's collaboration style, and the driver takes no default stance:
- `'stop'` (Phase 1 default): any concern triggers `StopCondition: approval-required`, and a human decides. A loop should never proceed on its own in the face of uncertainty—suitable for cautious teams and for high-impact loop scenarios
- `'notify-continue'` (Phase 1): record a high-priority `loop/goal-concern` session event plus an explicit ACP notification, then continue; a human reviews at the end. The loop internal is not interrupted—suitable for unattended long runs
- `'reflect'` (Phase 2): call `GoalReflector` to decide continue, revise, or stop. Delegates the initial judgment to an independent agent in place of a human—suitable for teams with moderate autonomy
- Not registering a `GoalReflector` and leaving `onGoalConcern` unset = the most hands-off tier: the loop stops only on traditional stop conditions
**Why default to `stop`?** In unattended scenarios, stopping one extra time is safer than running for hours in the wrong direction. Users who explicitly want unattended can switch to `notify-continue`.
A concern is itself just an ordinary session event, composing naturally with the persistence capability described earlier: on resume, one can pick up from the round where the concern surfaced, swap the goal, and re-run—the work of the previous N rounds is not lost.
**Abuse and loss protection**. An agent could raise a concern every round; the mitigation is the `severity` field plus a minimal rate limit on the driver side (same-concern dedup within 30 seconds). The cost of that abuse is that the agent stalls itself and cannot make progress, so the incentive is weak. Once a goal has been revised, the original goal is lost; each revise persists a `loop/goal-revised` session event with rationale, and resume can select any historical goal version.
### User surface
Four trigger surfaces share one driver:
- **Agent-side tool**: `loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` starts a nested harness loop. Inside a running loop, the internal agent can call `loop_flag_concern({ concern, severity })` to raise a concern proactively. ACP rendering intent is `generic`. An agent-initiated call is proactive triggering with no extra machinery
- **CLI**: `dsh loop <prompt> --stop <shell-cmd> --max-rounds N --max-usd X --handoff fresh` is human-initiated startup, the most typical Ralph-style usage
- **cordis leaf**: declare a resident loop as a leaf in `cordis.yml`, with future `dsh-schedule` RFC integration for periodic triggering
- **ACP slash command**: `/loop <goal>` (and `/loop-flag-concern`) starts directly from within the editor or client's current session. Semantically equivalent to a human typing `dsh loop` in a shell, but happens within the ongoing ACP session context, letting the loop result inject back into the session
The ACP slash command depends on: `packages/ui/acp`'s `available_commands_update` surface is currently unbuilt ([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md)). Once the harness's slash-command infrastructure lands, `/loop` and `/loop-flag-concern` only need to be registered against that infrastructure; the driver and tool interfaces do not change. This RFC reserves the names and specifies the argument shape, but does not commit the infrastructure itself—that belongs to a separate ACP catch-up RFC.
The default system prompt carries two hard constraints, distributed with every built-in `loop` tool:
1. No writing of `TODO`, `FAKE`, or `PLACEHOLDER` placeholders to superficially pass the evaluator
2. No writing of empty `try/except` or `catch(_)` blocks so the evaluator ignores errors
Neither can be stopped at the seam layer; both are prompt-layer conventions. Users may customize the system prompt but the built-in constraints remain.
### Relationship with existing code
Direct reuse without modification:
- `packages/subagent`'s `spawn` provider, `toolFilter`, and `persona`—the loop spawns a subagent per round; the evaluator gets the read-only tool set
- The SQLite backend from `packages/session-persistence`—the loop-session persists
- `packages/compact`—the implementation basis for `handoff-continue-with-compaction`
- `packages/todo`—an optional progress representation in single-session continue mode
- If [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) lands first, the loop tool can use it for per-round UI updates
Not touched: `packages/core/agent-loop` (the inner-loop semantics stay the same); `packages/workflow` (DAG orchestration vs. iterating one goal is an orthogonal relationship; the two READMEs cross-link in their "Related" section to describe the boundary).
Two dependencies not yet landed:
- [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md)—see the limitation paragraph of Loop as an independent session for the mitigation
- The ACP slash-command infrastructure (the `available_commands_update` surface)—see User surface. Before the infrastructure lands, the slash-command trigger is absent while the other three trigger surfaces work as normal
The one modification to existing code can be deferred to Phase 2: adding a "resume an existing subagent" argument surface to `packages/subagent-tool`, used by the `handoff-continue-*` backends. The underlying `SubagentRun.sendMessage` and `resume` already exist as seam capabilities; only the tool-layer argument entrypoint is missing. If Phase 1 ships only `handoff-fresh-with-summary`, subagent-tool need not be touched at all; Phase 2 adds it.
### Phasing
**Phase 1** (the scope this RFC commits): the three-package seam; `StopCondition`; the four-tier `EvaluatorSpec` type plus the `protectedPaths` hard isolation (reusing the `packages/fs` policy gate), with built-in implementations for `single-metric` and `llm-judge` and the `rubric` and `contract` types open for integration; Default-FAIL enforcement; three built-in evaluator/budget/handoff backends; the `loop_flag_concern` tool; the no-progress heuristic; the `onGoalConcern: 'stop' | 'notify-continue'` pair; the CLI; the tool; the default system prompt hard constraints. **Not included**: the session-query surface, the ACP slash-command trigger surface (depends on the `available_commands_update` infrastructure), the subagent-tool resume change, the stuck detector, the Reflector subagent, the `loop_split` tool, and the built-in implementations of the `rubric` and `contract` tiers.
**Phase 2**: the query surface; the stuck detector (reproducing OpenHands's five patterns); the subagent-tool resume change (unlocking the two continue tiers of handoff); the Reflector subagent; the `onGoalConcern: 'reflect'` tier; the `loop_split` model-facing tool; the built-in implementations of the `rubric` and `contract` tiers.
**Phase 3**: agent fleet (N parallel loops for the same goal, best result wins); integration with `dsh-schedule`; two-container evaluator isolation (evaluator definition files entirely inaccessible to the main agent, defending against reward hacking).
## Alternatives considered
**Extend `packages/core/agent-loop`**: add an "iterate on end_turn until goal" switch to the inner loop. Rejected—AGENTS.md says "new behavior goes on documented extension seams; changing agent-loop requires updating docs/architecture.md". The harness loop needs state across sessions and across agents; stuffing it into the inner loop tangles session semantics into two mixed layers.
**Ship a single slash command `/loop` (Claude Code clone)**: minimal implementation. Rejected—the slash-command layer does not resolve the harness/inner boundary; the four design points (queryable session, tiered evaluator, pluggable handoff, pluggable goal reflector) have nowhere to sit at the slash-command layer, and every capability this RFC commits is lost.
**Fully outsource to `packages/workflow`**: express the loop as a workflow node with a back edge. Rejected—workflow lacks first-class semantics for iteration, StopCondition, and Evaluator; forcing it means the evaluator has to masquerade as a phase, violating the architectural-isolation requirement that the evaluator be independent of the producer; the budget guardrail in workflow is phase-level rather than round-level, and the granularities do not match.
**Hardcode a binary choice between A (fresh) and B (continue)**: the Ralph school and the LoopTroop school each have strong scenarios. Rejected—Pluggable RoundHandoff proposes a seam plus three built-in backends that cover both schools and allow hybrids.
**Skip the evaluator seam, ship a few built-ins**: lighter. Rejected—the core value of Pluggable Evaluator and Budget is that team-private evaluators can extend the system. Hardcoding leaves long unattended users no option but to modify the main library.
**Accept a free function that lacks an `EvaluatorSpec` tier**: allow users to pass any `(result) => boolean`. Rejected—the tier system forces users to declare at start time "which strength of judgment I'm using", the key to preventing quiet regression to a weaker tier. A free function looks flexible but lets evaluator strength quietly regress, and the cost is heavy in long-run scenarios.
**Introduce an independent memory engine (Beads / dex-style)**: an established approach to external state. Rejected—`packages/session-persistence` + `sqlite-session-query-provider` already provide equivalent capability; the payoff of a new engine is far smaller than the maintenance cost.
**Fold goal reflection into the Evaluator seam** (have the evaluator return "criteria are impossible"): rejected—it conflates "was the goal achieved" with "is the goal still correct", which are orthogonal concerns. `Evaluator` should stay independent, read-only, and simple.
**Only add an event for goal-concern, no seam**: lighter. Rejected—the response-strategy family (stop / notify / reflect) is well defined and teams will want to plug in their own, so making it a seam pays off more than it costs.
**Ship the full Reflector subagent in Phase 1**: more complete. Rejected—`loop_flag_concern` tool plus no-progress heuristic plus the two-policy `onGoalConcern` already covers 80% of scenarios; running an independent subagent every round is expensive, and introducing it on demand in Phase 2 is more sensible.
**Do not ship `loop_split`; let users split themselves**: Phase 1 already does. Phase 2 adds it because long-run scenarios reveal that agents receiving an oversized goal will run it directly rather than split it, so explicit tool guidance is needed.
## Acceptance criteria
- The three packages `packages/loop/{loop,loop-driver,loop-tool}` are built as a capability seam; `dsh-loop` exports only types and registry
- `StopCondition` discrimination covers all branches (unit); `assertNever` closes the switch at compile time
- The four services `Evaluator`, `BudgetPolicy`, `RoundHandoff`, and `GoalReflector` can each be replaced by an external plugin (fixture: inject a mock implementation, driver calls it correctly)
- `EvaluatorSpec`'s four-tier type converges at compile time; the driver refuses to start a loop without a paired evaluator (fixture: `loop({ goal, evaluator: undefined })` returns a configuration error immediately)
- Default-FAIL fixture: when the evaluator report returns `{criterion, pass: true, evidence: []}`, the driver refuses that criterion flip and records an `evaluator/invalid-report` session event
- Each of the three built-in handoff backends has unit tests plus one e2e: `fresh-with-summary` (runs to pass), `continue-with-compaction` (runs past the token threshold to trigger compaction), `continue-raw` (runs 3 rounds)
- `dsh loop` CLI e2e: given a goal plus a 3-round cap plus one shell evaluator, both the pass and exhaustion paths return a structured stop cause with a semantic exit code
- Evaluator isolation fixture: the main agent has fs.write, the evaluator subagent's tool set does not; attempting to call fs.write is rejected by the registry
- protectedPaths fixture: with `EvaluatorSpec.protectedPaths: ["tests/**"]` declared, a main-agent attempt to write `tests/foo.py` is rejected by the `packages/fs` policy gate and recorded as a `loop/hack-attempt` session event, while the evaluator's read of that path succeeds
- Preflight guardrail fixture: inject a mock pricing table to construct a scenario over `perRoundUsd`; the driver refuses to start that round and emits a `budget-cap` StopCondition
- Goal concern fixture: `loop_flag_concern` is callable from the main agent and yields a `loop/goal-concern` session event; under `onGoalConcern: 'stop'` an `approval-required` StopCondition is emitted; under `'notify-continue'` the loop continues and the event carries an ACP high-priority marker; the no-progress heuristic fires once when budget exceeds 50% with zero passes (with rate-limit dedup)
- The default system prompt hard constraints (no TODO/FAKE/PLACEHOLDER, no empty catch) are distributed with the built-in `loop` tool, and a snapshot covers the prompt content
- Each round's prompt, inner-loop result, evaluator report, and stop decision appear as session events; when Phase 2 adds the query surface, they are indexable by `loopId`
- The "Related" sections in `packages/loop/README.md` and `packages/workflow/README.md` cross-link and describe the "when to use workflow vs. when to use loop" boundary clearly
- Unit 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene all green; the ACP rendering intent (`generic`) of the new tool has a snapshot
## Risks
**Dependency on [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) landing**. The user-visible value of Loop as an independent session (arbitrary-round resume plus meta-loop learning) requires it. The mitigation is in that section's limitation paragraph; Phase 1 does not hard-bind, and the query surface ships in Phase 2.
**The boundary between `packages/workflow` and loop is a recurring FAQ**. "Is multi-round a loop or a workflow?"—both READMEs must state clearly: workflow is "steps known, agent to run undecided, parallel or serial orchestration"; loop is "agent decided, round count undecided, evaluator decides when to stop". Unclear docs cause users to pick the wrong one.
**Evaluator reverse-optimization (reward hacking)**. In a sufficiently long loop, the agent can identify the evaluator's pattern and optimize against it—for example, discovering that "as long as `assert True` appears in a test file, it PASSes" and bypassing real completion that way. **Phase 1 blocks most cases via `protectedPaths`**: evaluator input files (tests, evaluator config) are declared write-forbidden for the main agent via the `packages/fs` policy gate, sealing off the "modify the tests to make the evaluator pass" path directly. It still cannot prevent the agent from learning the evaluator's pattern and evading it in substance (for example, writing code that satisfies the surface pattern but is semantically wrong). Users needing high adversarial strength need Phase 3's two-container approach: the entire evaluator runtime (binary, rubric, dependency libraries) sits in a container that the main agent cannot access, matching what Anthropic patch.py does.
**Placeholder faking and over-defensive code**. Agents sometimes write `# TODO: implement` to sneak through a test, or write large amounts of `try/except: pass` to make the evaluator superficially PASS. These do not belong to the evaluator layer; they are prompt and training issues at the agent-generation stage. Mitigation goes through the two default system-prompt hard constraints in User surface; users who add "static-check-forbid TODO and empty catch" rules to a custom evaluator are safer. This class of problem cannot be cured at the seam layer.
**Budget estimation drift**. The pricing table is a constant; the estimate drifts once the model provider changes prices. A conservative approximation is not a bug in itself, but the README notes "actual billing is per usage events; preflight only defends against a single round exploding".
**Long-run loop log growth**. A 100-round loop reaches MB scale for one session. `logDetail: 'summary'` is a safety net but Phase 1 defaults to `full`; Phase 2 adds summary semantics.
**Pre-release allows direct evolution**. `SESSION_FORMAT_VERSION=0`; the `LoopRoundEvent` schema can change at any time. Backends reject old formats rather than maintain compatibility, matching the pre-release stance at the top of AGENTS.md.
@@ -0,0 +1,321 @@
# RFC: harness 层 goal-based loop
Status: proposed
[English](2026-07-16-harness-level-loop.md) | 中文
## 问题
`packages/core/agent-loop` 只跑 inner loop:一次 turn 内推理加工具循环,模型返回 `end_turn` 就结束。其 README 明确写「No built-in turn budget」——预算是它自己承认的 gap。跨轮次调度落在 harness 层:跑到测试全绿、按 rubric 反复改稿、把 PRD 拆成 bead 逐个推进、无人值守跑一整晚。这几类任务今天都没有一等公民的实现。
现有代码里有三种「能凑合跑」的替代,都不够用:
| 替代 | 问题 |
|---|---|
| `packages/workflow` 脚本表达 `while (!done)` | README 明写「No token-budget vocabulary」和「No journaling or resume」;父 turn 阻塞到脚本 settle。能跑几分钟的编排,跑不了几小时的长期任务 |
| 外部 shell `while :; do dsh …; done` | Ralph 风格的调度今天就能这么写。缺共享的 stop condition、budget、evaluator 词汇,每个使用者各自重发明;循环本身没有持久化对象可供事后诊断或恢复 |
| `packages/subagent` seam 的 `sendMessage`/`resume` | README 明写「Runtime steering and continuation are seam-only capabilities」。没有 model-facing consumer,模型只能起 fresh 子会话 |
典型使用场景有三类。**自动化修复**:面前一个失败的测试套件,希望一个进程持续修改代码、跑测试、根据失败信息再修改,直到全绿或触达预算上限。**按 rubric 迭代改稿**:一份文档、代码或翻译需要满足打分标准,循环反复调整、独立评估者打分、直到达标或耗尽轮数。**无人值守长跑**:例如通宵把一个仓库从一种技术栈移植到另一种,下班前启动第二天回来看结果,全程只有预算兜底。三类共同的形态:几分钟到几小时、evaluator 决定成败、预算是硬约束、跑完还需要能回看和恢复。
## 提案
**Loop 有四种触发形态**,按谁在什么时候启动一轮划分:
| 形态 | 谁触发 | 何时触发 | 现有对标 | 本 RFC |
|---|---|---|---|---|
| **turn-based** | 用户在会话里发一条消息 | 每一轮用户回复 | `packages/core/agent-loop` 现有一次 turn 内的推理与工具循环 | 不覆盖,已有实现 |
| **goal-based** | 用户或 agent 明确指定「跑到某条件为止」 | 一次启动,evaluator 判停 | Claude Code 的 `/goal`、Codex 的 `/goal`、Ralph 家族 | **本 RFC 覆盖** |
| **time-based** | scheduler | 按 cron 或时间间隔 | Claude Code 的 `/loop`(周期性)、`/schedule` | 延后到 `dsh-schedule` RFC |
| **proactive** | agent 自己 | agent 在推理中意识到需要开一个 loop 时 | Anthropic ClaudeDevs 4 类分类里的 proactive 档 | **本 RFC 自然包含**agent 调 `loop` tool 就是 proactive |
本 RFC 只**新增 capability seam `packages/loop/`** 处理 goal-based 一种。proactive 复用同一 `loop` tool,agent 主动调用即触发,无需额外机制。time-based 需要独立的 scheduler package,属于另一份 RFC 的事情;本 RFC 只在 cordis leaf 触发面预留跟未来 `dsh-schedule` 联动的钩子。
三个包:
- `@deepseek-ai/dsh-loop`:类型、`LoopDriver` service、`StopCondition` 判别联合、四个内置 service 定义(`Evaluator` / `BudgetPolicy` / `RoundHandoff` / `GoalReflector`)、事件 schema
- `@deepseek-ai/dsh-loop-driver`:默认 driver 实现
- `@deepseek-ai/dsh-loop-tool`model-facing `loop` tool + CLI `dsh loop`
设计围绕四个具体问题展开,每个问题对应一条独立的 cordis service seam
1. 长跑 loop 出问题后缺诊断和恢复手段。**loop 作为独立 session** 解决。
2. loop 结束时的 PASS 是否可信决定几小时工作是否作废。同一个 LLM 既生成又自评的架构本身就不可信。**Evaluator 与 Budget 做成 service seam** 解决。
3. 短任务和长任务需要的记忆策略相反,硬编一种模式会让另一类场景不可用。**RoundHandoff 做成 service seam** 解决。
4. 用户初始给的 goal 未必始终正确。agent 沿着错的目标蛮干会耗尽预算做错事。**GoalReflector 做成 service seam** 解决。
四条 seam 之外还有一条贯穿全文的原则:**一个 loop 只处理一个原子目标**。大目标拆成若干小 loop 串联,不塞进一个 loop 让 evaluator 判定多件事。判定 granularity 是否合适的经验规则:如果 loop 跑完说不清它到底做完了什么,granularity 就太大,应当拆。Phase 2 补 `loop_split` model-facing tool 让 agent 收到过大 goal 时能自己拆。
术语约定:**inner loop** 指 `packages/core/agent-loop` 一次 turn 的推理与工具循环;**harness loop** 指本 RFC 引入的外层调度器,围绕 inner loop 反复迭代。本 RFC 不改 `agent-loop`,符合 AGENTS.md「Plugins, not loop changes」。
`StopCondition` 是 discriminated union`assertNever` 收口:
```ts
interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] }
type StopCondition =
| { kind: 'goal-met'; evidence: EvaluatorReport }
| { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds' }
| { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' }
| { kind: 'approval-required'; reason: string }
| { kind: 'user-cancel' }
export {}
```
### Loop 作为独立 session
长跑 loop 一旦出错,用户没有系统的诊断手段。跑几小时后失败,只能翻散落的日志文件。发现中间某一轮走偏想倒回去重跑,只能从头开始。agent 想参考自己过去 loop 的经验也没有可用的 API。
Driver 为每个 loop 开一个独立的 loop-session(新的 session id)。每轮的输入、inner-loop 结果、evaluator 报告、stop 决策都作为 session event 落盘,复用 `packages/session-persistence` 的 SQLite backend。得到三种能力。
- **从任意轮恢复**:发现第 78 轮偏航,从第 77 轮拉起,换 prompt 或换 evaluator 重跑,不必从头
- **事后诊断**:通过 [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) 查「哪一轮 evaluator 开始一直挂在同条 criterion 上」定位卡点
- **元循环学习**:agent 开新 loop 前查自己过往同类 loop 的经验——「我以前 fix 过类似的 bug 吗?失败在哪一轮?」
Claude Code、Codex 的 `/goal` 是一次性对象:跑完就丢,agent 下次遇到同类问题从零开始。
**存储与依赖**。每轮几 KB events100 轮 loop 约 100500 KB;跑几千个 loop 会到 GB 级。`logDetail: 'summary' | 'full'` 配置缓解,默认 `full`,长跑用户可切 `summary`。中间态全持久化会把生成过的 key、密码一并落盘,跟普通 session 是同一类风险但量放大 10–100 倍,README 明确提示。**最关键的一条**:本节能力硬依赖尚未落地的 [sqlite-session-query-provider RFC](2026-07-10-sqlite-session-query-provider.md)。若该 RFC 未落地,任意轮 resume 与查询能力会降级为「只能翻 JSONL 文件」。若 Phase 1 交付时该 RFC 还未 merge,本 Phase 只保证 event 结构正确,query 面延后到 Phase 2。
### 可插拔的 Evaluator 与 Budget
loop 的价值最终取决于结束时的 PASS 是否可信。如果 evaluator 会被 hack 或幻觉 PASS,前面几小时的工作全部作废。同一个 LLM 既生成又自评的架构本身就不可信:模型有条件说服自己 PASS。即便让独立 subagent 做 evaluator,只要 evaluator 还是 LLM,就仍然对同类内容有系统性偏好——独立 subagent 只是缓解不是根治。
真正可信的评估必须是完全非 LLM 的硬检查:shell exit code、静态分析、外部服务。LLM 物理上碰不到评估过程。但硬检查只有用户自己知道该跑什么:不同项目 `pytest` 命令不同、公司有私有合规检查器、有些团队还要跑内部 lint。主库无论内置几种都覆盖不全。所以 evaluator 必须做成用户可以自己接入的 seam。
预算方面同理:产品级的花费护栏是黑盒,无法按团队策略调整(个人卡、团队分摊、按 PR 结算)。
`Evaluator``BudgetPolicy` 都作为 cordis service seam 暴露。`Goal` 必须携带一个明确档位的 `EvaluatorSpec`driver 拒绝启动没有 evaluator 配对的 loop——含糊的目标("把代码写好")不能进入 loop 系统:
```ts
interface RubricItem { name: string; description: string }
interface EvaluatorContract { readonly name: string }
type EvaluatorSpec = {
tier:
| { kind: 'single-metric'; check: string } // "pytest -q && ruff check"、"exit code == 0"
| { kind: 'rubric'; criteria: RubricItem[] } // 若干独立 criterion,各自 pass/fail + evidence
| { kind: 'contract'; interface: EvaluatorContract } // 结构化合约(如 API sig 校验)
| { kind: 'llm-judge'; rubric: string; model: string } // 兜底档,仅软目标
/**
* 主 agent 不可写的路径(通常是 evaluator 会读的测试文件、评估配置)。
* 违规写会被 packages/fs policy gate 拒绝,记 loop/hack-attempt session event。
* 这是防 reward hacking 的核心机制——把「改测试让 evaluator 通过」这条路封死。
*/
protectedPaths?: readonly string[]
}
export {}
```
**为什么分档,而不是让用户传自由函数?** 档位强制用户在启动时明确「用哪一档强度判成败」。自由函数看起来灵活,实际让 evaluator 强度隐性下沉——用户以为在做严格判定,实际写的是 LLM-judge 那种模糊的语义匹配。长跑场景下代价是几小时白跑。
四档从上到下可信度依次降低。前三档都是完全非 LLM 的硬检查。`llm-judge` 是最弱一档,仅用于确实无硬信号的软目标(改稿、翻译质量)。文档明确标注「此档不能挡定向对抗,长跑无人值守场景需至少一档硬检查配合」。
Driver 强制五条硬约束,不下放给 Evaluator 实现。它们分别对付三类不同的攻击面:
**防「同一个 agent 既生成又自评」**
1. **fresh subagent**evaluator 每轮开 fresh subagent(用 `dsh-subagent``spawn`),不继承主循环 context
**防主 agent 通过修改 evaluator 输入来 reward hack**
2. **`protectedPaths` 硬隔离**`EvaluatorSpec` 声明的路径(通常是测试文件、评估配置)由 `packages/fs` 已有的 policy gate 拒绝主 agent 的写请求,记 `loop/hack-attempt` session event。这是 Phase 1 真正挡 reward hacking 的一层——直接封死「改测试让 evaluator 通过」这条路
**防 evaluator subagent 自身被 subverted**
3. **只读工具集**evaluator 的 tool set 被 driver 剥离到只保留读类工具,写类工具禁用(复用 `SubagentProvider``toolFilter`)。它防的不是主 agent 的 reward hacking,而是 evaluator 读到被 evaluate 的代码里 embed 的 prompt injection 时不会被诱导去改状态
**防 evaluator 报告本身欺骗 driver**
4. **PASS 只能由 evaluator 报告翻转**`goal-met` StopCondition 只能来自 evaluatordriver 或主 agent 都不能直接构造
5. **Default-FAIL**driver 内部维护每个 criterion 的 pass 状态默认 `false`,只有 evaluator 报告里带非空 evidence 才允许翻 `true`evaluator 无法通过返回 `{pass: true}` 而不给 evidence 让 driver 接受
五条一起决定 evaluator 结论只能靠证据推动,无法靠自信推动,也无法靠主 agent 悄悄改测试推动。
**Phase 1 内置三个 backend**
- `loop-evaluator-shell` 实现 `single-metric`:跑 shell 命令,`exit 0` = pass
- `loop-evaluator-rubric-judge` 实现 `llm-judge`:预写 rubric + LLM 打分,仅软目标
- `loop-budget-preflight`:每轮启动前估 `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`,超 `perRoundUsd` 拒绝启动。估算模型来自 MartinLoop `policy.ts:551-596`
`PricingProvider` 服务注入 pricing 表,test seam 可覆盖,不硬编到 driver 里(AGENTS.md「No hardcoded tunables in plugins」)。`rubric``contract` 档 Phase 2 补内置实现,Phase 1 只暴露类型让第三方插件先接。
**局限**evaluator subagent 拿到的"只读工具"仍是同一进程的 shell 与 fs 读,理论上仍可能被 prompt injection 绕过。挡定向对抗需要两容器方案(evaluator 定义文件对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路),本 RFC Phase 3 才做。见 风险。
### 可插拔的 RoundHandoff
每轮之间如何传递 context 是一个两难。完整保留之前对话(continue)连续性好,但对话会持续增长最终撞上 context 上限,且上一轮的错误信息会污染后续每一轮。每轮从零开始(fresh)避免污染,但每次需要重新理解上下文。跑 3 轮改稿与跑 80 轮 overnight 修 bug 需要的策略是相反的。Claude Code、Codex 都硬编一种模式,用户没法按任务类型切换。
做成 service seam
```ts
interface RoundContext { loopId: string; round: number }
interface NextRoundSpec { mode: 'fresh' | 'continue' }
interface RoundHandoff {
buildNextRound(prev: RoundContext): NextRoundSpec
}
export {}
```
Phase 1 内置三个 backend
| Backend | 场景 | 机制 |
|---|---|---|
| `handoff-fresh-with-summary`(默认) | 长跑、无人值守 | 每轮开 fresh subagent,只注入一段 progress 摘要作 system prompt 附加段 |
| `handoff-continue-with-compaction`(推荐中间档) | 5–20 轮的中等长度 | 整段对话保留到 token 阈值,超了复用 [`packages/compact`](../../../../packages/compact/README.md) 压缩,摘要 + 最近 K 轮作起点 |
| `handoff-continue-raw`(专业档) | ≤5 轮短任务、测试 | 纯连续对话不裁剪 |
**为什么默认 fresh** 所有实际跑成的长跑 looprepomirror、Kimi ralph-loop、autoresearch)用的都是 fresh。把重要 loop 状态放在 context window 外由 driver 管理是长跑的正确姿势。`handoff-continue-raw` 违反这条经验,README 明写长跑不适用。
**为什么中间档只有我们能做?** `handoff-continue-with-compaction` 依赖 compaction seam——竞品都没有,只有本仓库 `packages/compact` 提供了这个基础设施。
**为什么做成 seam 而不是三选一 flag?** 用户可以写 20 行插件表达「前 5 轮 continue、之后 fresh」这类混合策略,或表达「context 到 50% 自动 compact 一次」,不用等主库支持。
**局限**`continue-with-compaction` 依赖 `packages/compact` 的压缩质量,压缩本身可能把幻觉信息写进摘要传下去;README 建议长跑首选 fresh。三个 backend 的边界会让新用户不知道选哪个;`dsh loop` CLI 默认用 fresh,用户在遇到具体问题前不需要理解这些差别。
### 可插拔的 GoalReflector
用户在启动 loop 时给的目标不一定准确。可能基于错误假设(让 agent 用某个已经废弃的 API 实现功能),可能不够清晰(agent 在做的过程中才发现需要澄清),也可能被后来的信息证伪。现在的循环执行框架把 goal 当作启动时冻结的合约,agent 只能沿着原路蛮干,结果是在错的方向上耗尽预算。
做成 service seam,与 `Evaluator` 职责分离:evaluator 问「是否达成目标」,reflector 问「目标是否还是那个目标」。
```ts
interface RoundContext { loopId: string; round: number }
interface GoalConcern { concern: string; severity: 'low' | 'medium' | 'high' }
interface GoalReflector {
reflect(ctx: RoundContext, concerns: GoalConcern[]): Promise<GoalReflection>
}
type GoalReflection =
| { kind: 'continue' } // goal 仍有效
| { kind: 'revise'; newGoal: string; why: string } // 建议修正 goal
| { kind: 'stop-for-human'; reason: string } // 需要人拍板
export {}
```
**concern 有三种触发来源**Phase 1 实现前两种:
- **agent 主动**:通过 model-facing tool `loop_flag_concern({ concern, severity })`。agent 在调研中意识到「用户假设的那个库已经废弃」时可以直接 raise
- **driver 启发式**:预算过 50% 且零 criterion pass 时,driver 自动 raise `no-progress-toward-goal` concern
- **周期性 reflector subagent**Phase 2):每 N 轮独立跑一个只读 subagent 复审 goal 有效性,与 evaluator 独立性遵循同一思路
**响应策略通过 `onGoalConcern` 配置项**。这四种配置对应不同的 loop 使用哲学,用户按团队协作方式选,driver 不预设立场:
- `'stop'`Phase 1 默认):任何 concern 都触发 `StopCondition: approval-required`,人拍板。loop 在遇到任何不确定性时都不应自己往下走,适合谨慎风格团队与影响面较大的 loop 场景
- `'notify-continue'`Phase 1):记 `loop/goal-concern` session event(高优先级)加 ACP 显式提示,继续跑,人在结束时集中审阅。loop 内部不打扰,适合无人值守长跑
- `'reflect'`Phase 2):调 `GoalReflector` 决定 continue、revise 还是 stop。委派一个独立 agent 代替人做初步判断,适合中等自主度的团队
- 不注册 `GoalReflector``onGoalConcern` 未设 = 最放手档,loop 只在传统 stop condition 触发时停
**为什么默认选 `stop`** 无人值守场景下宁可多停一次也不要在错方向上跑几小时。用户明确要无人值守可切 `notify-continue`
concern 本身就是普通 session event,跟前文的持久化 session 能力天然协同:resume 时可以从 concern 出现的那一轮拉起,换 goal 重跑,前 N 轮的工作不丢。
**滥用与丢失防护**。agent 可能每轮都 raise concern;缓解是 `severity` 字段和 driver 侧的最小 rate limit(同一 concern 30 秒内去重)。这种滥用的代价是 agent 卡住自己无法推进,动机不强。goal 被 revise 后原始 goal 会丢失;每次 revise 落 `loop/goal-revised` session event 带 rationaleresume 时可选任意历史 goal 版本。
### 用户面
四个触发面共享同一个 driver
- **agent 侧 tool**`loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` 启动嵌套 harness loop。正在跑的 loop 内部 agent 可用 `loop_flag_concern({ concern, severity })` 主动发起 concern。ACP 渲染意图为 `generic`。agent 自主发起就是 proactive 触发,无需额外机制
- **CLI**`dsh loop <prompt> --stop <shell-cmd> --max-rounds N --max-usd X --handoff fresh`。人类主导启动,最典型的 Ralph 风格用法
- **cordis leaf**`cordis.yml` 里以 leaf 形式声明常驻循环,配合未来的 `dsh-schedule` RFC 可做周期性触发
- **ACP slash command**`/loop <goal>`(还有 `/loop-flag-concern`)在编辑器/客户端的当前会话里直接启动。语义等价于人类在 CLI 里敲 `dsh loop`,但发生在正在进行的 ACP session 上下文中,允许 loop 结果直接注入会话
ACP slash command 的依赖:`packages/ui/acp``available_commands_update` 面目前是 unbuilt 状态([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md))。等 harness 的 slash command 基础设施落地,`/loop``/loop-flag-concern` 只需在该基础设施里注册;driver 与 tool 接口不变。本 RFC 保留名字并给出参数 shape,但不承诺基础设施本身——那属于独立的 ACP 补齐 RFC。
默认 system prompt 里有两条硬约束,随所有内置 `loop` tool 一起分发:
1. 不允许写 `TODO``FAKE``PLACEHOLDER` 占位符让 evaluator 表面通过
2. 不允许写空的 `try/except``catch(_)` 让 evaluator 忽略错误
这两条不是 seam 层能拦的,是 prompt 层的约定。用户可以自定义 system prompt 但内置约束保留。
### 与仓库现有代码的关系
直接复用无需修改:
- `packages/subagent``spawn` provider、`toolFilter``persona`——loop 每轮起 subagent、evaluator 只读工具集
- `packages/session-persistence` 的 SQLite backend——loop-session 落盘
- `packages/compact`——`handoff-continue-with-compaction` 的实现基础
- `packages/todo`——单会话 continue 模式下作为可选 progress 表达
- 若 [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) 先落地,loop tool 可用它逐轮 UI 更新
不动:`packages/core/agent-loop`inner loop 语义保持);`packages/workflow`DAG 编排 vs. 迭代同 goal 是 orthogonal 关系,两个 README 在「Related」段互链说明边界)。
依赖尚未落地的两处:
- [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md)——见 Loop 作为独立 session 局限段的缓解方案
- ACP slash command 基础设施(`available_commands_update` 面)——见 用户面。基础设施落地前,slash command 触发面缺席,其它三个触发面照常工作
唯一涉及现有代码的改动可延后到 Phase 2:给 `packages/subagent-tool` 增加「续跑已有 subagent」的参数暴露,用于 `handoff-continue-*` 两个 backend。底层 `SubagentRun.sendMessage``resume` 已作为 seam 能力存在,缺的只是 tool 层的参数入口。若 Phase 1 只上 `handoff-fresh-with-summary`,完全不动 subagent-toolPhase 2 再补。
### 分阶段
**Phase 1**(本 RFC 承诺范围):三包 seam;`StopCondition``EvaluatorSpec` 四档类型 + `protectedPaths` 硬隔离(复用 `packages/fs` policy gate),其中 `single-metric``llm-judge` 有内置实现,`rubric``contract` 类型开放待接;Default-FAIL 强制;3 个内置 evaluator/budget/handoff backend`loop_flag_concern` toolno-progress 启发式;`onGoalConcern: 'stop' | 'notify-continue'` 二档;CLItool;默认 system prompt 硬约束。**不含**session-query 面、ACP slash command 触发面(依赖 `available_commands_update` 基础设施)、subagent-tool 续跑改动、stuck 检测器、Reflector subagent、`loop_split` tool、`rubric``contract` 档的内置实现。
**Phase 2**query 面;stuck 检测器(复现 OpenHands 5 种模式);subagent-tool 续跑改动(解锁 continue 两档 handoff);Reflector subagent`onGoalConcern: 'reflect'` 档;`loop_split` model-facing tool`rubric``contract` 档的内置实现。
**Phase 3**agent fleet(同 goal 派 N 个并行 loop 取最优);与 `dsh-schedule` 集成;两容器 evaluator 隔离(evaluator 定义文件对主 agent 完全不可访问,防 reward 反向优化)。
## 备选方案
**扩 `packages/core/agent-loop`**:给 inner loop 加「iterate on end_turn until goal」开关。拒绝——AGENTS.md「新行为走文档化扩展 seam;改 agent-loop 需要更新 docs/architecture.md」。harness loop 需要跨 session、跨 agent 的状态,塞进 inner loop 会把 session 语义拧成两层混合。
**只做一个 slash command `/loop`Claude Code 复刻)**:实现最简。拒绝——slash-command 层不解决 harness/inner 边界;四条设计要点(可查询 session、分档 evaluator、可插拔 handoff、可插拔 goal reflector)在 slash-command 层没有承载点,本 RFC 承诺的能力全部丢失。
**全权外包给 `packages/workflow`**:把 loop 表达成带回边的 workflow 节点。拒绝——workflow 缺 iteration、StopCondition、Evaluator 的一等公民语义。硬用会把 evaluator 冒充成一个 phase,违反 evaluator 独立于 producer 的架构隔离要求;预算护栏在 workflow 是 phase-level 而非 round-level,粒度对不上。
**Afreshvs. Bcontinue)硬编二选一**Ralph 派和 LoopTroop 派各自都有强场景。拒绝——可插拔的 RoundHandoff 提出 seam + 三档内置 backend 涵盖两派并允许 hybrid。
**不做 evaluator seam,内置几种够用**:更轻。拒绝——可插拔的 Evaluator 与 Budget 的核心价值是团队或私有 evaluator 可扩展。写死后长跑无人值守场景的用户只能改主库。
**接受不带 `EvaluatorSpec` 档位的自由函数**:允许用户传任意 `(result) => boolean`。拒绝——档位强制用户在启动时明确「用哪一档强度判成败」,是防止不知不觉滑到弱档的关键。自由函数看起来灵活,实际让 evaluator 强度隐性下沉,长跑场景代价大。
**引入独立记忆引擎(Beads / dex-style**:外部化状态的成熟做法。拒绝——`packages/session-persistence` + `sqlite-session-query-provider` 已能提供等效能力;新引擎收益远小于维护成本。
**goal reflection 塞进 Evaluator seam**(让 evaluator 返回「criteria 不可能满足」):拒绝——混淆「是否成功」和「目标是否正确」两个正交问题。`Evaluator` 应保持独立、只读、简单。
**goal-concern 只做 event 不做 seam**:更轻。拒绝——响应策略族(stop / notify / reflect)明确,各团队会想插自己的,seam 化投资小于收益。
**Phase 1 就上完整 Reflector subagent**:更全。拒绝——`loop_flag_concern` tool + no-progress 启发式 + 二档 policy 覆盖 80% 场景;每轮跑独立 subagent 成本高,Phase 2 按需引入更合理。
**不做 `loop_split`,用户自己拆**Phase 1 已经如此。Phase 2 加是因为长跑场景发现 agent 收到过大 goal 会直接跑而不是自己拆,需要显式工具引导。
## 验收标准
- `packages/loop/{loop,loop-driver,loop-tool}` 三包按 capability seam 建成;`dsh-loop` 只导 types 与 registry
- `StopCondition` 判别覆盖所有分支(单元),`assertNever` 编译期收口
- `Evaluator``BudgetPolicy``RoundHandoff``GoalReflector` 四条 service 都能被外部插件替换(fixture:注入 mock 实现,driver 正确调用)
- `EvaluatorSpec` 四档类型编译期收敛;driver 拒绝启动没有 evaluator 配对的 loopfixture`loop({ goal, evaluator: undefined })` 立即返回配置错误)
- Default-FAIL fixtureevaluator 报告返回 `{criterion, pass: true, evidence: []}` 时 driver 拒绝该 criterion 翻转、记 `evaluator/invalid-report` session event
- 三个内置 handoff backend 都有单元 + 一个 e2e`fresh-with-summary`(跑到 pass)、`continue-with-compaction`(跑超 token 阈值触发 compact)、`continue-raw`(跑 3 轮)
- `dsh loop` CLI e2e:给定 goal + 3 轮上限 + 一个 shell evaluator,通过与耗尽两条路径都返回结构化 stop cause 并 exit code 语义化
- Evaluator 独立性 fixture:主 agent 有 fs.writeevaluator subagent 的 tool set 里没有;试图调 fs.write 被 registry 拒绝
- protectedPaths fixture`EvaluatorSpec.protectedPaths: ["tests/**"]` 声明后,主 agent 尝试写 `tests/foo.py``packages/fs` policy gate 拒绝并记 `loop/hack-attempt` session eventevaluator 侧读该路径正常
- Preflight 护栏 fixture:注入 mock pricing 表构造超 `perRoundUsd` 的场景,driver 拒绝启动该轮且 emit `budget-cap` StopCondition
- Goal concern fixture`loop_flag_concern` 可从主 agent 调用并产出 `loop/goal-concern` session event`onGoalConcern: 'stop'` 下 emit `approval-required` StopCondition`'notify-continue'` 下继续跑且事件带 ACP 高优先级标记;no-progress 启发式在预算超 50% 且零 pass 时自动触发一次(rate-limit 去重)
- 默认 system prompt 硬约束(无 TODO/FAKE/PLACEHOLDER、无空 catch)随内置 `loop` tool 一起分发,snapshot 覆盖 prompt 内容
- 每轮的 prompt、inner-loop 结果、evaluator report、stop 决策都以 session event 出现;Phase 2 补 query 面时可按 `loopId` 检索
- `packages/loop/README.md``packages/workflow/README.md` 的「Related」段互链清楚「何时用 workflow、何时用 loop」的边界
- 单元 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene 全绿;新增 tool 的 ACP 渲染意图(`generic`)有 snapshot
## 风险
**依赖 [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) 落地**。Loop 作为独立 session 的用户可见价值(任意轮 resume + 元循环学习)需要它。缓解在该节局限段;Phase 1 不硬绑,Phase 2 才交付 query 面。
**`packages/workflow` 与 loop 的边界是持续答疑热点**。「多轮是 loop 还是 workflow」两个 README 必须写清楚:workflow 是「步骤已知、agent 未定、并串行编排」;loop 是「agent 已定、轮数未定、evaluator 判停」。文档不清晰会让用户混用错档。
**evaluator 反向优化(reward hacking**。足够长的 loop 里 agent 有条件识别 evaluator 的模式并针对性优化,例如发现「只要测试文件里出现 `assert True` 就 PASS」从而绕过实质完成。**Phase 1 靠 `protectedPaths` 挡多数 case**evaluator 的输入文件(测试、评估配置)通过 `packages/fs` policy gate 声明为主 agent 不可写,直接从「改测试让 evaluator 通过」这条路上封死。但仍无法阻止 agent 学出 evaluator 的模式做实质规避(比如写符合表面 pattern 但语义错的代码)。对抗强度高的用户需要 Phase 3 的两容器方案:evaluator 的整个运行时(二进制、rubric、依赖库)都在主 agent 完全不可访问的容器里,Anthropic patch.py 走的就是这条路。
**占位符伪造与过度防御码**。agent 有时会写 `# TODO: implement` 让测试勉强通过,或写大量 `try/except: pass` 让 evaluator 表面 PASS。这些不属于 evaluator 层的问题,而是 agent 生成阶段的 prompt 与训练问题。缓解走 用户面 段那两条默认 system prompt 硬约束;用户自定义 evaluator 时若加入「静态检查禁止 TODO 与空 catch」这类规则更稳妥。这类问题不是 seam 层能根治的。
**预算估算漂移**。pricing 表是常量,模型调价后估算会飘。护栏保守方向的近似不算 bug,但 README 说明「真实计费以 usage 事件为准,preflight 仅保护单轮爆炸」。
**长跑 loop 日志膨胀**。跑 100 轮 loop 单 session 上 MB 级。`logDetail: 'summary'` 兜底但 Phase 1 默认 `full`Phase 2 再补 summary 语义。
**pre-release 允许直接演进**`SESSION_FORMAT_VERSION=0``LoopRoundEvent` schema 可随时改;后端拒收旧格式而非兼容,与 AGENTS.md 顶部 pre-release stance 一致。