From 8a33e7cd006eab76000392cef1bbc20d9552b174 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 16 Jul 2026 16:53:25 +0800 Subject: [PATCH 01/10] docs(rfc): propose harness-level goal-based loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/rfc/INDEX.md | 1 + .../2026-07-16-harness-level-loop.i18n.yaml | 6 + .../feature/2026-07-16-harness-level-loop.md | 321 ++++++++++++++++++ .../2026-07-16-harness-level-loop.zh.md | 321 ++++++++++++++++++ 4 files changed, 649 insertions(+) create mode 100644 docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml create mode 100644 docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md create mode 100644 docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a60579ef4c..17dcb13f8f 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -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 diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml new file mode 100644 index 0000000000..fe26d5d8a4 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -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 diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md new file mode 100644 index 0000000000..8bc4ecf373 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md @@ -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 100–500 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 10–100×, 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, 5–20 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 +} + +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 --stop --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 ` (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. diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md new file mode 100644 index 0000000000..4e1b677259 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.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 events,100 轮 loop 约 100–500 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 只能来自 evaluator,driver 或主 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?** 所有实际跑成的长跑 loop(repomirror、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 +} + +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 带 rationale,resume 时可选任意历史 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 --stop --max-rounds N --max-usd X --handoff fresh`。人类主导启动,最典型的 Ralph 风格用法 +- **cordis leaf**:`cordis.yml` 里以 leaf 形式声明常驻循环,配合未来的 `dsh-schedule` RFC 可做周期性触发 +- **ACP slash command**:`/loop `(还有 `/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-tool;Phase 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` tool;no-progress 启发式;`onGoalConcern: 'stop' | 'notify-continue'` 二档;CLI;tool;默认 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,粒度对不上。 + +**A(fresh)vs. B(continue)硬编二选一**: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 配对的 loop(fixture:`loop({ goal, evaluator: undefined })` 立即返回配置错误) +- Default-FAIL fixture:evaluator 报告返回 `{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.write,evaluator subagent 的 tool set 里没有;试图调 fs.write 被 registry 拒绝 +- protectedPaths fixture:`EvaluatorSpec.protectedPaths: ["tests/**"]` 声明后,主 agent 尝试写 `tests/foo.py` 被 `packages/fs` policy gate 拒绝并记 `loop/hack-attempt` session event,evaluator 侧读该路径正常 +- 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 一致。 From d7ead6fdec930362de7877d33a991f5ba0d31aeb Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 16 Jul 2026 18:47:46 +0800 Subject: [PATCH 02/10] docs(rfc): correct harness loop contracts --- .../2026-07-16-harness-level-loop.i18n.yaml | 4 +- .../feature/2026-07-16-harness-level-loop.md | 184 ++++++++++-------- .../2026-07-16-harness-level-loop.zh.md | 184 ++++++++++-------- 3 files changed, 208 insertions(+), 164 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml index fe26d5d8a4..b167a54cef 100644 --- a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # 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 +2026-07-16-harness-level-loop.md: d308442c53dacd4fdb0d298c21e843899aba8d5c +2026-07-16-harness-level-loop.zh.md: b4884f2082deb601a1a5308911cb9458fe72f8af diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md index 8bc4ecf373..d308442c53 100644 --- a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md @@ -13,7 +13,7 @@ The existing code offers three "just enough to run" alternatives, none of them a | 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 | +| An external shell `while :; do dsh-sdk …; 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. @@ -33,16 +33,16 @@ This RFC only **adds a capability seam `packages/loop/`** for the goal-based sha 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`: types, the `LoopDriver` service, the `StopCondition` discriminated union, the Phase 1 service definitions (`Evaluator` / `BudgetPolicy` / `RoundHandoff`), and the event schema; `GoalReflector` joins in Phase 2 with its first caller - `@deepseek-ai/dsh-loop-driver`: the default driver implementation -- `@deepseek-ai/dsh-loop-tool`: the model-facing `loop` tool plus the CLI `dsh loop` +- `@deepseek-ai/dsh-loop-tool`: the model-facing `loop` tool plus the CLI `dsh-sdk loop` -The design is organized around four concrete problems, each addressed by an independent cordis service seam: +The design is organized around four concrete problems, addressed by service seams or explicit driver policies in the phase where each has a caller: 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. +4. The user's initial goal is not always correct. An agent stubbornly pursuing a wrong goal exhausts the budget doing wrong work. **Goal concern events and policies cover Phase 1; the GoalReflector service arrives with the Phase 2 `reflect` path**. 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. @@ -55,7 +55,7 @@ interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; ev type StopCondition = | { kind: 'goal-met'; evidence: EvaluatorReport } - | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds' } + | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds'; observed: number; maximum: number } | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } | { kind: 'approval-required'; reason: string } | { kind: 'user-cancel' } @@ -67,21 +67,21 @@ export {} 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. +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 diagnostic and replay 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?" +- **Replay conversation from a recorded round**: while the source session is live, discover round 78 went off and fork the round-77 event prefix with a different prompt or evaluator; persisted replay needs a separate trusted load-and-seed path. Both forms replay conversation state against the current workspace, not the files and external side effects that existed at round 77 +- **Post-hoc diagnosis**: through the existing `ctx.sessionQuery` exact-read service, inspect the round where the evaluator started hanging on the same criterion +- **Meta-loop learning**: the proposed [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) can later find related historical loops before a new run—"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 100–500 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 10–100×, 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. +**Storage and recovery boundary**. A few KB of events per round, roughly 100–500 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 10–100×, and the README calls this out clearly. Exact live and persisted reads already exist through `ctx.sessionQuery`; FTS5 is an optional discovery improvement, not a Phase 1 dependency. Exact execution-world restore is not promised: `SessionStore.fork()` accepts a live session, and session events do not restore files, processes, environment, or external side effects. Restoring those requires a separate Git/worktree/checkpoint design. ### 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. +Trustworthy evaluation needs both a deterministic judgment mechanism and an isolation boundary appropriate to the threat model: shell exit code, static analysis, or an external service avoids LLM self-judgment, while a separate worktree, read-only mount, container, or remote service prevents the worker from rewriting evaluator inputs. Only the user knows which checks and boundary to use: `pytest` commands differ by project, companies have private compliance checkers, and 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). @@ -91,47 +91,47 @@ Budget is the same story: product-level spending guardrails are opaque, and cann 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[] +type CriteriaSpec = + | { kind: 'single-metric'; name: string } + | { kind: 'rubric'; criteria: RubricItem[] } + | { kind: 'contract'; interface: EvaluatorContract } + +type ExecutorSpec = + | { kind: 'shell'; command: string } + | { kind: 'llm-judge'; rubric: string; model: string } + | { kind: 'provider'; name: string; config?: unknown } + +type IsolationSpec = 'same-workspace' | 'separate-worktree' | 'container' | 'remote' + +interface EvaluatorSpec { + criteria: CriteriaSpec + executor: ExecutorSpec + isolation: IsolationSpec } 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. +**Why explicit dimensions instead of letting the user pass any function?** The spec forces the user, at start time, to declare what is judged, what executes the judgment, and what isolation boundary protects it. A free function looks flexible, but in practice it lets evaluator strength quietly regress—the user thinks they're doing a deterministic isolated check when they've actually written a same-workspace LLM judgment. 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". +Criteria shape, executor, and isolation are orthogonal rather than a trust ladder: a rubric may be checked by shell, an LLM, or an external service, and a contract may run in the same workspace or in a container. `llm-judge` remains the weakest executor for soft goals where there truly is no hard signal (revision, translation quality). The documentation explicitly notes "this executor cannot defend against targeted adversarial input; long unattended runs require at least one deterministic evaluator with an isolation boundary appropriate to the threat model". -The driver enforces five hard constraints, not delegated to Evaluator implementations. They address three distinct attack surfaces: +The driver enforces four structural constraints, not delegated to Evaluator implementations. Isolation strength remains an explicit property of the configured provider rather than a claim the driver can manufacture. **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 +1. **fresh subagent for LLM evaluation**: an LLM evaluator opens a fresh subagent (via `dsh-subagent`'s `spawn`) each round, not inheriting the main loop's context **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 +2. **scoped tool set**: an LLM evaluator's model-facing tool set is stripped by the driver to reads only, with write tools disabled (reusing `SubagentProvider`'s `toolFilter`). This reduces accidental mutation but is not process isolation: shell, code runtimes, or another capability can still write unless the configured isolation boundary prevents it **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 +3. **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 +4. **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. +Together, the four ensure that evaluator conclusions are structurally evidence-driven rather than confidence-driven. They do not stop the main agent from modifying evaluator inputs in a shared workspace. **Phase 1 ships three backends**: @@ -139,9 +139,9 @@ Together, the five decide that evaluator conclusions can only be driven by evide - `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. +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"). A resolved budget carries `maxRounds`, optional `maxTokens` and `maxUsd`, and optional `perRoundUsd`. The driver checks per-round admission before starting work, then accumulates worker, evaluator, compaction, and reflector usage after every request. A token or USD cap may overrun by one in-flight request because usage arrives after completion; the `budget-cap` result reports `observed` and `maximum`. The `rubric` and `contract` criteria shapes get built-in executors in Phase 2; Phase 1 exposes the shapes 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. +**Limitation**: `same-workspace` plus a read-only model-facing tool set is not hard isolation. The current `packages/fs` policy enforces read-before-edit and version guards, not path denial, and bash or code runtimes can bypass filesystem tools. Defending against targeted adversarial input requires a boundary across every mutation channel—such as a read-only mount, isolated worktree, container, or remote evaluator. The two-container approach (the evaluator's definition files are entirely inaccessible to the main agent, the route Anthropic patch.py takes) remains a Phase 3 item. See Risks. ### Pluggable RoundHandoff @@ -150,23 +150,40 @@ How context passes between rounds is a dilemma. Preserving the full prior conver Made a service seam: ```ts -interface RoundContext { loopId: string; round: number } -interface NextRoundSpec { mode: 'fresh' | 'continue' } +interface ContinuationRun { + readonly id: string + resume?(prompt: string): Promise +} + +interface PreviousRound { + result: unknown + evaluator: { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } + tokenUsage: number + summary: string + sessionId: string + run?: ContinuationRun +} + +interface RoundContext { loopId: string; round: number; previous: PreviousRound } + +type NextRoundSpec = + | { mode: 'fresh'; prompt: string } + | { mode: 'continue'; run: ContinuationRun; prompt: string } interface RoundHandoff { - buildNextRound(prev: RoundContext): NextRoundSpec + buildNextRound(prev: RoundContext, signal: AbortSignal): Promise } export {} ``` -Phase 1 ships three backends: +Phase 1 ships the fresh backend; Phase 2 adds the two continuation backends after provider continuation exists: -| 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, 5–20 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 | +| Backend | Phase | Scenario | Mechanism | +|---|---|---|---| +| `handoff-fresh-with-summary` (default) | Phase 1 | 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) | Phase 2 | Medium length, 5–20 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) | Phase 2 | ≤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. @@ -174,13 +191,13 @@ Phase 1 ships three backends: **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. +**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-sdk 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". +Phase 2 makes this 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". Phase 1 carries concern events plus the `stop` and `notify-continue` driver policies without registering an unused `GoalReflector` service. ```ts interface RoundContext { loopId: string; round: number } @@ -198,7 +215,7 @@ type GoalReflection = export {} ``` -**Concerns have three sources**, and Phase 1 ships the first two: +**Concerns have three sources**. Phase 1 ships the first two; the `GoalReflector` service and periodic source arrive together in Phase 2: - **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 @@ -207,58 +224,61 @@ export {} **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 +- `'notify-continue'` (Phase 1): record an ordinary `loop/goal-concern` session event, then continue; a human reviews at the end. ACP has no general high-priority marker, so dedicated concern rendering is deferred with the ACP command infrastructure. 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. +A concern is itself just an ordinary session event, composing naturally with the persistence capability described earlier: a later replay can seed a new conversation from the round where the concern surfaced and swap the goal. This does not roll the workspace back to that round. -**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. +**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 later replay can select any historical goal version without claiming workspace restoration. ### 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 --stop --max-rounds N --max-usd X --handoff fresh` is human-initiated startup, the most typical Ralph-style usage +- **Agent-side tool**: `loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` registers `kind: 'loop'` through `ctx.tasks`, returns the task id immediately, and runs the harness loop in the background. `task_output`, `task_list`, and `task_kill` provide collection and cancellation. 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-sdk loop --stop --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 ` (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 +- **ACP slash command**: `/loop ` (and `/loop-flag-concern`) starts directly from within the editor or client's current session. Semantically equivalent to a human typing `dsh-sdk 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: +The default system prompt carries two behavioral instructions, 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. +Neither can be enforced at the seam layer; both are prompt-layer guidance and must not be described as hard constraints. Users may customize the system prompt; evaluators that require these rules must check them explicitly. ### 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 +- `packages/subagent`'s `spawn` provider, `toolFilter`, and `persona`—the loop spawns a subagent per round; an LLM evaluator gets a scoped model-facing tool set, not a process-isolation guarantee +- `packages/tasks`—the model-facing loop is a `loop` task producer and reuses owner isolation, `task_output`/`task_list`/`task_kill`, completion notices, cancellation, and awaited cleanup - The SQLite backend from `packages/session-persistence`—the loop-session persists +- `packages/session-query`—exact live and persisted session reads for post-hoc diagnosis - `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: +One dependency is 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. +The proposed [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) is an optional Phase 2 discovery improvement over the existing exact-read query service, not a dependency for Phase 1 event access. + +Continuation work can be deferred to Phase 2: the `SubagentRun.sendMessage` and `resume` methods exist as optional seam capabilities, but the current `subagent-spawn` provider deliberately exposes neither. The two `handoff-continue-*` backends therefore require provider implementations, capability checks, ownership tests, and a consumer surface—not only a new argument on `packages/subagent-tool`. Phase 1 ships only `handoff-fresh-with-summary` and does not touch subagent continuation. ### 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 1** (the scope this RFC commits): the three-package seam; `StopCondition`; the orthogonal criteria/executor/isolation `EvaluatorSpec`, with built-in implementations for shell and LLM-judge execution and rubric/contract criteria shapes open for integration; Default-FAIL enforcement; evaluator and cumulative-budget backends; `handoff-fresh-with-summary`; `ctx.tasks` integration; the `loop_flag_concern` tool; the no-progress heuristic; the `onGoalConcern: 'stop' | 'notify-continue'` pair; the CLI; the tool; and the default system-prompt guidance. **Not included**: the SQLite FTS5 search surface, the ACP slash-command trigger surface (depends on the `available_commands_update` infrastructure), subagent continuation provider/tool work, the `GoalReflector` service, the stuck detector, the Reflector subagent, the `loop_split` tool, and built-in executors for every rubric/contract combination. -**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 2**: the SQLite FTS5 search surface; the stuck detector (reproducing OpenHands's five patterns); subagent continuation provider implementations, capability checks, and consumer surface (unlocking the two continue handoffs); the `GoalReflector` service and Reflector subagent; the `onGoalConcern: 'reflect'` tier; the `loop_split` model-facing tool; and built-in executors for additional rubric/contract combinations. **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). @@ -274,13 +294,13 @@ The one modification to existing code can be deferred to Phase 2: adding a "resu **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. +**Accept a free function that lacks an explicit `EvaluatorSpec`**: allow users to pass any `(result) => boolean`. Rejected—the criteria/executor/isolation dimensions force users to declare at start time what is judged, what runs the judgment, and what boundary protects it, preventing quiet regression to a weaker setup. 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. +**Introduce an independent memory engine (Beads / dex-style)**: an established approach to external state. Rejected—`packages/session-persistence` plus the existing exact-read `ctx.sessionQuery` already cover Phase 1 diagnosis, while SQLite FTS5 can add search later; 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. +**Only ever add an event for goal-concern, no seam**: lighter. Phase 1 does use the event plus `stop`/`notify` policies; rejected as the final design because the Phase 2 `reflect` path needs a replaceable response strategy. The seam lands with that first caller rather than ahead of it. **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. @@ -290,31 +310,33 @@ The one modification to existing code can be deferred to Phase 2: adding a "resu - 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) +- The Phase 1 services `Evaluator`, `BudgetPolicy`, and `RoundHandoff` can each be replaced by an external plugin (fixture: inject a mock implementation, driver calls it correctly); no `GoalReflector` service is registered before the Phase 2 `reflect` consumer exists +- `EvaluatorSpec`'s criteria/executor/isolation dimensions converge 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` +- `RoundHandoff` receives the previous result, evaluator report, token usage, summary, session id, optional run handle, and cancellation signal; Phase 1's `fresh-with-summary` has unit coverage plus one pass-path e2e, while continuation backend tests wait for Phase 2 provider support +- `dsh-sdk 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 scoping fixture: the main agent has fs.write while an LLM evaluator's model-facing tool set does not; the result and documentation still label `same-workspace` as non-isolated, and no `protectedPaths` guarantee is exposed +- Budget fixtures cover `perRoundUsd` admission plus cumulative `maxRounds`, `maxTokens`, and `maxUsd` across worker and evaluator usage; an in-flight overrun emits `budget-cap` with `observed` and `maximum` +- Goal concern fixture: `loop_flag_concern` is callable from the main agent and yields an ordinary `loop/goal-concern` session event; under `onGoalConcern: 'stop'` an `approval-required` StopCondition is emitted; under `'notify-continue'` the loop continues without nonexistent ACP priority metadata; the no-progress heuristic fires once when budget exceeds 50% with zero passes (with rate-limit dedup) +- The default system-prompt guidance (no TODO/FAKE/PLACEHOLDER, no empty catch) is distributed with the built-in `loop` tool, and a snapshot covers the prompt content without treating it as enforcement +- Each round's prompt, inner-loop result, evaluator report, and stop decision appear as session events and are readable through the existing exact-read `ctx.sessionQuery`; FTS5 search remains Phase 2 +- Model-facing loop startup returns a `loop` task id immediately; `task_output`, `task_list`, `task_kill`, parent-agent disposal, cancellation, producer reload, and service disposal cover owner isolation and awaited quiescence - 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. +**Conversation replay is not workspace restore**. Exact session reads already exist, and FTS5 improves historical discovery rather than enabling correctness. Replaying a round prefix against the current workspace can diagnose or redirect a run, but reproducing the execution world at that round requires Git/worktree/checkpoint support and an explicit policy for external side effects. **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. +**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's `same-workspace` mode does not prevent the agent from modifying tests or evaluator configuration through bash, code runtimes, or another write channel; the current `packages/fs` policy is not a path-isolation boundary. Users needing adversarial strength must choose an isolated worktree, read-only mount, container, or remote evaluator. Phase 3's two-container approach keeps the evaluator runtime (binary, rubric, dependency libraries) entirely inaccessible to the main agent, 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. +**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. The two default system-prompt instructions in User surface are guidance only; users who add "static-check-forbid TODO and empty catch" rules to a custom evaluator get enforceable coverage. 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". +**Budget estimation drift and in-flight overrun**. Pricing can change, and cumulative token/USD usage becomes exact only after each worker, evaluator, compaction, or reflector request reports usage. Preflight protects a single round; cumulative caps stop the next request and may exceed the configured maximum by one in-flight request. The README reports both observed and maximum values and states that provider billing remains authoritative. + +**Background tasks are process-local**. `ctx.tasks` gives the model-facing loop owner isolation, generic collection/cancellation, completion notices, and awaited cleanup. Parent-agent or service disposal cancels and awaits the loop; a process crash cannot run cleanup, and durable restart remains outside Phase 1. **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. diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md index 4e1b677259..b4884f2082 100644 --- a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md @@ -13,7 +13,7 @@ Status: proposed | 替代 | 问题 | |---|---| | `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 词汇,每个使用者各自重发明;循环本身没有持久化对象可供事后诊断或恢复 | +| 外部 shell `while :; do dsh-sdk …; 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 决定成败、预算是硬约束、跑完还需要能回看和恢复。 @@ -33,16 +33,16 @@ Status: proposed 三个包: -- `@deepseek-ai/dsh-loop`:类型、`LoopDriver` service、`StopCondition` 判别联合、四个内置 service 定义(`Evaluator` / `BudgetPolicy` / `RoundHandoff` / `GoalReflector`)、事件 schema +- `@deepseek-ai/dsh-loop`:类型、`LoopDriver` service、`StopCondition` 判别联合、Phase 1 service 定义(`Evaluator` / `BudgetPolicy` / `RoundHandoff`)、事件 schema;`GoalReflector` 在 Phase 2 与首个调用方一起加入 - `@deepseek-ai/dsh-loop-driver`:默认 driver 实现 -- `@deepseek-ai/dsh-loop-tool`:model-facing `loop` tool + CLI `dsh loop` +- `@deepseek-ai/dsh-loop-tool`:model-facing `loop` tool + CLI `dsh-sdk loop` -设计围绕四个具体问题展开,每个问题对应一条独立的 cordis service seam: +设计围绕四个具体问题展开,在每项能力出现调用方的 phase 中通过 service seam 或显式 driver policy 解决: 1. 长跑 loop 出问题后缺诊断和恢复手段。**loop 作为独立 session** 解决。 2. loop 结束时的 PASS 是否可信决定几小时工作是否作废。同一个 LLM 既生成又自评的架构本身就不可信。**Evaluator 与 Budget 做成 service seam** 解决。 3. 短任务和长任务需要的记忆策略相反,硬编一种模式会让另一类场景不可用。**RoundHandoff 做成 service seam** 解决。 -4. 用户初始给的 goal 未必始终正确。agent 沿着错的目标蛮干会耗尽预算做错事。**GoalReflector 做成 service seam** 解决。 +4. 用户初始给的 goal 未必始终正确。agent 沿着错的目标蛮干会耗尽预算做错事。**Phase 1 用 goal concern event 与 policy 处理;GoalReflector service 随 Phase 2 的 `reflect` 路径一起加入**。 四条 seam 之外还有一条贯穿全文的原则:**一个 loop 只处理一个原子目标**。大目标拆成若干小 loop 串联,不塞进一个 loop 让 evaluator 判定多件事。判定 granularity 是否合适的经验规则:如果 loop 跑完说不清它到底做完了什么,granularity 就太大,应当拆。Phase 2 补 `loop_split` model-facing tool 让 agent 收到过大 goal 时能自己拆。 @@ -55,7 +55,7 @@ interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; ev type StopCondition = | { kind: 'goal-met'; evidence: EvaluatorReport } - | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds' } + | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds'; observed: number; maximum: number } | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } | { kind: 'approval-required'; reason: string } | { kind: 'user-cancel' } @@ -67,21 +67,21 @@ export {} 长跑 loop 一旦出错,用户没有系统的诊断手段。跑几小时后失败,只能翻散落的日志文件。发现中间某一轮走偏想倒回去重跑,只能从头开始。agent 想参考自己过去 loop 的经验也没有可用的 API。 -Driver 为每个 loop 开一个独立的 loop-session(新的 session id)。每轮的输入、inner-loop 结果、evaluator 报告、stop 决策都作为 session event 落盘,复用 `packages/session-persistence` 的 SQLite backend。得到三种能力。 +Driver 为每个 loop 开一个独立的 loop-session(新的 session id)。每轮的输入、inner-loop 结果、evaluator 报告、stop 决策都作为 session event 落盘,复用 `packages/session-persistence` 的 SQLite backend。得到三种诊断与 replay 能力。 -- **从任意轮恢复**:发现第 78 轮偏航,从第 77 轮拉起,换 prompt 或换 evaluator 重跑,不必从头 -- **事后诊断**:通过 [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) 查「哪一轮 evaluator 开始一直挂在同条 criterion 上」定位卡点 -- **元循环学习**:agent 开新 loop 前查自己过往同类 loop 的经验——「我以前 fix 过类似的 bug 吗?失败在哪一轮?」 +- **从已记录轮次 replay 对话**:源 session 仍 live 时,发现第 78 轮偏航,可以 fork 第 77 轮的 event prefix,换 prompt 或 evaluator;已持久化 session 的 replay 还需要独立的受信任 load-and-seed 路径。两者都只会基于当前工作区 replay 对话状态,不会恢复第 77 轮的文件与外部副作用 +- **事后诊断**:通过现有 `ctx.sessionQuery` 精确读取 service 检查 evaluator 从哪一轮开始一直挂在同条 criterion 上 +- **元循环学习**:拟议中的 [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) 后续可以在新 loop 启动前找到相关历史 loop——「我以前 fix 过类似的 bug 吗?失败在哪一轮?」 Claude Code、Codex 的 `/goal` 是一次性对象:跑完就丢,agent 下次遇到同类问题从零开始。 -**存储与依赖**。每轮几 KB events,100 轮 loop 约 100–500 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。 +**存储与恢复边界**。每轮几 KB events,100 轮 loop 约 100–500 KB;跑几千个 loop 会到 GB 级。`logDetail: 'summary' | 'full'` 配置缓解,默认 `full`,长跑用户可切 `summary`。中间态全持久化会把生成过的 key、密码一并落盘,跟普通 session 是同一类风险但量放大 10–100 倍,README 明确提示。通过 `ctx.sessionQuery` 的精确 live 与已持久化读取已经存在;FTS5 是可选的发现能力增强,不是 Phase 1 依赖。本 RFC 不承诺精确恢复执行世界:`SessionStore.fork()` 只接受 live session,而 session event 不会恢复文件、进程、环境或外部副作用。这需要单独的 Git/worktree/checkpoint 设计。 ### 可插拔的 Evaluator 与 Budget loop 的价值最终取决于结束时的 PASS 是否可信。如果 evaluator 会被 hack 或幻觉 PASS,前面几小时的工作全部作废。同一个 LLM 既生成又自评的架构本身就不可信:模型有条件说服自己 PASS。即便让独立 subagent 做 evaluator,只要 evaluator 还是 LLM,就仍然对同类内容有系统性偏好——独立 subagent 只是缓解不是根治。 -真正可信的评估必须是完全非 LLM 的硬检查:shell exit code、静态分析、外部服务。LLM 物理上碰不到评估过程。但硬检查只有用户自己知道该跑什么:不同项目 `pytest` 命令不同、公司有私有合规检查器、有些团队还要跑内部 lint。主库无论内置几种都覆盖不全。所以 evaluator 必须做成用户可以自己接入的 seam。 +可信评估同时需要确定性的判断机制,以及与 threat model 匹配的隔离边界:shell exit code、静态分析或外部服务避免 LLM 自评;独立 worktree、只读 mount、容器或远程服务防止 worker 改写 evaluator 输入。具体检查和边界只有用户知道:不同项目 `pytest` 命令不同、公司有私有合规检查器、有些团队还要跑内部 lint。主库无论内置几种都覆盖不全。所以 evaluator 必须做成用户可以自己接入的 seam。 预算方面同理:产品级的花费护栏是黑盒,无法按团队策略调整(个人卡、团队分摊、按 PR 结算)。 @@ -91,47 +91,47 @@ loop 的价值最终取决于结束时的 PASS 是否可信。如果 evaluator 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[] +type CriteriaSpec = + | { kind: 'single-metric'; name: string } + | { kind: 'rubric'; criteria: RubricItem[] } + | { kind: 'contract'; interface: EvaluatorContract } + +type ExecutorSpec = + | { kind: 'shell'; command: string } + | { kind: 'llm-judge'; rubric: string; model: string } + | { kind: 'provider'; name: string; config?: unknown } + +type IsolationSpec = 'same-workspace' | 'separate-worktree' | 'container' | 'remote' + +interface EvaluatorSpec { + criteria: CriteriaSpec + executor: ExecutorSpec + isolation: IsolationSpec } export {} ``` -**为什么分档,而不是让用户传自由函数?** 档位强制用户在启动时明确「用哪一档强度判成败」。自由函数看起来灵活,实际让 evaluator 强度隐性下沉——用户以为在做严格判定,实际写的是 LLM-judge 那种模糊的语义匹配。长跑场景下代价是几小时白跑。 +**为什么使用显式维度,而不是让用户传自由函数?** spec 强制用户在启动时声明评估什么、由什么执行判断,以及什么隔离边界保护它。自由函数看起来灵活,实际让 evaluator 强度隐性下沉——用户以为在做确定性隔离检查,实际写的是同工作区 LLM 判断。长跑场景下代价是几小时白跑。 -四档从上到下可信度依次降低。前三档都是完全非 LLM 的硬检查。`llm-judge` 是最弱一档,仅用于确实无硬信号的软目标(改稿、翻译质量)。文档明确标注「此档不能挡定向对抗,长跑无人值守场景需至少一档硬检查配合」。 +criteria shape、executor 与 isolation 是三个正交维度,不是可信度阶梯:rubric 可以由 shell、LLM 或外部服务检查,contract 也可以在同一工作区或容器中运行。`llm-judge` 仍是最弱的 executor,仅用于确实无硬信号的软目标(改稿、翻译质量)。文档明确标注「此 executor 不能挡定向对抗,长跑无人值守场景至少需要一个确定性 evaluator,并配合与 threat model 匹配的隔离边界」。 -Driver 强制五条硬约束,不下放给 Evaluator 实现。它们分别对付三类不同的攻击面: +Driver 强制四条结构约束,不下放给 Evaluator 实现。隔离强度仍是已配置提供方的显式属性,不是 driver 能凭空制造的保证。 **防「同一个 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 通过」这条路 +1. **LLM 评估使用 fresh subagent**:LLM evaluator 每轮开 fresh subagent(用 `dsh-subagent` 的 `spawn`),不继承主循环 context **防 evaluator subagent 自身被 subverted**: -3. **只读工具集**:evaluator 的 tool set 被 driver 剥离到只保留读类工具,写类工具禁用(复用 `SubagentProvider` 的 `toolFilter`)。它防的不是主 agent 的 reward hacking,而是 evaluator 读到被 evaluate 的代码里 embed 的 prompt injection 时不会被诱导去改状态 +2. **限制模型可见工具集**:LLM evaluator 的 model-facing tool set 被 driver 剥离到只保留读类工具,写类工具禁用(复用 `SubagentProvider` 的 `toolFilter`)。这会减少意外修改,但不是进程隔离:除非已配置隔离边界拦截,否则 shell、代码运行时或其他 capability 仍能写入 **防 evaluator 报告本身欺骗 driver**: -4. **PASS 只能由 evaluator 报告翻转**:`goal-met` StopCondition 只能来自 evaluator,driver 或主 agent 都不能直接构造 -5. **Default-FAIL**:driver 内部维护每个 criterion 的 pass 状态默认 `false`,只有 evaluator 报告里带非空 evidence 才允许翻 `true`;evaluator 无法通过返回 `{pass: true}` 而不给 evidence 让 driver 接受 +3. **PASS 只能由 evaluator 报告翻转**:`goal-met` StopCondition 只能来自 evaluator,driver 或主 agent 都不能直接构造 +4. **Default-FAIL**:driver 内部维护每个 criterion 的 pass 状态默认 `false`,只有 evaluator 报告里带非空 evidence 才允许翻 `true`;evaluator 无法通过返回 `{pass: true}` 而不给 evidence 让 driver 接受 -五条一起决定 evaluator 结论只能靠证据推动,无法靠自信推动,也无法靠主 agent 悄悄改测试推动。 +四条一起保证 evaluator 结论在结构上由证据推动,而不是由自信推动。它们不能阻止主 agent 在共享工作区中修改 evaluator 输入。 **Phase 1 内置三个 backend**: @@ -139,9 +139,9 @@ Driver 强制五条硬约束,不下放给 Evaluator 实现。它们分别对 - `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 只暴露类型让第三方插件先接。 +`PricingProvider` 服务注入 pricing 表,test seam 可覆盖,不硬编到 driver 里(AGENTS.md「No hardcoded tunables in plugins」)。解析后的 budget 携带 `maxRounds`、可选 `maxTokens` 与 `maxUsd`,以及可选 `perRoundUsd`。driver 在启动工作前检查单轮准入,随后在每次请求后累计 worker、evaluator、compaction 和 reflector 用量。token 或 USD 上限可能被一个在途请求超出,因为 usage 在完成后才到达;`budget-cap` 结果同时报告 `observed` 与 `maximum`。`rubric` 与 `contract` criteria shape 在 Phase 2 补内置 executor,Phase 1 暴露这些 shape 让第三方插件先接。 -**局限**:evaluator subagent 拿到的"只读工具"仍是同一进程的 shell 与 fs 读,理论上仍可能被 prompt injection 绕过。挡定向对抗需要两容器方案(evaluator 定义文件对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路),本 RFC Phase 3 才做。见 风险。 +**局限**:`same-workspace` 加只读 model-facing tool set 不是硬隔离。当前 `packages/fs` policy 实施 read-before-edit 与版本保护,不是路径拒写;bash 或代码运行时可以绕过 filesystem tool。挡定向对抗需要覆盖所有写入通道的边界,例如只读 mount、隔离 worktree、容器或远程 evaluator。两容器方案(evaluator 定义文件对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路)仍在 Phase 3。见 风险。 ### 可插拔的 RoundHandoff @@ -150,23 +150,40 @@ Driver 强制五条硬约束,不下放给 Evaluator 实现。它们分别对 做成 service seam: ```ts -interface RoundContext { loopId: string; round: number } -interface NextRoundSpec { mode: 'fresh' | 'continue' } +interface ContinuationRun { + readonly id: string + resume?(prompt: string): Promise +} + +interface PreviousRound { + result: unknown + evaluator: { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } + tokenUsage: number + summary: string + sessionId: string + run?: ContinuationRun +} + +interface RoundContext { loopId: string; round: number; previous: PreviousRound } + +type NextRoundSpec = + | { mode: 'fresh'; prompt: string } + | { mode: 'continue'; run: ContinuationRun; prompt: string } interface RoundHandoff { - buildNextRound(prev: RoundContext): NextRoundSpec + buildNextRound(prev: RoundContext, signal: AbortSignal): Promise } export {} ``` -Phase 1 内置三个 backend: +Phase 1 交付 fresh backend;Phase 2 在 provider continuation 存在后增加两个 continuation 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 轮短任务、测试 | 纯连续对话不裁剪 | +| Backend | Phase | 场景 | 机制 | +|---|---|---|---| +| `handoff-fresh-with-summary`(默认) | Phase 1 | 长跑、无人值守 | 每轮开 fresh subagent,只注入一段 progress 摘要作 system prompt 附加段 | +| `handoff-continue-with-compaction`(推荐中间档) | Phase 2 | 5–20 轮的中等长度 | 整段对话保留到 token 阈值,超了复用 [`packages/compact`](../../../../packages/compact/README.md) 压缩,摘要 + 最近 K 轮作起点 | +| `handoff-continue-raw`(专业档) | Phase 2 | ≤5 轮短任务、测试 | 纯连续对话不裁剪 | **为什么默认 fresh?** 所有实际跑成的长跑 loop(repomirror、Kimi ralph-loop、autoresearch)用的都是 fresh。把重要 loop 状态放在 context window 外由 driver 管理是长跑的正确姿势。`handoff-continue-raw` 违反这条经验,README 明写长跑不适用。 @@ -174,13 +191,13 @@ Phase 1 内置三个 backend: **为什么做成 seam 而不是三选一 flag?** 用户可以写 20 行插件表达「前 5 轮 continue、之后 fresh」这类混合策略,或表达「context 到 50% 自动 compact 一次」,不用等主库支持。 -**局限**:`continue-with-compaction` 依赖 `packages/compact` 的压缩质量,压缩本身可能把幻觉信息写进摘要传下去;README 建议长跑首选 fresh。三个 backend 的边界会让新用户不知道选哪个;`dsh loop` CLI 默认用 fresh,用户在遇到具体问题前不需要理解这些差别。 +**局限**:`continue-with-compaction` 依赖 `packages/compact` 的压缩质量,压缩本身可能把幻觉信息写进摘要传下去;README 建议长跑首选 fresh。三个 backend 的边界会让新用户不知道选哪个;`dsh-sdk loop` CLI 默认用 fresh,用户在遇到具体问题前不需要理解这些差别。 ### 可插拔的 GoalReflector 用户在启动 loop 时给的目标不一定准确。可能基于错误假设(让 agent 用某个已经废弃的 API 实现功能),可能不够清晰(agent 在做的过程中才发现需要澄清),也可能被后来的信息证伪。现在的循环执行框架把 goal 当作启动时冻结的合约,agent 只能沿着原路蛮干,结果是在错的方向上耗尽预算。 -做成 service seam,与 `Evaluator` 职责分离:evaluator 问「是否达成目标」,reflector 问「目标是否还是那个目标」。 +Phase 2 把它做成 service seam,与 `Evaluator` 职责分离:evaluator 问「是否达成目标」,reflector 问「目标是否还是那个目标」。Phase 1 只携带 concern event,以及 `stop` 与 `notify-continue` driver policy,不注册没有调用方的 `GoalReflector` service。 ```ts interface RoundContext { loopId: string; round: number } @@ -198,7 +215,7 @@ type GoalReflection = export {} ``` -**concern 有三种触发来源**,Phase 1 实现前两种: +**concern 有三种触发来源**。Phase 1 实现前两种;`GoalReflector` service 与周期性来源一起在 Phase 2 加入: - **agent 主动**:通过 model-facing tool `loop_flag_concern({ concern, severity })`。agent 在调研中意识到「用户假设的那个库已经废弃」时可以直接 raise - **driver 启发式**:预算过 50% 且零 criterion pass 时,driver 自动 raise `no-progress-toward-goal` concern @@ -207,58 +224,61 @@ export {} **响应策略通过 `onGoalConcern` 配置项**。这四种配置对应不同的 loop 使用哲学,用户按团队协作方式选,driver 不预设立场: - `'stop'`(Phase 1 默认):任何 concern 都触发 `StopCondition: approval-required`,人拍板。loop 在遇到任何不确定性时都不应自己往下走,适合谨慎风格团队与影响面较大的 loop 场景 -- `'notify-continue'`(Phase 1):记 `loop/goal-concern` session event(高优先级)加 ACP 显式提示,继续跑,人在结束时集中审阅。loop 内部不打扰,适合无人值守长跑 +- `'notify-continue'`(Phase 1):记录普通 `loop/goal-concern` session event 后继续跑,人在结束时集中审阅。ACP 没有通用高优先级 marker,因此专用 concern 渲染与 ACP command 基础设施一起后置。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 轮的工作不丢。 +concern 本身就是普通 session event,跟前文的持久化 session 能力天然协同:后续 replay 可以从 concern 出现的轮次为新对话提供 seed,并替换 goal。这不会把工作区回滚到该轮。 -**滥用与丢失防护**。agent 可能每轮都 raise concern;缓解是 `severity` 字段和 driver 侧的最小 rate limit(同一 concern 30 秒内去重)。这种滥用的代价是 agent 卡住自己无法推进,动机不强。goal 被 revise 后原始 goal 会丢失;每次 revise 落 `loop/goal-revised` session event 带 rationale,resume 时可选任意历史 goal 版本。 +**滥用与丢失防护**。agent 可能每轮都 raise concern;缓解是 `severity` 字段和 driver 侧的最小 rate limit(同一 concern 30 秒内去重)。这种滥用的代价是 agent 卡住自己无法推进,动机不强。goal 被 revise 后原始 goal 会丢失;每次 revise 落 `loop/goal-revised` session event 带 rationale,后续 replay 可选任意历史 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 --stop --max-rounds N --max-usd X --handoff fresh`。人类主导启动,最典型的 Ralph 风格用法 +- **agent 侧 tool**:`loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` 通过 `ctx.tasks` 注册 `kind: 'loop'`,立即返回 task id,并在后台运行 harness loop。`task_output`、`task_list` 和 `task_kill` 负责收集与取消。正在跑的 loop 内部 agent 可用 `loop_flag_concern({ concern, severity })` 主动发起 concern。ACP 渲染意图为 `generic`。agent 自主发起就是 proactive 触发,无需额外机制 +- **CLI**:`dsh-sdk loop --stop --max-rounds N --max-usd X --handoff fresh`。人类主导启动,最典型的 Ralph 风格用法 - **cordis leaf**:`cordis.yml` 里以 leaf 形式声明常驻循环,配合未来的 `dsh-schedule` RFC 可做周期性触发 -- **ACP slash command**:`/loop `(还有 `/loop-flag-concern`)在编辑器/客户端的当前会话里直接启动。语义等价于人类在 CLI 里敲 `dsh loop`,但发生在正在进行的 ACP session 上下文中,允许 loop 结果直接注入会话 +- **ACP slash command**:`/loop `(还有 `/loop-flag-concern`)在编辑器/客户端的当前会话里直接启动。语义等价于人类在 CLI 里敲 `dsh-sdk 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 一起分发: +默认 system prompt 里有两条行为指令,随所有内置 `loop` tool 一起分发: 1. 不允许写 `TODO`、`FAKE`、`PLACEHOLDER` 占位符让 evaluator 表面通过 2. 不允许写空的 `try/except` 或 `catch(_)` 让 evaluator 忽略错误 -这两条不是 seam 层能拦的,是 prompt 层的约定。用户可以自定义 system prompt 但内置约束保留。 +这两条无法在 seam 层强制,只是 prompt 层 guidance,不能描述成硬约束。用户可以自定义 system prompt;需要强制这些规则的 evaluator 必须显式检查。 ### 与仓库现有代码的关系 直接复用无需修改: -- `packages/subagent` 的 `spawn` provider、`toolFilter`、`persona`——loop 每轮起 subagent、evaluator 只读工具集 +- `packages/subagent` 的 `spawn` provider、`toolFilter`、`persona`——loop 每轮起 subagent;LLM evaluator 获得受限的 model-facing tool set,不获得进程隔离保证 +- `packages/tasks`——model-facing loop 是 `loop` task producer,复用 owner isolation、`task_output`/`task_list`/`task_kill`、完成通知、取消和 awaited cleanup - `packages/session-persistence` 的 SQLite backend——loop-session 落盘 +- `packages/session-query`——精确读取 live 与已持久化 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-tool;Phase 2 再补。 +拟议中的 [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) 是现有 exact-read query service 之上的可选 Phase 2 发现能力增强,不是 Phase 1 event 访问的依赖。 + +Continuation 工作可以延后到 Phase 2:`SubagentRun.sendMessage` 与 `resume` 方法作为可选 seam capability 存在,但当前 `subagent-spawn` provider 明确不暴露这两个方法。因此,两个 `handoff-continue-*` backend 需要 provider 实现、capability check、ownership 测试和 consumer surface,不只是给 `packages/subagent-tool` 增加参数。Phase 1 只交付 `handoff-fresh-with-summary`,不改 subagent continuation。 ### 分阶段 -**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` tool;no-progress 启发式;`onGoalConcern: 'stop' | 'notify-continue'` 二档;CLI;tool;默认 system prompt 硬约束。**不含**:session-query 面、ACP slash command 触发面(依赖 `available_commands_update` 基础设施)、subagent-tool 续跑改动、stuck 检测器、Reflector subagent、`loop_split` tool、`rubric` 与 `contract` 档的内置实现。 +**Phase 1**(本 RFC 承诺范围):三包 seam;`StopCondition`;criteria/executor/isolation 三个正交维度的 `EvaluatorSpec`,其中 shell 与 LLM-judge execution 有内置实现,rubric/contract criteria shape 开放待接;Default-FAIL 强制;evaluator 与累计 budget backend;`handoff-fresh-with-summary`;`ctx.tasks` 集成;`loop_flag_concern` tool;no-progress 启发式;`onGoalConcern: 'stop' | 'notify-continue'` 二档;CLI;tool;默认 system prompt guidance。**不含**:SQLite FTS5 search 面、ACP slash command 触发面(依赖 `available_commands_update` 基础设施)、subagent continuation provider/tool 工作、`GoalReflector` service、stuck 检测器、Reflector subagent、`loop_split` tool,以及每种 rubric/contract 组合的内置 executor。 -**Phase 2**:query 面;stuck 检测器(复现 OpenHands 5 种模式);subagent-tool 续跑改动(解锁 continue 两档 handoff);Reflector subagent;`onGoalConcern: 'reflect'` 档;`loop_split` model-facing tool;`rubric` 与 `contract` 档的内置实现。 +**Phase 2**:SQLite FTS5 search 面;stuck 检测器(复现 OpenHands 5 种模式);subagent continuation provider 实现、capability check 与 consumer surface(解锁两个 continue handoff);`GoalReflector` service 与 Reflector subagent;`onGoalConcern: 'reflect'` 档;`loop_split` model-facing tool;更多 rubric/contract 组合的内置 executor。 **Phase 3**:agent fleet(同 goal 派 N 个并行 loop 取最优);与 `dsh-schedule` 集成;两容器 evaluator 隔离(evaluator 定义文件对主 agent 完全不可访问,防 reward 反向优化)。 @@ -274,13 +294,13 @@ ACP slash command 的依赖:`packages/ui/acp` 的 `available_commands_update` **不做 evaluator seam,内置几种够用**:更轻。拒绝——可插拔的 Evaluator 与 Budget 的核心价值是团队或私有 evaluator 可扩展。写死后长跑无人值守场景的用户只能改主库。 -**接受不带 `EvaluatorSpec` 档位的自由函数**:允许用户传任意 `(result) => boolean`。拒绝——档位强制用户在启动时明确「用哪一档强度判成败」,是防止不知不觉滑到弱档的关键。自由函数看起来灵活,实际让 evaluator 强度隐性下沉,长跑场景代价大。 +**接受不带显式 `EvaluatorSpec` 的自由函数**:允许用户传任意 `(result) => boolean`。拒绝——criteria/executor/isolation 维度强制用户在启动时声明评估什么、由什么执行判断、由什么边界保护,防止不知不觉滑到更弱的配置。自由函数看起来灵活,实际让 evaluator 强度隐性下沉,长跑场景代价大。 -**引入独立记忆引擎(Beads / dex-style)**:外部化状态的成熟做法。拒绝——`packages/session-persistence` + `sqlite-session-query-provider` 已能提供等效能力;新引擎收益远小于维护成本。 +**引入独立记忆引擎(Beads / dex-style)**:外部化状态的成熟做法。拒绝——`packages/session-persistence` 加现有 exact-read `ctx.sessionQuery` 已经覆盖 Phase 1 诊断,SQLite FTS5 后续可以补 search;新引擎收益远小于维护成本。 **goal reflection 塞进 Evaluator seam**(让 evaluator 返回「criteria 不可能满足」):拒绝——混淆「是否成功」和「目标是否正确」两个正交问题。`Evaluator` 应保持独立、只读、简单。 -**goal-concern 只做 event 不做 seam**:更轻。拒绝——响应策略族(stop / notify / reflect)明确,各团队会想插自己的,seam 化投资小于收益。 +**goal-concern 永远只做 event 不做 seam**:更轻。Phase 1 确实使用 event 加 `stop`/`notify` policy;作为最终设计仍拒绝,因为 Phase 2 的 `reflect` 路径需要可替换响应策略。seam 与首个调用方一起落地,不提前出现。 **Phase 1 就上完整 Reflector subagent**:更全。拒绝——`loop_flag_concern` tool + no-progress 启发式 + 二档 policy 覆盖 80% 场景;每轮跑独立 subagent 成本高,Phase 2 按需引入更合理。 @@ -290,31 +310,33 @@ ACP slash command 的依赖:`packages/ui/acp` 的 `available_commands_update` - `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 配对的 loop(fixture:`loop({ goal, evaluator: undefined })` 立即返回配置错误) +- Phase 1 的 `Evaluator`、`BudgetPolicy`、`RoundHandoff` 三条 service 都能被外部插件替换(fixture:注入 mock 实现,driver 正确调用);Phase 2 `reflect` consumer 出现前不注册 `GoalReflector` service +- `EvaluatorSpec` 的 criteria/executor/isolation 维度在编译期收敛;driver 拒绝启动没有 evaluator 配对的 loop(fixture:`loop({ goal, evaluator: undefined })` 立即返回配置错误) - Default-FAIL fixture:evaluator 报告返回 `{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.write,evaluator subagent 的 tool set 里没有;试图调 fs.write 被 registry 拒绝 -- protectedPaths fixture:`EvaluatorSpec.protectedPaths: ["tests/**"]` 声明后,主 agent 尝试写 `tests/foo.py` 被 `packages/fs` policy gate 拒绝并记 `loop/hack-attempt` session event,evaluator 侧读该路径正常 -- 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` 检索 +- `RoundHandoff` 接收上一轮 result、evaluator report、token usage、summary、session id、可选 run handle 和 cancellation signal;Phase 1 的 `fresh-with-summary` 有单元覆盖与一个 pass-path e2e,continuation backend 测试等待 Phase 2 provider 支持 +- `dsh-sdk loop` CLI e2e:给定 goal + 3 轮上限 + 一个 shell evaluator,通过与耗尽两条路径都返回结构化 stop cause 并 exit code 语义化 +- Evaluator scope fixture:主 agent 有 fs.write,LLM evaluator 的 model-facing tool set 没有;结果与文档仍把 `same-workspace` 标记为未隔离,不暴露 `protectedPaths` 保证 +- Budget fixture 覆盖 `perRoundUsd` 准入,以及跨 worker 与 evaluator usage 累计的 `maxRounds`、`maxTokens`、`maxUsd`;在途超限 emit 带 `observed` 与 `maximum` 的 `budget-cap` +- Goal concern fixture:`loop_flag_concern` 可从主 agent 调用并产出普通 `loop/goal-concern` session event;`onGoalConcern: 'stop'` 下 emit `approval-required` StopCondition;`'notify-continue'` 下继续跑且不携带不存在的 ACP priority metadata;no-progress 启发式在预算超 50% 且零 pass 时自动触发一次(rate-limit 去重) +- 默认 system prompt guidance(无 TODO/FAKE/PLACEHOLDER、无空 catch)随内置 `loop` tool 一起分发,snapshot 覆盖 prompt 内容但不把它当作强制机制 +- 每轮的 prompt、inner-loop 结果、evaluator report、stop 决策都以 session event 出现,并可通过现有 exact-read `ctx.sessionQuery` 读取;FTS5 search 留在 Phase 2 +- model-facing loop 启动后立即返回 `loop` task id;`task_output`、`task_list`、`task_kill`、父 agent dispose、取消、producer reload 和 service dispose 覆盖 owner isolation 与 awaited quiescence - `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 面。 +**对话 replay 不是工作区恢复**。精确 session 读取已经存在,FTS5 改善历史发现能力,不决定正确性。基于当前工作区 replay 某一轮 prefix 可以诊断或改变运行方向,但复现该轮执行世界需要 Git/worktree/checkpoint 支持,以及针对外部副作用的显式 policy。 **`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 走的就是这条路。 +**evaluator 反向优化(reward hacking)**。足够长的 loop 里 agent 有条件识别 evaluator 的模式并针对性优化,例如发现「只要测试文件里出现 `assert True` 就 PASS」从而绕过实质完成。Phase 1 的 `same-workspace` 模式不能阻止 agent 通过 bash、代码运行时或其他写入通道修改测试或 evaluator 配置;当前 `packages/fs` policy 不是路径隔离边界。需要对抗强度的用户必须选择隔离 worktree、只读 mount、容器或远程 evaluator。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 层能根治的。 +**占位符伪造与过度防御码**。agent 有时会写 `# TODO: implement` 让测试勉强通过,或写大量 `try/except: pass` 让 evaluator 表面 PASS。这些不属于 evaluator 层的问题,而是 agent 生成阶段的 prompt 与训练问题。用户面 段的两条默认 system prompt 指令只是 guidance;用户在自定义 evaluator 中加入「静态检查禁止 TODO 与空 catch」才能获得可强制覆盖。这类问题不是 seam 层能根治的。 -**预算估算漂移**。pricing 表是常量,模型调价后估算会飘。护栏保守方向的近似不算 bug,但 README 说明「真实计费以 usage 事件为准,preflight 仅保护单轮爆炸」。 +**预算估算漂移与在途超限**。pricing 可能变化,累计 token/USD usage 只能在每次 worker、evaluator、compaction 或 reflector 请求报告 usage 后精确。preflight 保护单轮;累计上限会停止下一个请求,但可能被一个在途请求超出。README 同时报告 observed 与 maximum,并说明 provider 账单才是权威。 + +**后台 task 只存在于当前进程**。`ctx.tasks` 为 model-facing loop 提供 owner isolation、通用收集/取消、完成通知和 awaited cleanup。父 agent 或 service dispose 会取消并等待 loop;进程 crash 无法执行 cleanup,持久重启不在 Phase 1 范围内。 **长跑 loop 日志膨胀**。跑 100 轮 loop 单 session 上 MB 级。`logDetail: 'summary'` 兜底但 Phase 1 默认 `full`,Phase 2 再补 summary 语义。 From a525776015c10c1a5c25c3cc625bb4a55f4f811a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:47:34 +0800 Subject: [PATCH 03/10] feat(goal): add persisted same-session goal domain --- docs/architecture.md | 10 +- docs/capability-seams.md | 4 + docs/config-catalog.md | 14 + docs/cordis-catalog/events.md | 23 + docs/cordis-catalog/services.md | 100 +++ docs/core-data-structures/core.md | 1 + docs/core-data-structures/goal.md | 139 ++++ docs/event-producer-consumer.md | 3 +- docs/glossary.md | 12 + docs/module-graph.md | 9 + docs/rfc/INDEX.md | 1 + ...rsisted-same-session-goal-domain.i18n.yaml | 6 + ...7-19-persisted-same-session-goal-domain.md | 62 ++ ...9-persisted-same-session-goal-domain.zh.md | 62 ++ .../tests/fixtures/goal/goal/cordis.yml | 24 + .../tests/fixtures/goal/goal/seed-goal.ts | 16 + examples/package.json | 1 + knip.json | 5 + packages/README.md | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 93 +++ packages/goal/README.md | 9 + packages/goal/goal/README.md | 45 ++ packages/goal/goal/package.json | 44 + packages/goal/goal/src/fold.ts | 379 +++++++++ packages/goal/goal/src/index.ts | 505 ++++++++++++ packages/goal/goal/src/render.ts | 21 + packages/goal/goal/src/runtime.ts | 27 + packages/goal/goal/src/types.ts | 168 ++++ packages/goal/goal/tests/goal.e2e.ts | 126 +++ packages/goal/goal/tests/goal.spec.ts | 749 ++++++++++++++++++ packages/goal/goal/tsconfig.json | 36 + packages/support/invariants/package.json | 1 + .../invariants/src/scoped-events.generated.ts | 2 + .../invariants/tests/invariants.spec.ts | 1 + packages/support/invariants/tsconfig.json | 3 + pnpm-lock.yaml | 34 + scripts/gen-cordis-catalog.ts | 6 + scripts/gen-doc-graphs.ts | 8 + scripts/gen-module-graph.ts | 1 + scripts/type-equiv.manifest.json | 12 + tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + website/.vitepress/config/api-sidebar.json | 4 + website/zh-CN/api/harness/events.md | 28 +- website/zh-CN/api/harness/goals.md | 241 ++++++ 46 files changed, 3033 insertions(+), 6 deletions(-) create mode 100644 docs/core-data-structures/goal.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md create mode 100644 examples/echo-agent/tests/fixtures/goal/goal/cordis.yml create mode 100644 examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts create mode 100644 packages/goal/README.md create mode 100644 packages/goal/goal/README.md create mode 100644 packages/goal/goal/package.json create mode 100644 packages/goal/goal/src/fold.ts create mode 100644 packages/goal/goal/src/index.ts create mode 100644 packages/goal/goal/src/render.ts create mode 100644 packages/goal/goal/src/runtime.ts create mode 100644 packages/goal/goal/src/types.ts create mode 100644 packages/goal/goal/tests/goal.e2e.ts create mode 100644 packages/goal/goal/tests/goal.spec.ts create mode 100644 packages/goal/goal/tsconfig.json create mode 100644 website/zh-CN/api/harness/goals.md diff --git a/docs/architecture.md b/docs/architecture.md index fd21648dd2..2c6cd9c9ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeepSeek Harness Architecture -The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is simple: **everything is a plugin**. The shipped loop is one plugin, not a privileged kernel. +The **DeepSeek Harness SDK** builds on Cordis: **everything is a plugin**, including the shipped loop. ## Overview @@ -35,6 +35,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | +| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus exact reads and relationship traces | @@ -54,9 +55,9 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains work from prompt through checkpoint. Every pause is a service call or event available to plugins. +The shipped loop drains prompts through checkpoints; every pause is exposed to plugins through services or events. -A **session** is an append-only event log. A **turn** drains queued input until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +A **turn** drains queued input in an append-only session until the model requests no more tools or plugin continuation. A **step** is one model request plus its tool executions. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. @@ -108,7 +109,7 @@ forever: Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts. +Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through turn close and flush, and discards later steering while preserving queued prompts. `dsh-compact-basic` handles pressure and canonical overflow at these checkpoints; retry requires a balanced surface replacement ([RFC](rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). @@ -174,6 +175,7 @@ New behavior should attach to a documented extension point; changing the shipped | Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | +| Manage a same-session objective | call `ctx.goals`; drive continuation through `Agent` and `agent/*` seams | | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | | Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 58641456b1..64ddcc40c5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -52,6 +52,8 @@ flowchart LR svc_agents["ctx.agents
Agent service"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] + pkg_goal["goal"] + svc_goals["ctx.goals
Same-session goal domain"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] pkg_bash_local["bash-local"] @@ -109,6 +111,7 @@ flowchart LR pkg_compact_basic --> svc_compact pkg_fs --> svc_fs pkg_fs_local --> svc_fs + pkg_goal --> svc_goals pkg_llm --> svc_llm pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm @@ -217,6 +220,7 @@ flowchart LR | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | +| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 225098e851..6473848ab3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -318,6 +318,20 @@ export interface Config { Source: [`packages/fs/fs-local/src/index.ts:38`](../packages/fs/fs-local/src/index.ts) +## `@deepseek-ai/dsh-goal` + +Requires: `agents` + +```ts config-catalog +/** Deployment defaults for goal creation. */ +export interface Config { + /** Total rounds used when a create request omits its own cap. */ + defaultMaxGoalRounds?: number +} +``` + +Source: [`packages/goal/goal/src/index.ts:56`](../packages/goal/goal/src/index.ts) + ## `@deepseek-ai/dsh-hooks-claude` Requires: `bash` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 7d7472a33e..0f5a4650cd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -450,6 +450,29 @@ Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../c Source: [`packages/fs/fs/src/index.ts:53`](../../packages/fs/fs/src/index.ts) +## `goal/*` + +### `goal/changed` — emit + +Goal mutation accepted by one live agent. The matching context event is already appended or queued in that agent's active tool-batch FIFO. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + +```ts cordis-catalog +/** + * Goal mutation accepted by one live agent. The matching context event is + * already appended or queued in that agent's active tool-batch FIFO. + * Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - agent whose session owns the goal. + * @param change - fresh current projection or clear tombstone. + * @mode emit + */ +'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void +``` + +Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/goal/goal/src/types.ts:166`](../../packages/goal/goal/src/types.ts) + ## `llm/*` ### `llm/stream` — waterfall diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..934086dd97 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -479,6 +479,106 @@ Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](.. Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) +## `ctx.goals` — `GoalService` + +Goal service (`ctx.goals`) backed exclusively by the owning session log. + +```ts cordis-catalog +/** + * Materialize deployment defaults and validate one create request. + * @param request - objective plus optional caller-selected round cap. + * @returns detached, fully resolved create specification. + */ +resolveCreate(request: CreateGoalRequest): CreateGoalSpec + +/** + * Read the current goal for one exact live agent. + * @param agent - owning live agent. + * @returns a fresh view or `undefined` when no goal is current. + * @throws {@link GoalError} when the agent is not the registry's live instance. + */ +get(agent: Agent): GoalView | undefined + +/** + * Create and arm a goal. A completed goal may be replaced; every other + * current phase must be cleared or resumed instead. + * @param agent - owning live agent. + * @param request - objective and optional round cap. + * @returns the created live view. + */ +create(agent: Agent, request: CreateGoalRequest): GoalView + +/** + * Edit objective and/or round cap without changing phase. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @param request - at least one replacement field. + * @returns the edited view. + */ +edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView + +/** + * Pause an active goal and disarm automatic continuation. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the paused view. + */ +pause(agent: Agent, ref: GoalRef): GoalView + +/** + * Resume and arm a stopped goal, or rearm an active goal after a + * session-start edge, while its round budget still has capacity. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the active view. + */ +resume(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark a current non-complete goal complete and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the completed view. + */ +complete(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark an active goal blocked and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the blocked view. + */ +block(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark an active goal stopped by an external usage limit. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the usage-limited view. + */ +markUsageLimited(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark an active goal stopped at its configured round cap. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the budget-limited view. + */ +markBudgetLimited(agent: Agent, ref: GoalRef): GoalView + +/** + * Clear the current goal while retaining a durable tombstone and history. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the tombstone ref whose revision is one past the cleared snapshot. + */ +clear(agent: Agent, ref: GoalRef): GoalRef +``` + +Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalSpec](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) + +Source: [`packages/goal/goal/src/index.ts:97`](../../packages/goal/goal/src/index.ts) + ## `ctx.llm` — `LlmService` The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1d45f3814a..25ded90954 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -18,6 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | +| [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces | diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md new file mode 100644 index 0000000000..76a1d0ed71 --- /dev/null +++ b/docs/core-data-structures/goal.md @@ -0,0 +1,139 @@ +# Same-session goals + +Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain RFC](../rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). + +## Identity and lifecycle + +`GoalId` is a [branded id](core.md#branded-ids). A caller mutates one exact revision through `GoalRef`; every accepted durable mutation increments the revision. + +```ts type-equiv +/** Compare-and-set identity for one exact goal revision. */ +interface GoalRef { + /** Stable goal identity. */ + readonly id: GoalId + /** Positive revision; every durable mutation increments it. */ + readonly revision: number +} +``` + +The durable phase answers what happened to the objective. Process-local activation separately answers whether a continuation consumer may start another round. + +```ts type-equiv +/** Durable continuation phase. Activation is process-local and separate. */ +type GoalPhase = + | 'active' + | 'paused' + | 'blocked' + | 'usage-limited' + | 'budget-limited' + | 'complete' +``` + +```ts type-equiv +/** Full durable state written by every non-clear goal mutation. */ +interface GoalSnapshot extends GoalRef { + /** Human-requested completion objective. */ + readonly objective: string + /** Durable lifecycle phase. */ + readonly phase: GoalPhase + /** Total admitted goal-round cap. */ + readonly maxGoalRounds: number +} +``` + +```ts type-equiv +/** Current goal projection, including values derived from the session log. */ +interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} +``` + +## Durable changes + +Every mutation is a `context/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, raw envelope, and rendered content form one replay invariant. + +```ts type-equiv +/** Full-snapshot goal mutation retained in a model-visible context event. */ +interface GoalSnapshotChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: Exclude + readonly goal: GoalSnapshot + readonly roundsStarted: number + readonly createdAt: number + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Tombstone retained when the current goal is cleared. */ +interface GoalClearChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: 'clear' + readonly cleared: GoalRef + readonly clearedAt: number +} +``` + +Goal state changes use round `0`. A continuation consumer attributes each admitted user-message turn with a positive, sequential round number and the current revision; replay rejects gaps, stale revisions, stopped phases, and cap overflow. + +```ts type-equiv +/** Message attribution for durable goal state and continuation rounds. */ +interface GoalMessageSource { + readonly kind: 'goal' + readonly goalId: GoalId + readonly revision: number + /** Zero for state changes; positive for admitted continuation rounds. */ + readonly round: number +} +``` + +## Requests and notifications + +Creation separates caller omission from the resolved deployment choice. An edit is a partial replacement whose runtime validator requires at least one field. Every mutation notification carries the accepted operation and exact revision; clear omits `goal`. + +```ts type-equiv +/** Input whose omitted round cap is resolved by the service configuration. */ +interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} +``` + +```ts type-equiv +/** Validated create input with every deployment default materialized. */ +interface CreateGoalSpec { + readonly objective: string + readonly maxGoalRounds: number +} +``` + +```ts type-equiv +/** Fields changed by an edit; at least one must be present. */ +interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} +``` + +```ts type-equiv +/** Live notification after one goal mutation has been accepted for logging. */ +interface GoalChanged { + readonly operation: GoalOperation + readonly ref: GoalRef + /** Absent for a clear tombstone. */ + readonly goal?: GoalView +} +``` + +## Service behavior + +[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay, enforces exact-live-agent identity and compare-and-set mutations, overlays deferred injections, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..8f1fd333fe 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | @@ -27,6 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:166`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/glossary.md b/docs/glossary.md index 81b9b4f84a..74dc5e14d5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -15,3 +15,15 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **restriction / scope-local registration** — a restriction (`tools.restrict`) filters the GLOBAL tool surface for one scope (compose by intersection); scope-local registrations are merged after that filter. A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one. - **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope and agent object exist but before the agent or session is published, `agent/session-start` fires, or the first prompt is assembled. Setup registers; it never drives the agent. - **lineage** — parent/child facts carried as data (`parentSession`, `subagentDepth`); never affects visibility. + +## goal + +- **goal** — one durable completion objective attached to an existing session, with a revisioned lifecycle phase and a goal-round cap. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. +- **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. +- **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work. + +## loop hierarchy + +- **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. +- **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. +- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. diff --git a/docs/module-graph.md b/docs/module-graph.md index 0886877f0e..56934f8495 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -28,6 +28,9 @@ flowchart TD pkg_system_prompt["system-prompt"] pkg_tools["tools"] end + subgraph group_goal["packages/goal"] + pkg_goal["goal"] + end subgraph group_bash["packages/bash"] pkg_bash["bash"] pkg_bash_local["bash-local"] @@ -194,6 +197,11 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_goal --> pkg_agent + pkg_goal --> pkg_brand + pkg_goal --> pkg_llm + pkg_goal --> pkg_scope + pkg_goal --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout pkg_compact_basic --> pkg_agent @@ -485,6 +493,7 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index bca5cf61f9..e6d4ef306b 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -89,6 +89,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | +| [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml new file mode 100644 index 0000000000..771afcf5bf --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -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-19-persisted-same-session-goal-domain.md: ebf6168a4d8552d40e22db309f953f6f273511a9 +2026-07-19-persisted-same-session-goal-domain.zh.md: 5fd35ee8faffc1c53d257bee48f36d70838fdee1 diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md new file mode 100644 index 0000000000..ebf6168a4d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -0,0 +1,62 @@ +# RFC: Persisted same-session goal domain + +Status: implemented + +English | [中文](2026-07-19-persisted-same-session-goal-domain.zh.md) + +## Problem + +A long-running objective outlives one prompt, turn, or model request. Treating that objective as an in-memory loop variable loses it on process restart, while putting it only in UI state makes model behavior impossible to reconstruct. Treating every session turn as progress also charges unrelated human messages against an automatic-work budget. + +Durable lifecycle and permission to continue are different facts. A session may retain an active objective after restart or fork, but silently starting work when a user opens that session is surprising. The domain needs replayable state without persisted auto-execution authority, and it must remain a plugin on the public agent/session seams rather than a special case in the concrete loop. + +## Decision + +`@deepseek-ai/dsh-goal` in `packages/goal/goal/` owns one current same-session goal through `ctx.goals`. A goal has a branded id, objective, durable phase, compare-and-set revision, and `maxGoalRounds`. `defaultMaxGoalRounds` is a validated deployment setting with default `256`; `resolveCreate()` materializes it before mutation. + +The durable phases are `active`, `paused`, `blocked`, `usage-limited`, `budget-limited`, and `complete`. A separate live activation is `armed` or `disarmed`. Creation and explicit resume arm activation; pause, completion, blocking, limit transitions, and clear disarm it. Edits preserve activation. Activation is never part of the persisted snapshot. + +### Durable record and replay + +Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `context/message` containing a versioned full snapshot. Clear appends a revisioned tombstone. The context source is `{ kind: 'goal', goalId, revision, round: 0 }`; metadata and rendered `...` content must agree exactly. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. + +The replay fold validates JSON shape, source attribution, rendered content, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds are positive sequential `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. + +When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; the durable log remains authoritative after restart. + +### Lifecycle and live activation + +At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a stopped phase or a disarmed active goal only when the round cap has remaining capacity; budget limiting requires the admitted count to have reached the cap. + +A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. Resume and fork therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. + +### Service boundary + +The service accepts only the exact live `Agent` object registered under its id. Successful mutation injection emits the scoped `goal/changed` event with contained listener failures. Policy consumers use this service plus the public `Agent` interface and `agent/*` events; the goal domain does not import or modify `dsh-agent-loop`. + +## Testing + +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. + +## Alternatives considered + +- **Store goals in a separate database or session header** — rejected because the session log already supplies ordering, persistence, fork prefixes, and reconstructability; a second store introduces atomicity and lineage questions. +- **Use hidden log-only events** — rejected because durable state that changes future model behavior must be model-visible and reconstructable under the repository's logging invariant. +- **Persist activation and restart automatically** — rejected because opening or resuming a session must wait for human input; durable phase records status, not fresh authority to spend resources. +- **Count all session turns as goal rounds** — rejected because one session can contain human clarification, inspection, and unrelated work; only goal-attributed continuation turns consume this budget. +- **Add goal state or a generic loop abstraction to `dsh-agent-loop`** — rejected because state and continuation policy can compose through existing plugins, `Agent` verbs, and events without privileging the shipped loop implementation. + +## Consequences + +- Goal history survives persistence, resume, compaction of unrelated nodes, and session fork as ordinary session data. +- Resume and fork expose the same durable phase while remaining operationally inert until an explicit resume mutation arms activation. +- Full snapshots simplify inspection and strict replay but repeat the objective and state fields in model history until compaction shadows them. +- Revision and lifecycle validation reject tampered, partially written, or producer-inconsistent goal records early. +- Round caps bound continuation count only; token, currency, time, and provider limits remain separate policy concerns. + +## Known limitations and deferred work + +- This domain records state but does not schedule goal rounds, cancel active turns, or classify abnormal stops. +- The actor that records `complete` or `blocked` is authoritative; an independent evaluator or completion certificate is deferred to a policy consumer. +- There is one current goal per session; parallel objective graphs and cross-session goal storage are absent. +- `GOAL_CHANGE_VERSION` has no pre-release compatibility promise or migration path. diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md new file mode 100644 index 0000000000..5fd35ee8fa --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -0,0 +1,62 @@ +# RFC: 持久的同会话目标领域 + +Status: implemented + +[English](2026-07-19-persisted-same-session-goal-domain.md) | 中文 + +## 问题 + +长时间运行的目标会跨越单个提示词、轮次或模型请求。若把该目标视为内存中的循环变量,进程重启时就会丢失;若只存放在 UI 状态中,又无法重建模型行为。若把会话中的每个轮次都视为目标进度,与自动工作无关的人类消息也会消耗预算。 + +持久生命周期与继续执行的权限是两个不同事实。会话在重启或 fork(派生)后可以保留活跃目标,但用户打开会话时静默启动工作并不符合直觉。该领域需要可回放的状态,却不能持久化自动执行权限;它还必须作为公共 agent(智能体)与会话接缝上的插件存在,而不是具体循环中的特例。 + +## 决策 + +位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含品牌化 id、目标描述、持久阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`resolveCreate()` 在变更前将其解析为完整值。 + +持久阶段包括 `active`、`paused`、`blocked`、`usage-limited`、`budget-limited` 和 `complete`。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞、达到限制和清除都会解除激活。编辑保留激活态。持久快照绝不包含激活态。 + +### 持久记录与回放 + +每次非清除变更都通过 `Agent.inject()` 追加一条原始且模型可见的 `context/message`,其中包含带版本的完整快照。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `...` 内容完全一致。会话日志是唯一的持久事实来源,因此持久化和 fork 会继承目标记录,而无需另设数据库或头字段。 + +回放折叠会校验 JSON 形状、来源归属、渲染内容、新 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 + +当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重启后仍以持久日志为准。 + +### 生命周期与实时激活态 + +最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,停止阶段或已解除激活的活跃目标才能恢复;只有已接纳回合数达到上限后,才能标记预算受限。 + +从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。因此,恢复和 fork 会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 + +### 服务边界 + +服务只接受在对应 id 下注册的同一个实时 `Agent` 对象。成功注入变更后,它会发出带作用域的 `goal/changed` 事件,并隔离监听器失败。策略消费者通过本服务、公共 `Agent` 接口和 `agent/*` 事件工作;目标领域既不导入也不修改 `dsh-agent-loop`。 + +## 测试 + +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 + +## 考虑过的替代方案 + +- **把目标存入独立数据库或会话头**——不予采纳,因为会话日志已经提供顺序、持久化、fork 前缀与可重建性;第二份存储会引入原子性和谱系问题。 +- **使用模型不可见的纯日志事件**——不予采纳,因为会改变后续模型行为的持久状态必须满足仓库日志不变量,保持模型可见且可重建。 +- **持久化激活态并自动重启**——不予采纳,因为打开或恢复会话时必须等待人类输入;持久阶段记录状态,而不是再次消耗资源的授权。 +- **把所有会话轮次都计为目标回合**——不予采纳,因为同一会话可以包含人类澄清、检查和无关工作;只有归属于目标的继续执行轮次才消耗该预算。 +- **向 `dsh-agent-loop` 添加目标状态或通用循环抽象**——不予采纳,因为状态与继续执行策略可以通过现有插件、`Agent` 动词和事件组合,而无需赋予默认循环实现特权。 + +## 后果 + +- 目标历史作为普通会话数据,在持久化、恢复、无关节点压缩和会话 fork 后继续保留。 +- 恢复与 fork 会暴露同一持久阶段,但在显式恢复变更激活目标前不会执行任何操作。 +- 完整快照便于检查和严格回放,但在压缩隐藏它们之前,会在模型历史中重复目标描述与状态字段。 +- 修订号与生命周期校验会尽早拒绝遭篡改、部分写入或生产者不一致的目标记录。 +- 回合上限只约束继续执行次数;token、费用、时间和提供方限制仍属于独立策略。 + +## 已知限制与延期工作 + +- 本领域记录状态,但不调度目标回合、不取消活跃轮次,也不分类异常停止。 +- 记录 `complete` 或 `blocked` 的参与者具有最终权威;独立评估器或完成证书延期到策略消费者中实现。 +- 每个会话只有一个当前目标;不存在并行目标图和跨会话目标存储。 +- `GOAL_CHANGE_VERSION` 在首次发布前不承诺兼容性,也不提供迁移路径。 diff --git a/examples/echo-agent/tests/fixtures/goal/goal/cordis.yml b/examples/echo-agent/tests/fixtures/goal/goal/cordis.yml new file mode 100644 index 0000000000..9d9e644cfa --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal/cordis.yml @@ -0,0 +1,24 @@ +# Test-only composition: create one goal through a Loader-mounted lifecycle consumer. +- id: mock-llm + name: '../../../../src/mock-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: goal + name: '@deepseek-ai/dsh-goal' + config: + defaultMaxGoalRounds: 11 + +- id: seed-goal + name: './seed-goal.ts' + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: mock + model: mock-echo + persona: 'Test the persisted goal domain.' + welcome: 'goal-domain e2e ready.' + persistenceRoot: './.sessions' + workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts b/examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts new file mode 100644 index 0000000000..ae3d0d231c --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts @@ -0,0 +1,16 @@ +/** Test-only Loader plugin that creates a goal at the real session-start edge. */ + +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-goal' + +export const name = 'seed-goal' +export const inject = ['goals'] + +export function apply(ctx: Context): void { + ctx.on('agent/session-start', (agent) => { + ctx.goals.create(agent, { + objective: 'Prove the composed goal survives in the session log', + maxGoalRounds: 7, + }) + }) +} diff --git a/examples/package.json b/examples/package.json index 76349b2ea4..3a3815582a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -15,6 +15,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", + "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/knip.json b/knip.json index 2c3465ab38..6f04363494 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "examples": { "entry": [ "echo-agent/src/*.ts", + "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", @@ -66,6 +67,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/goal/goal": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/code-runtime/code-runtime-worker": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/README.md b/packages/README.md index 61a1e994c8..f2298a47fa 100644 --- a/packages/README.md +++ b/packages/README.md @@ -9,6 +9,7 @@ Packages live at `packages///`; groups are containers, while names r | Group | Role | Release expectation | |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | +| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 52f8477318..9262046683 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -250,6 +250,56 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'goals', + summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.', + methods: [ + { + signature: 'resolveCreate(request: CreateGoalRequest): CreateGoalSpec', + jsDoc: '/**\n * Materialize deployment defaults and validate one create request.\n * @param request - objective plus optional caller-selected round cap.\n * @returns detached, fully resolved create specification.\n */', + }, + { + signature: 'get(agent: Agent): GoalView | undefined', + jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */', + }, + { + signature: 'create(agent: Agent, request: CreateGoalRequest): GoalView', + jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */', + }, + { + signature: 'edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', + jsDoc: '/**\n * Edit objective and/or round cap without changing phase.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param request - at least one replacement field.\n * @returns the edited view.\n */', + }, + { + signature: 'pause(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Pause an active goal and disarm automatic continuation.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the paused view.\n */', + }, + { + signature: 'resume(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Resume and arm a stopped goal, or rearm an active goal after a\n * session-start edge, while its round budget still has capacity.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the active view.\n */', + }, + { + signature: 'complete(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */', + }, + { + signature: 'block(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the blocked view.\n */', + }, + { + signature: 'markUsageLimited(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark an active goal stopped by an external usage limit.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the usage-limited view.\n */', + }, + { + signature: 'markBudgetLimited(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark an active goal stopped at its configured round cap.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the budget-limited view.\n */', + }, + { + signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', + jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */', + }, + ], + }, { key: 'llm', summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.', @@ -746,6 +796,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Single-slot decision for the next {@link FileSystem.writeText}. Calling\n * `next()` yields the bare provider\'s unconditional write; the first listener\n * that returns an intent owns the decision rather than composing with peers.\n * @param target - the resolved target about to be written.\n * @param actor - the opaque tool-execution context the decider keys off.\n * @mode waterfall\n */', summary: 'Single-slot decision for the next FileSystem.writeText.', }, + { + name: 'goal/changed', + mode: 'emit', + signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped, agent: Agent, change: GoalChanged): void', + jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching context event is\n * already appended or queued in that agent\'s active tool-batch FIFO.\n * Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', + summary: 'Goal mutation accepted by one live agent.', + }, { name: 'llm/stream', mode: 'waterfall', @@ -1076,6 +1133,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateAgentOptions', declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, + { + name: 'CreateGoalRequest', + declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}', + }, + { + name: 'CreateGoalSpec', + declaration: 'export interface CreateGoalSpec {\n readonly objective: string;\n readonly maxGoalRounds: number;\n}', + }, { name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}', @@ -1096,6 +1161,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DshEnvironmentKey', declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;', }, + { + name: 'EditGoalRequest', + declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', + }, { name: 'EpochHeader', declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n}', @@ -1168,6 +1237,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GenericResultView', declaration: 'export interface GenericResultView {\n card: \'generic\';\n title?: string;\n content?: ContentBlock[];\n}', }, + { + name: 'GoalActivation', + declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';', + }, + { + name: 'GoalId', + declaration: 'export type GoalId = Branded<\'GoalId\'>;', + }, + { + name: 'GoalPhase', + declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'usage-limited\' | \'budget-limited\' | \'complete\';', + }, + { + name: 'GoalRef', + declaration: 'export interface GoalRef {\n readonly id: GoalId;\n readonly revision: number;\n}', + }, + { + name: 'GoalSnapshot', + declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly maxGoalRounds: number;\n}', + }, + { + name: 'GoalView', + declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}', + }, { name: 'HookContext', declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}', diff --git a/packages/goal/README.md b/packages/goal/README.md new file mode 100644 index 0000000000..45f1b4f135 --- /dev/null +++ b/packages/goal/README.md @@ -0,0 +1,9 @@ +# goal/ — persisted same-session goals + +The goal family owns durable objective state independently of the model-facing tools and continuation policy that consume it. + +| Package | Role | ctx key | +|---|---|---| +| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` | + +Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams. diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md new file mode 100644 index 0000000000..57f366c844 --- /dev/null +++ b/packages/goal/goal/README.md @@ -0,0 +1,45 @@ +# @deepseek-ai/dsh-goal + +Event-sourced same-session goal state. The service retains one current completion objective in an agent's existing session while keeping permission to continue as process-local activation. The [goal-domain RFC](../../../docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the design rationale; the [goal type catalog](../../../docs/core-data-structures/goal.md) records the literal data shapes. + +## Config + +```yaml +- id: goal + name: '@deepseek-ai/dsh-goal' + config: + defaultMaxGoalRounds: 256 +``` + +`defaultMaxGoalRounds` must be a positive safe integer. `resolveCreate()` materializes this deployment default before `create()` commits a goal; a request-level value overrides it. + +## Service contract + +`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). + +At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; an active armed goal rejects the redundant operation. + +Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. + +Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. + +Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. + +## Extension points + +Policy plugins call the service verbs and react to the scoped `goal/changed` event. A continuation consumer admits rounds as `user/message` events with `GoalMessageSource`; ordinary human turns never increment `roundsStarted`. Consumers use the `Agent` interface and events rather than importing `dsh-agent-loop`. + +## Model Experience + +### Goal-state mutation + +**What the model sees**: Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. + +**Token effect**: Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields. + +## Known Limitations and Deferred Work + +- **State, not scheduling** — this package does not decide when an armed goal continues, retry abnormal failures, or cancel an active turn; those policies belong to agent-seam consumers. +- **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas. +- **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer. +- **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear. diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json new file mode 100644 index 0000000000..534c258c6b --- /dev/null +++ b/packages/goal/goal/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-goal", + "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.17.2" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts new file mode 100644 index 0000000000..d7256a38db --- /dev/null +++ b/packages/goal/goal/src/fold.ts @@ -0,0 +1,379 @@ +/** Pure replay fold and strict decoder for durable goal changes. */ + +import type { MessageSource } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { renderGoalChange } from './render.ts' +import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts' +import type { + FoldedGoal, + GoalChangeMeta, + GoalClearChangeMeta, + GoalMessageSource, + GoalOperation, + GoalPhase, + GoalRef, + GoalSnapshot, + GoalSnapshotChangeMeta, +} from './types.ts' + +type ContextMessageEvent = Extract + +const SNAPSHOT_OPERATIONS: ReadonlySet> = new Set([ + 'create', + 'edit', + 'pause', + 'resume', + 'complete', + 'block', + 'mark-usage-limited', + 'mark-budget-limited', +]) +const PHASES: ReadonlySet = new Set([ + 'active', + 'paused', + 'blocked', + 'usage-limited', + 'budget-limited', + 'complete', +]) + +/** Mutable accumulator kept private to the pure fold. */ +export interface GoalFoldState { + goal: GoalSnapshot | undefined + roundsStarted: number + createdAt: number | undefined + updatedAt: number | undefined + lastRef: GoalRef | undefined + seenGoalIds: Set +} + +/** + * Build an empty replay accumulator. + * @returns mutable state with no current goal or prior ref. + */ +export function emptyGoalFoldState(): GoalFoldState { + return { + goal: undefined, + roundsStarted: 0, + createdAt: undefined, + updatedAt: undefined, + lastRef: undefined, + seenGoalIds: new Set(), + } +} + +/** Whether a value is a JSON record rather than an array. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Require one positive safe integer. */ +function positiveInteger(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`goal change ${field} must be a positive safe integer`) + } + return value +} + +/** Require one non-negative safe integer. */ +function nonNegativeInteger(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`goal change ${field} must be a non-negative safe integer`) + } + return value +} + +/** Decode and validate one snapshot. */ +function decodeSnapshot(value: unknown): GoalSnapshot { + if (!isRecord(value)) throw new Error('goal change goal must be a record') + const keys = Object.keys(value).sort() + if (keys.join(',') !== 'id,maxGoalRounds,objective,phase,revision') { + throw new Error('goal change goal has an invalid shape') + } + if (typeof value['id'] !== 'string' || value['id'].length === 0) { + throw new Error('goal change goal.id must be a non-empty string') + } + if (typeof value['objective'] !== 'string' || value['objective'].trim().length === 0 + || value['objective'] !== value['objective'].trim()) { + throw new Error('goal change goal.objective must be non-empty and normalized') + } + if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) { + throw new Error('goal change goal.phase is invalid') + } + return { + id: GoalId(value['id']), + revision: positiveInteger(value['revision'], 'goal.revision'), + objective: value['objective'], + phase: value['phase'] as GoalPhase, + maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'), + } +} + +/** Decode and validate one ref. */ +function decodeRef(value: unknown): GoalRef { + if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') { + throw new Error('goal clear tombstone has an invalid shape') + } + if (typeof value['id'] !== 'string' || value['id'].length === 0) { + throw new Error('goal clear tombstone id must be a non-empty string') + } + return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'cleared.revision') } +} + +/** + * Decode metadata that declares itself as a goal change. Unrelated metadata + * returns `undefined`; malformed goal metadata fails replay loudly. + * @param value - context-message metadata. + * @returns validated goal change or `undefined` for another metadata kind. + */ +export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined { + if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined + if (value['version'] !== GOAL_CHANGE_VERSION) { + throw new Error(`unsupported goal change version ${String(value['version'])}`) + } + if (value['operation'] === 'clear') { + const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version'] + if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { + throw new Error('goal clear change has an invalid shape') + } + return { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation: 'clear', + cleared: decodeRef(value['cleared']), + clearedAt: nonNegativeInteger(value['clearedAt'], 'clearedAt'), + } satisfies GoalClearChangeMeta + } + if (typeof value['operation'] !== 'string' + || !SNAPSHOT_OPERATIONS.has(value['operation'] as Exclude)) { + throw new Error('goal change operation is invalid') + } + const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version'] + if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { + throw new Error('goal snapshot change has an invalid shape') + } + const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt') + const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt') + if (updatedAt < createdAt) throw new Error('goal change updatedAt cannot precede createdAt') + return { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation: value['operation'] as Exclude, + goal: decodeSnapshot(value['goal']), + roundsStarted: nonNegativeInteger(value['roundsStarted'], 'roundsStarted'), + createdAt, + updatedAt, + } satisfies GoalSnapshotChangeMeta +} + +/** Narrow model attribution to a valid goal source. */ +function goalSource(source: MessageSource): GoalMessageSource | undefined { + if (source.kind !== 'goal') return undefined + if (typeof source.goalId !== 'string' || source.goalId.length === 0 + || !Number.isSafeInteger(source.revision) || source.revision < 1 + || !Number.isSafeInteger(source.round) || source.round < 0) { + throw new Error('goal message source is invalid') + } + return source +} + +/** Require two snapshots to retain fields that only `edit` may replace. */ +function requireSameDefinition(current: GoalSnapshot, next: GoalSnapshot, operation: GoalOperation): void { + if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) { + throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`) + } +} + +/** Require one exact next revision of the current goal. */ +function requireNextRevision(current: GoalSnapshot, next: GoalRef, operation: GoalOperation): void { + if (next.id !== current.id || next.revision !== current.revision + 1) { + throw new Error(`goal ${operation} must advance the current goal by one revision`) + } +} + +/** Validate one non-create snapshot operation against the preceding projection. */ +function validateSnapshotTransition( + state: GoalFoldState, + change: GoalSnapshotChangeMeta, + current: GoalSnapshot, +): void { + const next = change.goal + requireNextRevision(current, next, change.operation) + /* v8 ignore next -- a current goal established by this fold always has an updatedAt */ + if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt') + if (change.createdAt !== state.createdAt + || change.updatedAt < state.updatedAt + || change.roundsStarted !== state.roundsStarted) { + throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`) + } + switch (change.operation) { + case 'edit': + if (next.phase !== current.phase) throw new Error('goal edit cannot change phase') + break + case 'pause': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'paused') throw new Error('goal pause has an invalid phase transition') + break + case 'resume': { + requireSameDefinition(current, next, change.operation) + const resumable: ReadonlySet = new Set([ + 'active', + 'paused', + 'blocked', + 'usage-limited', + 'budget-limited', + ]) + if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) { + throw new Error('goal resume has an invalid phase transition or exhausted round budget') + } + break + } + case 'complete': + requireSameDefinition(current, next, change.operation) + if (current.phase === 'complete' || next.phase !== 'complete') throw new Error('goal complete has an invalid phase transition') + break + case 'block': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition') + break + case 'mark-usage-limited': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'usage-limited') { + throw new Error('goal mark-usage-limited has an invalid phase transition') + } + break + case 'mark-budget-limited': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'budget-limited' + || state.roundsStarted < next.maxGoalRounds) { + throw new Error('goal mark-budget-limited has an invalid phase transition or remaining round budget') + } + break + /* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */ + case 'create': + throw new Error('goal create cannot be validated as a current-goal transition') + default: + change.operation satisfies never + throw new Error('unknown goal snapshot operation') + /* v8 ignore stop */ + } +} + +/** + * Return the revision identity carried by a snapshot or tombstone. + * @param change - decoded goal mutation. + * @returns stable identity used to reconcile a deferred change with its log event. + */ +export function goalChangeRef(change: GoalChangeMeta): GoalRef { + return change.operation === 'clear' ? change.cleared : change.goal +} + +/** + * Validate and apply one decoded change to a mutable accumulator. + * @param state - preceding durable goal projection. + * @param change - decoded full snapshot or clear tombstone. + */ +export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): void { + const ref = goalChangeRef(change) + if (change.operation === 'clear') { + const current = state.goal + if (current === undefined) throw new Error('goal clear requires a current goal') + requireNextRevision(current, change.cleared, change.operation) + /* v8 ignore next -- a current goal established by this fold always has an updatedAt */ + if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt') + if (change.clearedAt < state.updatedAt) { + throw new Error('goal clear timestamp cannot precede the current goal update') + } + state.goal = undefined + state.roundsStarted = 0 + state.createdAt = undefined + state.updatedAt = undefined + state.lastRef = ref + return + } + if (change.operation === 'create') { + if (change.goal.revision !== 1 || change.goal.phase !== 'active' || change.roundsStarted !== 0 + || (state.goal !== undefined && state.goal.phase !== 'complete') + || state.seenGoalIds.has(change.goal.id)) { + throw new Error('goal create requires a fresh active revision-one goal with zero rounds') + } + state.seenGoalIds.add(change.goal.id) + } else { + const current = state.goal + if (current === undefined) throw new Error(`goal ${change.operation} requires a current goal`) + validateSnapshotTransition(state, change, current) + } + state.goal = change.goal + state.roundsStarted = change.roundsStarted + state.createdAt = change.createdAt + state.updatedAt = change.updatedAt + state.lastRef = ref +} + +/** + * Decode and verify one model-visible goal context event without folding it. + * @param event - context event whose metadata and rendered content must agree. + * @returns validated change or `undefined` for an unrelated context event. + */ +export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined { + const change = decodeGoalChange(event.data.meta) + const source = goalSource(event.data.source) + if (change === undefined) { + if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`) + return undefined + } + const ref = goalChangeRef(change) + if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) { + throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`) + } + if (event.data.envelope !== 'raw' + || JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) { + throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`) + } + return change +} + +/** + * Apply one session event and return its goal change, when present. + * @param state - mutable fold accumulator. + * @param event - next event in sequence order. + * @returns decoded change for pending-overlay reconciliation. + */ +export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined { + if (event.type === 'context/message') { + const change = decodeGoalEvent(event) + if (change === undefined) return undefined + applyGoalChange(state, change) + return change + } + if (event.type === 'user/message') { + const source = goalSource(event.data.source) + if (source !== undefined) { + const current = state.goal + if (current === undefined || current.phase !== 'active' || source.goalId !== current.id + || source.revision !== current.revision || source.round !== state.roundsStarted + 1 + || source.round > current.maxGoalRounds) { + throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`) + } + state.roundsStarted = source.round + } + } + return undefined +} + +/** + * Fold current goal state from a contiguous session event log. + * @param events - session events in sequence order. + * @returns a fresh durable projection; activation is deliberately absent. + */ +export function foldGoal(events: readonly SessionEvent[]): FoldedGoal { + const state = emptyGoalFoldState() + for (const event of events) applyGoalEvent(state, event) + return { + ...state.goal === undefined ? {} : { goal: { ...state.goal } }, + roundsStarted: state.roundsStarted, + ...state.createdAt === undefined ? {} : { createdAt: state.createdAt }, + ...state.updatedAt === undefined ? {} : { updatedAt: state.updatedAt }, + ...state.lastRef === undefined ? {} : { lastRef: { ...state.lastRef } }, + } +} diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts new file mode 100644 index 0000000000..726ceb6c53 --- /dev/null +++ b/packages/goal/goal/src/index.ts @@ -0,0 +1,505 @@ +/** + * Same-session goal domain: event-sourced state, compare-and-set mutations, + * and process-local continuation activation. + * @module @deepseek-ai/dsh-goal + */ + +import { randomUUID } from 'node:crypto' +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue, Session } from '@deepseek-ai/dsh-session' +import { + applyGoalChange, + applyGoalEvent, + decodeGoalEvent, + emptyGoalFoldState, + goalChangeRef, +} from './fold.ts' +import type { GoalFoldState } from './fold.ts' +import { renderGoalChange } from './render.ts' +import { + GOAL_CHANGE_VERSION, + GoalError, + GoalId, +} from './runtime.ts' +import type { + CreateGoalRequest, + CreateGoalSpec, + EditGoalRequest, + GoalActivation, + GoalChangeMeta, + GoalChanged, + GoalClearChangeMeta, + GoalOperation, + GoalPhase, + GoalRef, + GoalSnapshot, + GoalSnapshotChangeMeta, + GoalView, +} from './types.ts' + +export * from './types.ts' +export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' +export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts' +export { renderGoalChange } from './render.ts' + +declare module 'cordis' { + interface Context { + goals: GoalService + } +} + +/** Deployment defaults for goal creation. */ +export interface Config { + /** Total rounds used when a create request omits its own cap. */ + defaultMaxGoalRounds?: number +} + +/** Resolved defaults. */ +export interface ResolvedConfig { + /** Validated positive safe-integer default round cap. */ + defaultMaxGoalRounds: number +} + +/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */ +interface GoalCache { + readonly state: GoalFoldState + activation: GoalActivation + observedSeq: number + readonly pending: GoalChangeMeta[] +} + +/** Validate a caller-visible positive safe-integer round cap. */ +function resolveMaxGoalRounds(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new GoalError('maxGoalRounds must be a positive safe integer', 'GOAL_INVALID_MAX_ROUNDS') + } + return value +} + +/** Validate and normalize an objective at the domain boundary. */ +function resolveObjective(value: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new GoalError('goal objective must be a non-empty string', 'GOAL_INVALID_OBJECTIVE') + } + return value.trim() +} + +/** Compare the complete canonical payloads used for deferred reconciliation. */ +function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */ +export class GoalService extends Service { + static inject = ['agents'] + + static Config: z = z.object({ + defaultMaxGoalRounds: z.number().default(256), + }) + + private readonly resolved: ResolvedConfig + private readonly caches = new WeakMap() + + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'goals') + this.resolved = { + defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), + } + ctx.on('agent/session-start', (agent) => { + this.cache(agent.session).activation = 'disarmed' + }) + } + + /** + * Materialize deployment defaults and validate one create request. + * @param request - objective plus optional caller-selected round cap. + * @returns detached, fully resolved create specification. + */ + resolveCreate(request: CreateGoalRequest): CreateGoalSpec { + return { + objective: resolveObjective(request.objective), + maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? this.resolved.defaultMaxGoalRounds), + } + } + + /** + * Read the current goal for one exact live agent. + * @param agent - owning live agent. + * @returns a fresh view or `undefined` when no goal is current. + * @throws {@link GoalError} when the agent is not the registry's live instance. + */ + get(agent: Agent): GoalView | undefined { + this.assertLive(agent) + const cache = this.cache(agent.session) + this.sync(agent.session, cache) + return this.view(cache) + } + + /** + * Create and arm a goal. A completed goal may be replaced; every other + * current phase must be cleared or resumed instead. + * @param agent - owning live agent. + * @param request - objective and optional round cap. + * @returns the created live view. + */ + create(agent: Agent, request: CreateGoalRequest): GoalView { + const spec = this.resolveCreate(request) + const cache = this.prepareMutation(agent) + const current = cache.state.goal + if (current !== undefined && current.phase !== 'complete') { + throw new GoalError(`goal "${current.id}" already exists with phase "${current.phase}"`, 'GOAL_ALREADY_EXISTS') + } + const now = Date.now() + const goal: GoalSnapshot = { + id: GoalId(`goal-${randomUUID()}`), + revision: 1, + objective: spec.objective, + phase: 'active', + maxGoalRounds: spec.maxGoalRounds, + } + return this.commitSnapshot(agent, cache, 'create', goal, 0, now, now, 'armed') + } + + /** + * Edit objective and/or round cap without changing phase. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @param request - at least one replacement field. + * @returns the edited view. + */ + edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + if (request.objective === undefined && request.maxGoalRounds === undefined) { + throw new GoalError('goal edit requires objective and/or maxGoalRounds', 'GOAL_INVALID_EDIT') + } + const goal: GoalSnapshot = { + ...current, + revision: current.revision + 1, + ...request.objective === undefined ? {} : { objective: resolveObjective(request.objective) }, + ...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds) }, + } + return this.commitCurrent(agent, cache, 'edit', goal, cache.activation) + } + + /** + * Pause an active goal and disarm automatic continuation. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the paused view. + */ + pause(agent: Agent, ref: GoalRef): GoalView { + return this.transition(agent, ref, 'pause', ['active'], 'paused', 'disarmed') + } + + /** + * Resume and arm a stopped goal, or rearm an active goal after a + * session-start edge, while its round budget still has capacity. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the active view. + */ + resume(agent: Agent, ref: GoalRef): GoalView { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'] + if (!resumable.includes(current.phase)) { + throw this.transitionError(current, 'resume', resumable) + } + if (current.phase === 'active' && cache.activation === 'armed') { + throw new GoalError(`goal "${current.id}" is already active and armed`, 'GOAL_INVALID_TRANSITION') + } + if (cache.state.roundsStarted >= current.maxGoalRounds) { + throw new GoalError( + `goal "${current.id}" exhausted ${current.maxGoalRounds} goal rounds; increase maxGoalRounds before resuming`, + 'GOAL_INVALID_TRANSITION', + ) + } + return this.commitCurrent(agent, cache, 'resume', this.withPhase(current, 'active'), 'armed') + } + + /** + * Mark a current non-complete goal complete and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the completed view. + */ + complete(agent: Agent, ref: GoalRef): GoalView { + return this.transition( + agent, + ref, + 'complete', + ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'], + 'complete', + 'disarmed', + ) + } + + /** + * Mark an active goal blocked and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the blocked view. + */ + block(agent: Agent, ref: GoalRef): GoalView { + return this.transition(agent, ref, 'block', ['active'], 'blocked', 'disarmed') + } + + /** + * Mark an active goal stopped by an external usage limit. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the usage-limited view. + */ + markUsageLimited(agent: Agent, ref: GoalRef): GoalView { + return this.transition(agent, ref, 'mark-usage-limited', ['active'], 'usage-limited', 'disarmed') + } + + /** + * Mark an active goal stopped at its configured round cap. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the budget-limited view. + */ + markBudgetLimited(agent: Agent, ref: GoalRef): GoalView { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + if (current.phase !== 'active') { + throw this.transitionError(current, 'mark-budget-limited', ['active']) + } + if (cache.state.roundsStarted < current.maxGoalRounds) { + throw new GoalError( + `goal "${current.id}" has started ${cache.state.roundsStarted}/${current.maxGoalRounds} rounds`, + 'GOAL_INVALID_TRANSITION', + ) + } + return this.commitCurrent( + agent, + cache, + 'mark-budget-limited', + this.withPhase(current, 'budget-limited'), + 'disarmed', + ) + } + + /** + * Clear the current goal while retaining a durable tombstone and history. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the tombstone ref whose revision is one past the cleared snapshot. + */ + clear(agent: Agent, ref: GoalRef): GoalRef { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + const tombstone: GoalRef = { id: current.id, revision: current.revision + 1 } + const change: GoalClearChangeMeta = { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation: 'clear', + cleared: tombstone, + clearedAt: this.nextMutationTime(cache), + } + this.commit(agent, cache, change, 'disarmed') + return { ...tombstone } + } + + /** Resolve and validate the cache used by a mutation. */ + private prepareMutation(agent: Agent): GoalCache { + this.assertLive(agent) + const cache = this.cache(agent.session) + this.sync(agent.session, cache) + return cache + } + + /** Reject stale or missing current-state refs. */ + private expectCurrent(cache: GoalCache, ref: GoalRef): GoalSnapshot { + const current = cache.state.goal + if (current === undefined) throw new GoalError('no current goal', 'GOAL_NOT_FOUND') + if (ref.id !== current.id || ref.revision !== current.revision) { + throw new GoalError( + `stale goal ref "${ref.id}" revision ${ref.revision}; current is "${current.id}" revision ${current.revision}`, + 'GOAL_STALE_REVISION', + ) + } + return current + } + + /** Enforce exact live-agent identity rather than trusting a matching id. */ + private assertLive(agent: Agent): void { + if (this.ctx.agents.get(agent.id) !== agent || agent.status === 'disposed') { + throw new GoalError(`agent "${agent.id}" is not live in this registry`, 'GOAL_AGENT_NOT_LIVE') + } + } + + /** Return the per-session cache, folding a seed once with activation disarmed. */ + private cache(session: Session): GoalCache { + let cache = this.caches.get(session) + if (cache !== undefined) return cache + const state = emptyGoalFoldState() + for (const event of session.events) applyGoalEvent(state, event) + cache = { + state, + activation: 'disarmed', + observedSeq: session.seq, + pending: [], + } + this.caches.set(session, cache) + return cache + } + + /** Incrementally observe durable events without losing deferred mutations. */ + private sync(session: Session, cache: GoalCache): void { + for (const event of session.events.slice(cache.observedSeq)) { + if (event.type === 'context/message') { + const change = decodeGoalEvent(event) + if (change !== undefined) { + const pending = cache.pending[0] + if (pending !== undefined && sameChange(pending, change)) { + cache.pending.shift() + continue + } + } + } + applyGoalEvent(cache.state, event) + } + cache.observedSeq = session.seq + } + + /** Build a new revision with one replacement phase. */ + private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot { + return { ...current, revision: current.revision + 1, phase } + } + + /** Shared validated phase transition. */ + private transition( + agent: Agent, + ref: GoalRef, + operation: Exclude, + allowed: readonly GoalPhase[], + phase: GoalPhase, + activation: GoalActivation, + ): GoalView { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + if (!allowed.includes(current.phase)) throw this.transitionError(current, operation, allowed) + return this.commitCurrent(agent, cache, operation, this.withPhase(current, phase), activation) + } + + /** Render a stable invalid-transition error. */ + private transitionError(current: GoalSnapshot, operation: GoalOperation, allowed: readonly GoalPhase[]): GoalError { + return new GoalError( + `cannot ${operation} goal "${current.id}" from phase "${current.phase}"; expected ${allowed.join(' or ')}`, + 'GOAL_INVALID_TRANSITION', + ) + } + + /** Commit a mutation that retains the current goal's derived counters/times. */ + private commitCurrent( + agent: Agent, + cache: GoalCache, + operation: Exclude, + goal: GoalSnapshot, + activation: GoalActivation, + ): GoalView { + const createdAt = cache.state.createdAt + /* v8 ignore next -- strict replay and every snapshot commit set createdAt whenever a current goal exists */ + if (createdAt === undefined) throw new Error('current goal cache lacks createdAt') + return this.commitSnapshot( + agent, + cache, + operation, + goal, + cache.state.roundsStarted, + createdAt, + this.nextMutationTime(cache), + activation, + ) + } + + /** Clamp a current goal's next timestamp across backward wall-clock movement. */ + private nextMutationTime(cache: GoalCache): number { + const updatedAt = cache.state.updatedAt + /* v8 ignore next -- strict replay and every snapshot commit set updatedAt whenever a current goal exists */ + if (updatedAt === undefined) throw new Error('current goal cache lacks updatedAt') + return Math.max(Date.now(), updatedAt) + } + + /** Build and commit one full-snapshot mutation. */ + private commitSnapshot( + agent: Agent, + cache: GoalCache, + operation: Exclude, + goal: GoalSnapshot, + roundsStarted: number, + createdAt: number, + updatedAt: number, + activation: GoalActivation, + ): GoalView { + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation, + goal, + roundsStarted, + createdAt, + updatedAt, + } + this.commit(agent, cache, change, activation) + const view = this.view(cache) + /* v8 ignore next -- applyGoalChange installs the snapshot immediately before this read */ + if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly') + return view + } + + /** Accept one mutation into the agent log/FIFO, cache, and live event stream. */ + private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void { + const ref = goalChangeRef(change) + // snapshotJsonValue preserves its input type for callers that already have + // a JsonValue; this interface is structurally JSON but intentionally has no + // index signature, so narrow the validated output at this boundary. + const meta = snapshotJsonValue(change) as JsonValue | undefined + /* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */ + if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable') + agent.inject(renderGoalChange(change), { + source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 }, + envelope: 'raw', + meta, + }) + cache.pending.push(change) + applyGoalChange(cache.state, change) + cache.activation = activation + this.sync(agent.session, cache) + const goal = this.view(cache) + const notification: GoalChanged = { + operation: change.operation, + ref: { ...ref }, + ...goal === undefined ? {} : { goal }, + } + agentEvents(this.ctx, agent).emit('goal/changed', notification) + } + + /** Build a detached current view. */ + private view(cache: GoalCache): GoalView | undefined { + const goal = cache.state.goal + const createdAt = cache.state.createdAt + const updatedAt = cache.state.updatedAt + if (goal === undefined) return undefined + /* v8 ignore next 3 -- strict replay and snapshot commits establish both timestamps with every current goal */ + if (createdAt === undefined || updatedAt === undefined) { + throw new Error(`goal "${goal.id}" cache lacks timestamps`) + } + return { + ...goal, + roundsStarted: cache.state.roundsStarted, + createdAt, + updatedAt, + activation: cache.activation, + } + } +} + +export default GoalService diff --git a/packages/goal/goal/src/render.ts b/packages/goal/goal/src/render.ts new file mode 100644 index 0000000000..f84a1b51ea --- /dev/null +++ b/packages/goal/goal/src/render.ts @@ -0,0 +1,21 @@ +/** Model-visible rendering for durable goal mutations. */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { GoalChangeMeta } from './types.ts' + +/** + * Render a complete goal snapshot or clear tombstone without hidden prose. + * @param change - durable goal change metadata. + * @returns the single raw context block logged for model reconstruction. + */ +export function renderGoalChange(change: GoalChangeMeta): ContentBlock[] { + const payload = change.operation === 'clear' + ? { cleared: change.cleared, clearedAt: change.clearedAt } + : { + goal: change.goal, + roundsStarted: change.roundsStarted, + createdAt: change.createdAt, + updatedAt: change.updatedAt, + } + return [{ type: 'text', text: `${JSON.stringify(payload)}` }] +} diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts new file mode 100644 index 0000000000..9cd656ccae --- /dev/null +++ b/packages/goal/goal/src/runtime.ts @@ -0,0 +1,27 @@ +/** Runtime constructors and protocol constants for the goal domain. */ + +import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts' + +/** Version of the goal change metadata embedded in `context/message`. */ +export const GOAL_CHANGE_VERSION = 1 + +/** + * Brand a string as a goal id. + * @param id - raw goal identifier. + * @returns the same string with the compile-time brand. + */ +export function GoalId(id: string): GoalIdType { + return id as GoalIdType +} + +/** Error returned by the goal domain boundary. */ +export class GoalError extends Error { + /** + * @param message - human-readable rejection reason. + * @param code - stable machine-routable classification. + */ + constructor(message: string, public readonly code: GoalErrorCode) { + super(message) + this.name = 'GoalError' + } +} diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts new file mode 100644 index 0000000000..ef40e997ef --- /dev/null +++ b/packages/goal/goal/src/types.ts @@ -0,0 +1,168 @@ +/** + * Durable and live vocabulary for one same-session goal. + * @module @deepseek-ai/dsh-goal/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { Agent } from '@deepseek-ai/dsh-agent' + +/** Identifies one goal across its durable revisions. */ +export type GoalId = Branded<'GoalId'> + +/** Compare-and-set identity for one exact goal revision. */ +export interface GoalRef { + /** Stable goal identity. */ + readonly id: GoalId + /** Positive revision; every durable mutation increments it. */ + readonly revision: number +} + +/** Durable continuation phase. Activation is process-local and separate. */ +export type GoalPhase = + | 'active' + | 'paused' + | 'blocked' + | 'usage-limited' + | 'budget-limited' + | 'complete' + +/** Full durable state written by every non-clear goal mutation. */ +export interface GoalSnapshot extends GoalRef { + /** Human-requested completion objective. */ + readonly objective: string + /** Durable lifecycle phase. */ + readonly phase: GoalPhase + /** Total admitted goal-round cap. */ + readonly maxGoalRounds: number +} + +/** Whether this live process may automatically continue an active goal. */ +export type GoalActivation = 'armed' | 'disarmed' + +/** Current goal projection, including values derived from the session log. */ +export interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} + +/** Goal state-changing verbs recorded in the durable change metadata. */ +export type GoalOperation = + | 'create' + | 'edit' + | 'pause' + | 'resume' + | 'complete' + | 'block' + | 'mark-usage-limited' + | 'mark-budget-limited' + | 'clear' + +/** Full-snapshot goal mutation retained in a model-visible context event. */ +export interface GoalSnapshotChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: Exclude + readonly goal: GoalSnapshot + readonly roundsStarted: number + readonly createdAt: number + readonly updatedAt: number +} + +/** Tombstone retained when the current goal is cleared. */ +export interface GoalClearChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: 'clear' + readonly cleared: GoalRef + readonly clearedAt: number +} + +/** Durable metadata union carried by a goal-owned `context/message`. */ +export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta + +/** Message attribution for durable goal state and continuation rounds. */ +export interface GoalMessageSource { + readonly kind: 'goal' + readonly goalId: GoalId + readonly revision: number + /** Zero for state changes; positive for admitted continuation rounds. */ + readonly round: number +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + goal: GoalMessageSource + } +} + +/** Pure replay fold of durable goal facts. */ +export interface FoldedGoal { + /** Current goal, absent after a clear or before the first create. */ + readonly goal?: GoalSnapshot + /** Highest admitted round for the current goal. */ + readonly roundsStarted: number + /** Current goal creation time, absent without a current goal. */ + readonly createdAt?: number + /** Current goal mutation time, absent without a current goal. */ + readonly updatedAt?: number + /** Latest mutation ref, including a clear tombstone. */ + readonly lastRef?: GoalRef +} + +/** Input whose omitted round cap is resolved by the service configuration. */ +export interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} + +/** Validated create input with every deployment default materialized. */ +export interface CreateGoalSpec { + readonly objective: string + readonly maxGoalRounds: number +} + +/** Fields changed by an edit; at least one must be present. */ +export interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} + +/** Live notification after one goal mutation has been accepted for logging. */ +export interface GoalChanged { + readonly operation: GoalOperation + readonly ref: GoalRef + /** Absent for a clear tombstone. */ + readonly goal?: GoalView +} + +/** Stable error codes for rejected goal reads and mutations. */ +export type GoalErrorCode = + | 'GOAL_AGENT_NOT_LIVE' + | 'GOAL_NOT_FOUND' + | 'GOAL_ALREADY_EXISTS' + | 'GOAL_STALE_REVISION' + | 'GOAL_INVALID_OBJECTIVE' + | 'GOAL_INVALID_MAX_ROUNDS' + | 'GOAL_INVALID_EDIT' + | 'GOAL_INVALID_TRANSITION' + +declare module 'cordis' { + interface Events { + /** + * Goal mutation accepted by one live agent. The matching context event is + * already appended or queued in that agent's active tool-batch FIFO. + * Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - agent whose session owns the goal. + * @param change - fresh current projection or clear tombstone. + * @mode emit + */ + 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void + } +} diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts new file mode 100644 index 0000000000..0942248b78 --- /dev/null +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -0,0 +1,126 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { decodeGoalChange, renderGoalChange } from '@deepseek-ai/dsh-goal' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/goal/goal/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 +const REPLY = 'You said: "hello". Try "echo " to see a tool call.' + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function runOneTurn(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'goal-domain-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stdout = '' + let stderr = '' + let inputClosed = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!inputClosed && stdout.includes(REPLY)) { + inputClosed = true + proc.stdin.end() + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error(`goal-domain e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) + + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`goal-domain e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('hello\n') + }) +} + +describe('goal domain through a real cordis.yml and stdio process', () => { + it('persists the Loader-created snapshot without starting a goal round', async () => { + const { stdout, stderr } = await runOneTurn() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('goal-domain e2e ready.') + expect(stdout).toContain(REPLY) + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) + + const contexts = events.filter(event => event.type === 'context/message' + && event.data.source.kind === 'goal') + expect(contexts).toHaveLength(1) + const context = contexts[0] + if (context?.type !== 'context/message') throw new Error('expected goal context event') + const change = decodeGoalChange(context.data.meta) + if (change === undefined) throw new Error('expected durable goal change') + expect(change).toMatchObject({ + operation: 'create', + roundsStarted: 0, + goal: { + revision: 1, + objective: 'Prove the composed goal survives in the session log', + phase: 'active', + maxGoalRounds: 7, + }, + }) + expect(context.data.envelope).toBe('raw') + expect(context.data.content).toEqual(renderGoalChange(change)) + expect(JSON.stringify(context)).not.toContain('activation') + expect(events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'goal')).toHaveLength(0) + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts new file mode 100644 index 0000000000..03b00dd699 --- /dev/null +++ b/packages/goal/goal/tests/goal.spec.ts @@ -0,0 +1,749 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import GoalService, { + GoalError, + GoalId, + decodeGoalChange, + foldGoal, + renderGoalChange, +} from '@deepseek-ai/dsh-goal' +import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' + +interface DeferredInjection { + content: ContentBlock[] + options: InjectOptions | undefined +} + +interface StubAgent { + agent: Agent + session: Session + deferred: DeferredInjection[] + setDeferred(value: boolean): void + setStatus(value: AgentStatus): void + drain(): void +} + +/** Number the next balanced one-shot injection turn. */ +function nextTurn(session: Session): number { + return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 +} + +/** Mirror the public Agent.inject idle/open-turn contract for domain tests. */ +function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { + const source: MessageSource = options?.source ?? { kind: 'user' } + const context = { + content, + source, + ...options?.envelope === undefined ? {} : { envelope: options.envelope }, + ...options?.meta === undefined ? {} : { meta: options.meta }, + } + const last = session.events.at(-1) + const open = last !== undefined && last.type !== 'turn/end' + if (open) { + session.append('context/message', context, { surfaceOp: 'append' }) + return + } + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', context, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +/** Build a registry-compatible agent around one concrete session. */ +function stubAgentForSession(session: Session): StubAgent { + const id = session.id + const deferred: DeferredInjection[] = [] + let shouldDefer = false + let status: AgentStatus = 'idle' + const agent: Agent = { + id, + options: {}, + session, + ctx: new Context(), + get status() { return status }, + send() {}, + steer() {}, + inject(content, options) { + if (shouldDefer) deferred.push({ content, options }) + else appendInjection(session, content, options) + }, + cancel() {}, + whenIdle() { return Promise.resolve() }, + } + return { + agent, + session, + deferred, + setDeferred(value) { shouldDefer = value }, + setStatus(value) { status = value }, + drain() { + shouldDefer = false + for (const injection of deferred.splice(0)) appendInjection(session, injection.content, injection.options) + }, + } +} + +/** Build a registry-compatible agent with controllable context deferral. */ +function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent { + return stubAgentForSession(new Session(SessionId(rawId), seed)) +} + +async function harness(config: { defaultMaxGoalRounds?: number } = {}) { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService, config) + const stub = stubAgent(`goal-test-${Math.random()}`) + ctx.agents.register(stub.agent) + return { ctx, ...stub } +} + +/** Append one admitted goal round as a balanced user-message turn. */ +function appendRound(session: Session, ref: GoalRef, round: number): void { + const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('user/message', { content: [{ type: 'text', text: `round ${round}` }], source }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +describe('GoalService creation and replay', () => { + it('resolves the configured default and writes one balanced raw context snapshot', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) + const seen: string[] = [] + ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + + expect(ctx.goals.resolveCreate({ objective: ' finish the feature ' })).toEqual({ + objective: 'finish the feature', + maxGoalRounds: 17, + }) + const goal = ctx.goals.create(agent, { objective: ' finish the feature ' }) + + expect(goal).toMatchObject({ + objective: 'finish the feature', + phase: 'active', + revision: 1, + maxGoalRounds: 17, + roundsStarted: 0, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + activation: 'armed', + }) + expect(goal.id).toMatch(/^goal-/) + expect(seen).toEqual(['create']) + expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + const context = session.events[1] + expect(context?.type).toBe('context/message') + if (context?.type !== 'context/message') throw new Error('expected goal context') + expect(context.data.envelope).toBe('raw') + expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 }) + const change = decodeGoalChange(context.data.meta) + if (change === undefined) throw new Error('expected decoded goal change') + expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } }) + expect(context.data.content).toEqual(renderGoalChange(change)) + expect(session.deriveMessages()).toEqual([{ role: 'user', content: context.data.content }]) + expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) + vi.useRealTimers() + }) + + it('uses 256 rounds by default and validates create input at the owning resolver', async () => { + const { ctx, agent } = await harness() + expect(ctx.goals.resolveCreate({ objective: 'x' })).toEqual({ objective: 'x', maxGoalRounds: 256 }) + expect(() => ctx.goals.resolveCreate({ objective: ' ' })).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_OBJECTIVE', + })) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_MAX_ROUNDS', + })) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1 })).toThrow(GoalError) + expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256) + }) + + it('also resolves the default when constructed directly without Cordis config normalization', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const goals = new GoalService(ctx) + expect(goals.resolveCreate({ objective: 'direct' })).toEqual({ + objective: 'direct', + maxGoalRounds: 256, + }) + }) + + it('rejects invalid direct configuration', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_MAX_ROUNDS', + })) + }) + + it('restores a seeded goal and rounds with activation disarmed', async () => { + const first = await harness() + const created = first.ctx.goals.create(first.agent, { objective: 'seed me', maxGoalRounds: 9 }) + appendRound(first.session, created, 1) + appendRound(first.session, created, 2) + + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const resumed = stubAgent('seeded-goal', first.session.events) + ctx.agents.register(resumed.agent) + expect(ctx.goals.get(resumed.agent)).toMatchObject({ + id: created.id, + roundsStarted: 2, + activation: 'disarmed', + }) + }) + + it('inherits the completed-turn goal prefix through SessionStore.fork with child activation disarmed', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent'))) + ctx.agents.register(parent.agent) + const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 }) + appendRound(parent.session, goal, 1) + + const child = stubAgentForSession(ctx.sessions.fork(parent.session)) + ctx.agents.register(child.agent) + expect(ctx.goals.get(child.agent)).toMatchObject({ + id: goal.id, + objective: goal.objective, + roundsStarted: 1, + activation: 'disarmed', + }) + expect(child.session.header.parentSession).toBe(parent.session.id) + expect(child.session.header.seedLength).toBe(parent.session.seq) + }) + + it('disarms live activation on every session-start edge', async () => { + const { ctx, agent, session } = await harness() + let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' }) + expect(goal.activation).toBe('armed') + agentEvents(ctx, agent).emit('agent/session-start', 'resume') + expect(ctx.goals.get(agent)?.activation).toBe('disarmed') + goal = ctx.goals.resume(agent, goal) + expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) + expect(() => foldGoal(session.events)).not.toThrow() + }) + + it('requires the exact live registry instance for reads and mutations', async () => { + const { ctx, agent } = await harness() + const impostor = { ...agent, session: new Session(agent.id) } + expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) + expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({ + code: 'GOAL_AGENT_NOT_LIVE', + })) + }) + + it('rejects a disposed live object even before registry teardown', async () => { + const test = await harness() + test.setStatus('disposed') + expect(() => test.ctx.goals.get(test.agent)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) + }) +}) + +describe('GoalService mutations', () => { + it('edits with compare-and-set revisions and rejects empty edits', async () => { + const { ctx, agent } = await harness() + const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 }) + expect(() => ctx.goals.edit(agent, created, {})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_EDIT' })) + const objective = ctx.goals.edit(agent, created, { objective: ' new ' }) + expect(objective).toMatchObject({ objective: 'new', maxGoalRounds: 4, revision: 2, activation: 'armed' }) + expect(() => ctx.goals.edit(agent, created, { maxGoalRounds: 8 })).toThrow(expect.objectContaining({ + code: 'GOAL_STALE_REVISION', + })) + const cap = ctx.goals.edit(agent, objective, { maxGoalRounds: 8 }) + expect(cap).toMatchObject({ objective: 'new', maxGoalRounds: 8, revision: 3 }) + expect(() => ctx.goals.edit(agent, cap, { objective: ' ' })).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_OBJECTIVE', + })) + }) + + it('supports pause, resume, block, usage-limit, and completion transitions', async () => { + const { ctx, agent } = await harness() + let goal = ctx.goals.create(agent, { objective: 'lifecycle' }) + goal = ctx.goals.pause(agent, goal) + expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 }) + goal = ctx.goals.resume(agent, goal) + expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 }) + goal = ctx.goals.block(agent, goal) + expect(goal).toMatchObject({ phase: 'blocked', activation: 'disarmed' }) + goal = ctx.goals.resume(agent, goal) + goal = ctx.goals.markUsageLimited(agent, goal) + expect(goal.phase).toBe('usage-limited') + goal = ctx.goals.resume(agent, goal) + goal = ctx.goals.pause(agent, goal) + goal = ctx.goals.complete(agent, goal) + expect(goal).toMatchObject({ phase: 'complete', activation: 'disarmed' }) + expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + }) + + it('allows completion from every stopped phase and replacement only after completion', async () => { + const phases = ['paused', 'blocked', 'usage-limited'] as const + for (const phase of phases) { + const { ctx, agent } = await harness() + let goal = ctx.goals.create(agent, { objective: phase }) + goal = phase === 'paused' + ? ctx.goals.pause(agent, goal) + : phase === 'blocked' + ? ctx.goals.block(agent, goal) + : ctx.goals.markUsageLimited(agent, goal) + const complete = ctx.goals.complete(agent, goal) + const replacement = ctx.goals.create(agent, { objective: `after ${phase}` }) + expect(complete.phase).toBe('complete') + expect(replacement.id).not.toBe(complete.id) + expect(replacement.revision).toBe(1) + } + }) + + it('rejects replacement and invalid phase transitions while a resumable goal exists', async () => { + const { ctx, agent } = await harness() + const goal = ctx.goals.create(agent, { objective: 'still active' }) + expect(() => ctx.goals.create(agent, { objective: 'replacement' })).toThrow(expect.objectContaining({ + code: 'GOAL_ALREADY_EXISTS', + })) + expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + const paused = ctx.goals.pause(agent, goal) + expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + expect(() => ctx.goals.block(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + expect(() => ctx.goals.markUsageLimited(agent, paused)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_TRANSITION', + })) + expect(() => ctx.goals.markBudgetLimited(agent, paused)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_TRANSITION', + })) + }) + + it('enforces the goal-round cap before budget limiting and resuming', async () => { + const { ctx, agent, session } = await harness() + let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 }) + appendRound(session, goal, 1) + expect(ctx.goals.get(agent)?.roundsStarted).toBe(1) + expect(() => ctx.goals.markBudgetLimited(agent, goal)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_TRANSITION', + })) + appendRound(session, goal, 2) + goal = ctx.goals.markBudgetLimited(agent, goal) + expect(goal).toMatchObject({ phase: 'budget-limited', roundsStarted: 2, activation: 'disarmed' }) + expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 }) + goal = ctx.goals.resume(agent, goal) + expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' }) + appendRound(session, goal, 3) + goal = ctx.goals.markBudgetLimited(agent, goal) + expect(ctx.goals.complete(agent, goal).phase).toBe('complete') + }) + + it('clears through a revisioned tombstone and permits a fresh goal', async () => { + const { ctx, agent, session } = await harness() + const goal = ctx.goals.create(agent, { objective: 'temporary' }) + const tombstone = ctx.goals.clear(agent, goal) + expect(tombstone).toEqual({ id: goal.id, revision: 2 }) + expect(ctx.goals.get(agent)).toBeUndefined() + expect(foldGoal(session.events)).toEqual({ roundsStarted: 0, lastRef: tombstone }) + expect(() => ctx.goals.clear(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_NOT_FOUND' })) + const next = ctx.goals.create(agent, { objective: 'fresh' }) + expect(next.id).not.toBe(goal.id) + }) + + it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => { + vi.useFakeTimers() + vi.setSystemTime(100) + const { ctx, agent, session } = await harness() + let goal = ctx.goals.create(agent, { objective: 'monotonic time' }) + vi.setSystemTime(90) + goal = ctx.goals.pause(agent, goal) + expect(goal.updatedAt).toBe(100) + vi.setSystemTime(80) + ctx.goals.clear(agent, goal) + const clear = session.events + .filter(event => event.type === 'context/message') + .map(event => decodeGoalChange(event.data.meta)) + .at(-1) + expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) + expect(() => foldGoal(session.events)).not.toThrow() + vi.useRealTimers() + }) + + it('contains goal notification failures and preserves later listeners', async () => { + const { ctx, agent } = await harness() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const seen: string[] = [] + ctx.on('goal/changed', () => { throw new Error('broken observer') }) + ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active') + expect(seen).toEqual(['create']) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer')) + }) + + it('preserves multiple pending revisions until deferred injections enter the log', async () => { + const test = await harness() + const { ctx, agent, session, deferred } = test + test.setDeferred(true) + let goal = ctx.goals.create(agent, { objective: 'deferred', maxGoalRounds: 5 }) + goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' }) + goal = ctx.goals.pause(agent, goal) + expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' }) + expect(deferred).toHaveLength(3) + expect(session.events).toHaveLength(0) + + appendInjection(session, [{ type: 'text', text: 'unrelated' }], { source: { kind: 'plugin', plugin: 'test' } }) + expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) + test.drain() + expect(deferred).toHaveLength(0) + expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) + expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) + }) + + it('rejects deferred goal mutations that enter the log out of FIFO order', async () => { + const test = await harness() + test.setDeferred(true) + const created = test.ctx.goals.create(test.agent, { objective: 'ordered' }) + test.ctx.goals.edit(test.agent, created, { objective: 'ordered edit' }) + const second = test.deferred[1] + if (second === undefined) throw new Error('expected a second deferred goal mutation') + appendInjection(test.session, second.content, second.options) + expect(() => test.ctx.goals.get(test.agent)).toThrow('advance the current goal') + }) + + it('observes a valid goal snapshot appended after an empty cache was established', async () => { + const { ctx, agent, session } = await harness() + expect(ctx.goals.get(agent)).toBeUndefined() + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-external'), + revision: 1, + objective: 'observe external append', + phase: 'active', + maxGoalRounds: 4, + }, + roundsStarted: 0, + createdAt: 12, + updatedAt: 12, + } + const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content: renderGoalChange(change), source, envelope: 'raw', meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + + expect(ctx.goals.get(agent)).toMatchObject({ + id: change.goal.id, + objective: change.goal.objective, + activation: 'disarmed', + }) + }) +}) + +describe('goal replay validation', () => { + function snapshotChange(overrides: Partial = {}): GoalSnapshotChangeMeta { + return { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-validation'), + revision: 1, + objective: 'validate', + phase: 'active', + maxGoalRounds: 2, + }, + roundsStarted: 0, + createdAt: 10, + updatedAt: 10, + ...overrides, + } + } + + function appendChange( + session: Session, + change: GoalChangeMeta, + overrides: { content?: ContentBlock[]; source?: MessageSource; envelope?: 'raw' } = {}, + ): void { + const source = overrides.source ?? { + kind: 'goal', + goalId: change.operation === 'clear' ? change.cleared.id : change.goal.id, + revision: change.operation === 'clear' ? change.cleared.revision : change.goal.revision, + round: 0, + } + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content: overrides.content ?? renderGoalChange(change), + source, + envelope: overrides.envelope ?? 'raw', + meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource; envelope?: 'raw' } = {}) { + const session = new Session(SessionId(`validation-${Math.random()}`)) + appendChange(session, change, overrides) + return session.events + } + + function mutation( + current: GoalSnapshotChangeMeta, + operation: Exclude, + phase: GoalSnapshotChangeMeta['goal']['phase'], + overrides: Partial = {}, + ): GoalSnapshotChangeMeta { + return { + ...current, + operation, + goal: { ...current.goal, revision: current.goal.revision + 1, phase }, + updatedAt: current.updatedAt + 1, + ...overrides, + } + } + + function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType { + const session = new Session(SessionId(`validation-pair-${Math.random()}`)) + appendChange(session, first) + appendChange(session, second) + return foldGoal(session.events) + } + + it('ignores unrelated metadata and non-goal round sources', () => { + expect(decodeGoalChange(undefined)).toBeUndefined() + expect(decodeGoalChange({ kind: 'other' })).toBeUndefined() + const session = new Session(SessionId('unrelated')) + appendInjection(session, [{ type: 'text', text: 'other' }], { + source: { kind: 'plugin', plugin: 'test' }, + meta: { kind: 'other' }, + }) + expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('user/message', { content: [{ type: 'text', text: 'ordinary' }], source }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + }) + + it('rejects rounds attributed to another goal', () => { + const change = snapshotChange() + const session = new Session(SessionId('other-goal-round'), oneChange(change)) + appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1) + expect(() => foldGoal(session.events)).toThrow('not the next admitted round') + }) + + it('rejects unsupported versions, operations, and top-level shapes', () => { + expect(() => decodeGoalChange({ ...snapshotChange(), version: 2 })).toThrow('unsupported goal change version') + expect(() => decodeGoalChange({ ...snapshotChange(), operation: 'explode' })).toThrow('operation is invalid') + expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change has an invalid shape') + expect(() => decodeGoalChange({ + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 2 }, clearedAt: 1, extra: true, + })).toThrow('clear change has an invalid shape') + }) + + it('rejects invalid create and missing-current mutation sequences', () => { + const base = snapshotChange() + const invalidCreates: GoalSnapshotChangeMeta[] = [ + { ...base, goal: { ...base.goal, revision: 2 } }, + { ...base, goal: { ...base.goal, phase: 'paused' } }, + { ...base, roundsStarted: 1 }, + ] + for (const change of invalidCreates) expect(() => foldGoal(oneChange(change))).toThrow('goal create requires') + + const edit = mutation(base, 'edit', 'active') + expect(() => foldGoal(oneChange(edit))).toThrow('requires a current goal') + const clear: GoalChangeMeta = { + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 12, + } + expect(() => foldGoal(oneChange(clear))).toThrow('clear requires a current goal') + + const secondCreate = snapshotChange({ + goal: { ...base.goal, id: GoalId('goal-second') }, + createdAt: 20, + updatedAt: 20, + }) + expect(() => foldPair(base, secondCreate)).toThrow('goal create requires') + }) + + it('rejects stale identity, counters, timestamps, and definition changes', () => { + const base = snapshotChange() + const invalid: GoalSnapshotChangeMeta[] = [ + mutation(base, 'edit', 'active', { goal: { ...base.goal, id: GoalId('goal-wrong'), revision: 2 } }), + mutation(base, 'edit', 'active', { goal: { ...base.goal, revision: 3 } }), + mutation(base, 'edit', 'active', { createdAt: 11 }), + mutation(base, 'edit', 'active', { updatedAt: 9 }), + mutation(base, 'edit', 'active', { roundsStarted: 1 }), + mutation(base, 'pause', 'paused', { + goal: { ...base.goal, revision: 2, phase: 'paused', objective: 'changed illegally' }, + }), + mutation(base, 'pause', 'paused', { + goal: { ...base.goal, revision: 2, phase: 'paused', maxGoalRounds: 3 }, + }), + ] + for (const change of invalid) expect(() => foldPair(base, change)).toThrow() + }) + + it('rejects invalid replayed lifecycle phase transitions', () => { + const base = snapshotChange() + const invalid: GoalSnapshotChangeMeta[] = [ + mutation(base, 'edit', 'paused'), + mutation(base, 'pause', 'active'), + mutation(base, 'resume', 'paused'), + mutation(base, 'complete', 'active'), + mutation(base, 'block', 'active'), + mutation(base, 'mark-usage-limited', 'active'), + mutation(base, 'mark-budget-limited', 'active'), + mutation(base, 'mark-budget-limited', 'budget-limited'), + ] + for (const change of invalid) expect(() => foldPair(base, change)).toThrow() + + const paused = mutation(base, 'pause', 'paused') + const exhausted = mutation(paused, 'resume', 'active', { + roundsStarted: 2, + goal: { ...paused.goal, revision: 3, phase: 'active', maxGoalRounds: 2 }, + }) + const session = new Session(SessionId('exhausted-resume')) + appendChange(session, base) + appendRound(session, base.goal, 1) + appendRound(session, base.goal, 2) + appendChange(session, { ...paused, roundsStarted: 2 }) + appendChange(session, exhausted) + expect(() => foldGoal(session.events)).toThrow('exhausted round budget') + }) + + it('rejects invalid clear continuity and goal id reuse', () => { + const base = snapshotChange() + const staleClear: GoalChangeMeta = { + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 3 }, clearedAt: 11, + } + expect(() => foldPair(base, staleClear)).toThrow('advance the current goal') + const earlyClear: GoalChangeMeta = { + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 9, + } + expect(() => foldPair(base, earlyClear)).toThrow('timestamp cannot precede') + + const complete = mutation(base, 'complete', 'complete') + const sameCurrentId = snapshotChange({ + goal: { ...base.goal, revision: 1 }, + createdAt: 20, + updatedAt: 20, + }) + const completedSession = new Session(SessionId('reuse-complete')) + appendChange(completedSession, base) + appendChange(completedSession, complete) + appendChange(completedSession, sameCurrentId) + expect(() => foldGoal(completedSession.events)).toThrow('fresh active revision-one') + + const second = snapshotChange({ + goal: { ...base.goal, id: GoalId('goal-second') }, + createdAt: 20, + updatedAt: 20, + }) + const secondComplete = mutation(second, 'complete', 'complete') + const nonAdjacentReuse = new Session(SessionId('reuse-non-adjacent')) + appendChange(nonAdjacentReuse, base) + appendChange(nonAdjacentReuse, complete) + appendChange(nonAdjacentReuse, second) + appendChange(nonAdjacentReuse, secondComplete) + appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 }) + expect(() => foldGoal(nonAdjacentReuse.events)).toThrow('fresh active revision-one') + + const clear: GoalChangeMeta = { + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11, + } + const clearedSession = new Session(SessionId('reuse-clear')) + appendChange(clearedSession, base) + appendChange(clearedSession, clear) + appendChange(clearedSession, sameCurrentId) + expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one') + }) + + it('rejects goal-source context without matching durable metadata', () => { + const session = new Session(SessionId('goal-source-without-meta')) + const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content: [{ type: 'text', text: 'missing' }], source, envelope: 'raw', + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + expect(() => foldGoal(session.events)).toThrow('lacks goal change metadata') + }) + + it('rejects malformed snapshots, refs, counters, and timestamps', () => { + const base = snapshotChange() + const badSnapshots: unknown[] = [ + null, + { ...base.goal, extra: true }, + { ...base.goal, id: '' }, + { ...base.goal, objective: ' ' }, + { ...base.goal, objective: ' padded ' }, + { ...base.goal, phase: 'unknown' }, + { ...base.goal, revision: 0 }, + { ...base.goal, maxGoalRounds: -1 }, + ] + for (const goal of badSnapshots) expect(() => decodeGoalChange({ ...base, goal })).toThrow() + expect(() => decodeGoalChange({ ...base, roundsStarted: -1 })).toThrow('roundsStarted') + expect(() => decodeGoalChange({ ...base, createdAt: -1 })).toThrow('createdAt') + expect(() => decodeGoalChange({ ...base, updatedAt: 9 })).toThrow('cannot precede') + expect(() => decodeGoalChange({ + kind: 'goal/change', version: 1, operation: 'clear', cleared: null, clearedAt: 1, + })).toThrow('tombstone') + expect(() => decodeGoalChange({ + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: '', revision: 1 }, clearedAt: 1, + })).toThrow('non-empty') + expect(() => decodeGoalChange({ + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 0 }, clearedAt: 1, + })).toThrow('positive safe integer') + }) + + it('rejects source, content, and envelope drift from the durable metadata', () => { + const change = snapshotChange() + expect(() => foldGoal(oneChange(change, { source: { kind: 'plugin', plugin: 'wrong' } }))).toThrow('mismatched source') + expect(() => foldGoal(oneChange(change, { + source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: -1 }, + }))).toThrow('source is invalid') + expect(() => foldGoal(oneChange(change, { content: [{ type: 'text', text: 'wrong' }] }))).toThrow('model-visible content') + const events = oneChange(change) + const context = events.find(event => event.type === 'context/message') + if (context?.type !== 'context/message') throw new Error('expected context') + const altered = structuredClone(events) + const clonedContext = altered.find(event => event.type === 'context/message') + if (clonedContext?.type !== 'context/message') throw new Error('expected cloned context') + delete (clonedContext.data as { envelope?: string }).envelope + expect(() => foldGoal(altered)).toThrow('model-visible content') + }) + + it('folds a clear tombstone after a snapshot', () => { + const change = snapshotChange() + const session = new Session(SessionId('fold-clear'), oneChange(change)) + const clear: GoalChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'clear', + cleared: { id: change.goal.id, revision: 2 }, + clearedAt: 20, + } + const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content: renderGoalChange(clear), source, envelope: 'raw', meta: clear as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + expect(foldGoal(session.events)).toEqual({ + roundsStarted: 0, + lastRef: { id: change.goal.id, revision: 2 }, + }) + }) +}) diff --git a/packages/goal/goal/tsconfig.json b/packages/goal/goal/tsconfig.json new file mode 100644 index 0000000000..8a97b2a5e0 --- /dev/null +++ b/packages/goal/goal/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 59a425387b..9ca1e0aab1 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts index 36d1721945..3c112d59a8 100644 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ b/packages/support/invariants/src/scoped-events.generated.ts @@ -8,6 +8,7 @@ import type { Events } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-goal' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -43,6 +44,7 @@ const scopedSubjectResolvers = Object.freeze({ 'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]), 'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]), 'approval/request': adapt<'approval/request'>(args => args[0].agent), + 'goal/changed': adapt<'goal/changed'>(args => args[0]), 'session/created': null, 'session/disposed': null, 'session/event': null, diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index de0046c00e..016e87a969 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -823,6 +823,7 @@ describe('scoped-dispatch invariants', () => { ['agent/turn-stop', [agent, 1]], ['agent/error', [agent, 1, 0, new Error('x')]], ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], + ['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json index 6c5bc479b5..8fefb2d7eb 100644 --- a/packages/support/invariants/tsconfig.json +++ b/packages/support/invariants/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/agent" }, + { + "path": "../../goal/goal" + }, { "path": "../../core/scope" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7649345fc..4f1fa58dce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:* version: link:../packages/fs/fs-policy + '@deepseek-ai/dsh-goal': + specifier: workspace:* + version: link:../packages/goal/goal '@deepseek-ai/dsh-hooks-claude': specifier: workspace:* version: link:../packages/hooks/hooks-claude @@ -976,6 +979,34 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/goal: + dependencies: + schemastery: + specifier: ^3.17.2 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/guard/repeat-tool-guard: dependencies: schemastery: @@ -1749,6 +1780,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 15780b17e0..289a2e9778 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -68,6 +68,12 @@ export const LINK_MAP: Record = { FsVersion: 'filesystem.md', FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', + CreateGoalRequest: 'goal.md', + CreateGoalSpec: 'goal.md', + EditGoalRequest: 'goal.md', + GoalChanged: 'goal.md', + GoalRef: 'goal.md', + GoalView: 'goal.md', LlmAdapter: 'llm-streaming.md', LlmService: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0681153330..bd0ecc18df 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -58,6 +58,7 @@ const GROUP_ORDER = [ 'util', 'llm', 'core', + 'goal', 'bash', 'sandbox', 'fs', @@ -169,6 +170,13 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-spine-demo'], note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.', }, + { + key: 'goals', + pkg: 'goal', + title: 'Same-session goal domain', + mode: 'core', + note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.', + }, { key: 'bash', pkg: 'bash', diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 520375c9ae..f803f164cf 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -21,6 +21,7 @@ const GROUP_ORDER = [ 'util', 'llm', 'core', + 'goal', 'bash', 'fs', 'skill', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 202f2efb6e..1092c6c0b0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -27,6 +27,18 @@ { "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" }, { "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalRef", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalPhase", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshot", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshotChangeMeta", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalClearChangeMeta", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalMessageSource", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalSpec", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 53f69b2cf5..ffcb649335 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -49,6 +49,7 @@ "./packages/skill/*/src", "./packages/compact/*/src", "./packages/context/*/src", + "./packages/goal/*/src", "./packages/guard/*/src", "./packages/subagent/*/src", "./packages/tasks/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 36d6c6d462..668541ade3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -25,6 +25,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/goal/goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/tsconfig.json b/tsconfig.json index 59bb4be2ca..156792259e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/goal/goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json index 3a2b11db3c..5fb3258805 100644 --- a/website/.vitepress/config/api-sidebar.json +++ b/website/.vitepress/config/api-sidebar.json @@ -54,6 +54,10 @@ "text": "ctx.fs", "link": "/zh-CN/api/harness/fs" }, + { + "text": "ctx.goals", + "link": "/zh-CN/api/harness/goals" + }, { "text": "ctx.llm", "link": "/zh-CN/api/harness/llm" diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index c3afe6b3aa..316af95351 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -2,7 +2,7 @@ # Harness events -Every event the harness packages declare on the cordis event bus (42 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). +Every event the harness packages declare on the cordis event bus (43 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). ## agent/* @@ -519,6 +519,32 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L53) +## goal/* + +### goal/changed + +**Mode:** `emit` + +```ts website-api +/** + * Goal mutation accepted by one live agent. The matching context event is + * already appended or queued in that agent's active tool-batch FIFO. + * Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - agent whose session owns the goal. + * @param change - fresh current projection or clear tombstone. + * @mode emit + */ +'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void +``` + +Goal mutation accepted by one live agent. The matching context event is already appended or queued in that agent's active tool-batch FIFO. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + +- `agent` — agent whose session owns the goal. +- `change` — fresh current projection or clear tombstone. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/types.ts#L166) + ## llm/* ### llm/stream diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md new file mode 100644 index 0000000000..146cb04193 --- /dev/null +++ b/website/zh-CN/api/harness/goals.md @@ -0,0 +1,241 @@ + + +# ctx.goals + +`GoalService` — provided by `@deepseek-ai/dsh-goal`. + +Goal service (`ctx.goals`) backed exclusively by the owning session log. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L97) + +### ctx.goals.resolveCreate(request) + +```ts website-api +/** + * Materialize deployment defaults and validate one create request. + * @param request - objective plus optional caller-selected round cap. + * @returns detached, fully resolved create specification. + */ +resolveCreate(request: CreateGoalRequest): CreateGoalSpec +``` + +Materialize deployment defaults and validate one create request. + +- `request` — objective plus optional caller-selected round cap. + +**Returns** detached, fully resolved create specification. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L122) + +### ctx.goals.get(agent) + +```ts website-api +/** + * Read the current goal for one exact live agent. + * @param agent - owning live agent. + * @returns a fresh view or `undefined` when no goal is current. + * @throws {@link GoalError} when the agent is not the registry's live instance. + */ +get(agent: Agent): GoalView | undefined +``` + +Read the current goal for one exact live agent. + +- `agent` — owning live agent. + +**Returns** a fresh view or `undefined` when no goal is current. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135) + +### ctx.goals.create(agent, request) + +```ts website-api +/** + * Create and arm a goal. A completed goal may be replaced; every other + * current phase must be cleared or resumed instead. + * @param agent - owning live agent. + * @param request - objective and optional round cap. + * @returns the created live view. + */ +create(agent: Agent, request: CreateGoalRequest): GoalView +``` + +Create and arm a goal. A completed goal may be replaced; every other current phase must be cleared or resumed instead. + +- `agent` — owning live agent. +- `request` — objective and optional round cap. + +**Returns** the created live view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L149) + +### ctx.goals.edit(agent, ref, request) + +```ts website-api +/** + * Edit objective and/or round cap without changing phase. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @param request - at least one replacement field. + * @returns the edited view. + */ +edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView +``` + +Edit objective and/or round cap without changing phase. + +- `agent` — owning live agent. +- `ref` — expected current revision. +- `request` — at least one replacement field. + +**Returns** the edited view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L174) + +### ctx.goals.pause(agent, ref) + +```ts website-api +/** + * Pause an active goal and disarm automatic continuation. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the paused view. + */ +pause(agent: Agent, ref: GoalRef): GoalView +``` + +Pause an active goal and disarm automatic continuation. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the paused view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L195) + +### ctx.goals.resume(agent, ref) + +```ts website-api +/** + * Resume and arm a stopped goal, or rearm an active goal after a + * session-start edge, while its round budget still has capacity. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the active view. + */ +resume(agent: Agent, ref: GoalRef): GoalView +``` + +Resume and arm a stopped goal, or rearm an active goal after a session-start edge, while its round budget still has capacity. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the active view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L206) + +### ctx.goals.complete(agent, ref) + +```ts website-api +/** + * Mark a current non-complete goal complete and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the completed view. + */ +complete(agent: Agent, ref: GoalRef): GoalView +``` + +Mark a current non-complete goal complete and disarm it. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the completed view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L231) + +### ctx.goals.block(agent, ref) + +```ts website-api +/** + * Mark an active goal blocked and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the blocked view. + */ +block(agent: Agent, ref: GoalRef): GoalView +``` + +Mark an active goal blocked and disarm it. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the blocked view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L248) + +### ctx.goals.markUsageLimited(agent, ref) + +```ts website-api +/** + * Mark an active goal stopped by an external usage limit. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the usage-limited view. + */ +markUsageLimited(agent: Agent, ref: GoalRef): GoalView +``` + +Mark an active goal stopped by an external usage limit. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the usage-limited view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L258) + +### ctx.goals.markBudgetLimited(agent, ref) + +```ts website-api +/** + * Mark an active goal stopped at its configured round cap. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the budget-limited view. + */ +markBudgetLimited(agent: Agent, ref: GoalRef): GoalView +``` + +Mark an active goal stopped at its configured round cap. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the budget-limited view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L268) + +### ctx.goals.clear(agent, ref) + +```ts website-api +/** + * Clear the current goal while retaining a durable tombstone and history. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the tombstone ref whose revision is one past the cleared snapshot. + */ +clear(agent: Agent, ref: GoalRef): GoalRef +``` + +Clear the current goal while retaining a durable tombstone and history. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the tombstone ref whose revision is one past the cleared snapshot. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L295) From e9940d35cf5bd33b1c434a5c3f0b4c6695df2511 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:56:11 +0800 Subject: [PATCH 04/10] fix(goal): preserve structured domain error codes --- packages/goal/goal/src/runtime.ts | 10 ++++++---- packages/goal/goal/tests/goal.spec.ts | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts index 9cd656ccae..49184faa8c 100644 --- a/packages/goal/goal/src/runtime.ts +++ b/packages/goal/goal/src/runtime.ts @@ -1,5 +1,6 @@ /** Runtime constructors and protocol constants for the goal domain. */ +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts' /** Version of the goal change metadata embedded in `context/message`. */ @@ -15,13 +16,14 @@ export function GoalId(id: string): GoalIdType { } /** Error returned by the goal domain boundary. */ -export class GoalError extends Error { +export class GoalError extends HarnessError { /** * @param message - human-readable rejection reason. * @param code - stable machine-routable classification. */ - constructor(message: string, public readonly code: GoalErrorCode) { - super(message) - this.name = 'GoalError' + // Keep the constructor to narrow HarnessError's string code at this boundary. + // eslint-disable-next-line @typescript-eslint/no-useless-constructor -- type-only narrowing + constructor(message: string, code: GoalErrorCode) { + super(message, code) } } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 03b00dd699..c8aa5bc3e3 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import GoalService, { GoalError, @@ -161,6 +161,7 @@ describe('GoalService creation and replay', () => { code: 'GOAL_INVALID_MAX_ROUNDS', })) expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError) expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1 })).toThrow(GoalError) expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256) }) From 4d965414b6074a28f18e5ba1871211d490999d3d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:24:09 +0800 Subject: [PATCH 05/10] docs(goal): describe cache effect --- packages/goal/goal/README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 57f366c844..b59536ee1f 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -33,9 +33,17 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve ### Goal-state mutation -**What the model sees**: Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. +#### What the model sees -**Token effect**: Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields. +Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. + +#### Token effect + +Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields. + +#### KV Cache effect + +Append-only within an epoch: each mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary. ## Known Limitations and Deferred Work From 3c2e07a92d48c712f707603e78bab4954e03a33f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:38:28 +0800 Subject: [PATCH 06/10] fix(goal): make publication reentrancy-safe --- ...rsisted-same-session-goal-domain.i18n.yaml | 4 +- ...7-19-persisted-same-session-goal-domain.md | 5 +- ...9-persisted-same-session-goal-domain.zh.md | 5 +- docs/cordis-catalog/services.md | 2 +- docs/glossary.md | 2 +- packages/goal/goal/README.md | 3 +- packages/goal/goal/src/index.ts | 47 +++++++--- packages/goal/goal/tests/goal.spec.ts | 92 +++++++++++++++++++ website/zh-CN/api/harness/goals.md | 24 ++--- 9 files changed, 152 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index 86c1ae0715..d398150b73 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -2,5 +2,5 @@ # 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-19-persisted-same-session-goal-domain.md: 3404e24c3cc5880f0eeddbdbf35e01ad32063b74 -2026-07-19-persisted-same-session-goal-domain.zh.md: fb12aad5d7c035a2befc30ffd650dcd336233104 +2026-07-19-persisted-same-session-goal-domain.md: 1484a919851c18ce978c93c068f6096bbf3b733f +2026-07-19-persisted-same-session-goal-domain.zh.md: 8bcedc6cab6b9219a33a655e8fcbc53762b7413f diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md index 3404e24c3c..1484a91985 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -22,7 +22,7 @@ Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `c The replay fold validates JSON shape, source attribution, rendered content, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds are positive sequential `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. -When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; the durable log remains authoritative after restart. +When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; reentrant append observers project each mutation exactly once. Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart. ### Lifecycle and live activation @@ -36,7 +36,7 @@ The service accepts only the exact live `Agent` object registered under its id. ## Testing -Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. ## Alternatives considered @@ -59,4 +59,5 @@ Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set r - This domain records state but does not schedule goal rounds, cancel active turns, or classify abnormal stops. - The actor that records `complete` or `blocked` is authoritative; an independent evaluator or completion certificate is deferred to a policy consumer. - There is one current goal per session; parallel objective graphs and cross-session goal storage are absent. +- Plugins share one trusted process boundary. Direct session writers can counterfeit goal records; strict replay detects inconsistency and fails goal access at the offending record, but does not isolate plugins or repair the log. - `GOAL_CHANGE_VERSION` has no pre-release compatibility promise or migration path. diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index fb12aad5d7..8bcedc6cab 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -22,7 +22,7 @@ Status: implemented 回放折叠会校验 JSON 形状、来源归属、渲染内容、新 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 -当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重启后仍以持久日志为准。 +当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重入的追加观察器对每次变更只投影一次。增量回放会在每个有效事件后推进游标,并停留在首个损坏事件处,因此后续读取会报告同一个持久故障。重启后仍以持久日志为准。 ### 生命周期与实时激活态 @@ -36,7 +36,7 @@ Status: implemented ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 @@ -59,4 +59,5 @@ Status: implemented - 本领域记录状态,但不调度目标回合、不取消活跃轮次,也不分类异常停止。 - 记录 `complete` 或 `blocked` 的参与者具有最终权威;独立评估器或完成证书延期到策略消费者中实现。 - 每个会话只有一个当前目标;不存在并行目标图和跨会话目标存储。 +- 插件共享同一个受信任的进程边界。直接写入会话的插件可以伪造目标记录;严格回放会检测不一致并在违规记录处使目标访问失败,但不会隔离插件或修复日志。 - `GOAL_CHANGE_VERSION` 在首次发布前不承诺兼容性,也不提供迁移路径。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 934086dd97..7d33e49c13 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -577,7 +577,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalSpec](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:97`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:104`](../../packages/goal/goal/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/docs/glossary.md b/docs/glossary.md index 480ef461ff..0166f01f63 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -26,4 +26,4 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. - **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. -- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. +- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round). Round counters belong to that policy and do not count every turn in a session. diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 4247ccb5e6..0e0807f684 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -21,7 +21,7 @@ At most one goal is current. Creation produces an active revision-one goal and a Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. -Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. +Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. @@ -51,3 +51,4 @@ Append-only within an epoch: each mutation follows the reusable request prefix a - **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas. - **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer. - **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear. +- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal metadata. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation. diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 726ceb6c53..c8583894ef 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -64,12 +64,19 @@ export interface ResolvedConfig { defaultMaxGoalRounds: number } +/** One accepted mutation waiting to enter or be observed in the session log. */ +interface PendingGoalChange { + readonly change: GoalChangeMeta + readonly activation: GoalActivation + applied: boolean +} + /** Process-local cache plus mutations waiting in the active tool-batch FIFO. */ interface GoalCache { readonly state: GoalFoldState activation: GoalActivation observedSeq: number - readonly pending: GoalChangeMeta[] + readonly pending: PendingGoalChange[] } /** Validate a caller-visible positive safe-integer round cap. */ @@ -358,15 +365,21 @@ export class GoalService extends Service { const change = decodeGoalEvent(event) if (change !== undefined) { const pending = cache.pending[0] - if (pending !== undefined && sameChange(pending, change)) { + if (pending !== undefined && sameChange(pending.change, change)) { + if (!pending.applied) { + applyGoalChange(cache.state, change) + cache.activation = pending.activation + pending.applied = true + } cache.pending.shift() + cache.observedSeq += 1 continue } } } applyGoalEvent(cache.state, event) + cache.observedSeq += 1 } - cache.observedSeq = session.seq } /** Build a new revision with one replacement phase. */ @@ -464,14 +477,26 @@ export class GoalService extends Service { const meta = snapshotJsonValue(change) as JsonValue | undefined /* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */ if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable') - agent.inject(renderGoalChange(change), { - source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 }, - envelope: 'raw', - meta, - }) - cache.pending.push(change) - applyGoalChange(cache.state, change) - cache.activation = activation + const pending: PendingGoalChange = { change, activation, applied: false } + cache.pending.push(pending) + try { + agent.inject(renderGoalChange(change), { + source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 }, + envelope: 'raw', + meta, + }) + } catch (error: unknown) { + const index = cache.pending.indexOf(pending) + /* v8 ignore next -- a committed goal append cannot reject after its contained observers run */ + if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error }) + cache.pending.splice(index, 1) + throw error + } + if (!pending.applied) { + applyGoalChange(cache.state, change) + cache.activation = activation + pending.applied = true + } this.sync(agent.session, cache) const goal = this.view(cache) const notification: GoalChanged = { diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index c8aa5bc3e3..4dcae8938a 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -235,6 +235,25 @@ describe('GoalService creation and replay', () => { expect(() => foldGoal(session.events)).not.toThrow() }) + it('removes the service and its session-start listener with the providing fiber', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(GoalService) + const first = ctx.goals + const stub = stubAgent('goal-hmr') + ctx.agents.register(stub.agent) + const goal = first.create(stub.agent, { objective: 'survive service reload' }) + + await fiber.dispose() + expect(ctx.get('goals')).toBeUndefined() + agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume') + expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' }) + + await ctx.plugin(GoalService) + expect(ctx.goals).not.toBe(first) + expect(ctx.goals.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'disarmed' }) + }) + it('requires the exact live registry instance for reads and mutations', async () => { const { ctx, agent } = await harness() const impostor = { ...agent, session: new Session(agent.id) } @@ -404,6 +423,46 @@ describe('GoalService mutations', () => { expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) }) + it('publishes a mutation consistently to a reentrant session observer', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer'))) + ctx.agents.register(stub.agent) + let observed: ReturnType + ctx.on('session/event', (session, event) => { + if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent) + }) + + const created = ctx.goals.create(stub.agent, { objective: 'publish once' }) + + expect(observed).toEqual(created) + expect(ctx.goals.get(stub.agent)).toEqual(created) + expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } }) + }) + + it('rolls back a pending mutation when injection rejects before append', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const stub = stubAgent('goal-rejected-injection') + const append = stub.agent.inject.bind(stub.agent) + let reject = true + stub.agent.inject = (content, options) => { + if (reject) throw new Error('injection rejected') + append(content, options) + } + ctx.agents.register(stub.agent) + + expect(() => ctx.goals.create(stub.agent, { objective: 'first attempt' })).toThrow('injection rejected') + reject = false + expect(ctx.goals.create(stub.agent, { objective: 'second attempt' })).toMatchObject({ + objective: 'second attempt', + revision: 1, + }) + }) + it('rejects deferred goal mutations that enter the log out of FIFO order', async () => { const test = await harness() test.setDeferred(true) @@ -447,6 +506,39 @@ describe('GoalService mutations', () => { activation: 'disarmed', }) }) + + it('reports the same corrupt unseen event after committing its valid prefix', async () => { + const { ctx, agent, session } = await harness() + expect(ctx.goals.get(agent)).toBeUndefined() + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-valid-prefix'), + revision: 1, + objective: 'valid prefix', + phase: 'active', + maxGoalRounds: 4, + }, + roundsStarted: 0, + createdAt: 12, + updatedAt: 12, + } + appendInjection(session, renderGoalChange(change), { + source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 }, + envelope: 'raw', + meta: change as never, + }) + appendInjection(session, [{ type: 'text', text: 'corrupt' }], { + source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 }, + envelope: 'raw', + meta: { ...change, operation: 'edit', extra: true } as never, + }) + + expect(() => ctx.goals.get(agent)).toThrow('invalid shape') + expect(() => ctx.goals.get(agent)).toThrow('invalid shape') + }) }) describe('goal replay validation', () => { diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md index 146cb04193..8789074e36 100644 --- a/website/zh-CN/api/harness/goals.md +++ b/website/zh-CN/api/harness/goals.md @@ -6,7 +6,7 @@ Goal service (`ctx.goals`) backed exclusively by the owning session log. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L97) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L104) ### ctx.goals.resolveCreate(request) @@ -25,7 +25,7 @@ Materialize deployment defaults and validate one create request. **Returns** detached, fully resolved create specification. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L122) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L129) ### ctx.goals.get(agent) @@ -45,7 +45,7 @@ Read the current goal for one exact live agent. **Returns** a fresh view or `undefined` when no goal is current. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L142) ### ctx.goals.create(agent, request) @@ -67,7 +67,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha **Returns** the created live view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L149) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L156) ### ctx.goals.edit(agent, ref, request) @@ -90,7 +90,7 @@ Edit objective and/or round cap without changing phase. **Returns** the edited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L174) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L181) ### ctx.goals.pause(agent, ref) @@ -111,7 +111,7 @@ Pause an active goal and disarm automatic continuation. **Returns** the paused view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L195) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L202) ### ctx.goals.resume(agent, ref) @@ -133,7 +133,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg **Returns** the active view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L206) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L213) ### ctx.goals.complete(agent, ref) @@ -154,7 +154,7 @@ Mark a current non-complete goal complete and disarm it. **Returns** the completed view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L231) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L238) ### ctx.goals.block(agent, ref) @@ -175,7 +175,7 @@ Mark an active goal blocked and disarm it. **Returns** the blocked view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L248) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L255) ### ctx.goals.markUsageLimited(agent, ref) @@ -196,7 +196,7 @@ Mark an active goal stopped by an external usage limit. **Returns** the usage-limited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L258) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L265) ### ctx.goals.markBudgetLimited(agent, ref) @@ -217,7 +217,7 @@ Mark an active goal stopped at its configured round cap. **Returns** the budget-limited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L268) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L275) ### ctx.goals.clear(agent, ref) @@ -238,4 +238,4 @@ Clear the current goal while retaining a durable tombstone and history. **Returns** the tombstone ref whose revision is one past the cleared snapshot. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L295) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L302) From 1468773dcd7f2a6eb0ffa85978a7ad959a152995 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:39:40 +0800 Subject: [PATCH 07/10] fix(goal): simplify blockers into one durable phase --- ...rsisted-same-session-goal-domain.i18n.yaml | 4 +- ...7-19-persisted-same-session-goal-domain.md | 12 +- ...9-persisted-same-session-goal-domain.zh.md | 12 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 32 +----- docs/core-data-structures/goal.md | 26 +++-- docs/event-producer-consumer.md | 2 +- docs/glossary.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 28 ++--- packages/goal/goal/README.md | 8 +- packages/goal/goal/src/fold.ts | 61 +++++----- packages/goal/goal/src/index.ts | 96 ++++++++-------- packages/goal/goal/src/types.ts | 21 ++-- packages/goal/goal/tests/goal.spec.ts | 104 +++++++++++------- scripts/gen-cordis-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 2 +- website/zh-CN/api/harness/events.md | 2 +- website/zh-CN/api/harness/goals.md | 89 +++------------ 18 files changed, 219 insertions(+), 286 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index d398150b73..b960f69e71 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -2,5 +2,5 @@ # 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-19-persisted-same-session-goal-domain.md: 1484a919851c18ce978c93c068f6096bbf3b733f -2026-07-19-persisted-same-session-goal-domain.zh.md: 8bcedc6cab6b9219a33a655e8fcbc53762b7413f +2026-07-19-persisted-same-session-goal-domain.md: 75355aa8d94789e0cc227d393c50139708a73f6a +2026-07-19-persisted-same-session-goal-domain.zh.md: fd7e31f4b6a5acec1d2b44fb3088e301b36abd5c diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md index 1484a91985..75355aa8d9 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -12,13 +12,13 @@ Durable lifecycle and permission to continue are different facts. A session may ## Decision -`@deepseek-ai/dsh-goal` in `packages/goal/goal/` owns one current same-session goal through `ctx.goals`. A goal has a branded id, objective, durable phase, compare-and-set revision, and `maxGoalRounds`. `defaultMaxGoalRounds` is a validated deployment setting with default `256`; `resolveCreate()` materializes it before mutation. +`@deepseek-ai/dsh-goal` in `packages/goal/goal/` owns one current same-session goal through `ctx.goals`. A goal has a branded id, objective, durable phase, compare-and-set revision, and `maxGoalRounds`. `defaultMaxGoalRounds` is a validated deployment setting with default `256`; `create()` materializes it internally before mutation rather than exposing resolution as another service verb. -The durable phases are `active`, `paused`, `blocked`, `usage-limited`, `budget-limited`, and `complete`. A separate live activation is `armed` or `disarmed`. Creation and explicit resume arm activation; pause, completion, blocking, limit transitions, and clear disarm it. Edits preserve activation. Activation is never part of the persisted snapshot. +The durable phases are `active`, `paused`, `blocked`, and `complete`. A blocked snapshot includes a policy-owned lower-kebab-case code and a normalized free-form message, so usage limits, round caps, execution failures, and human-input dependencies share one lifecycle state without losing their cause. A separate live activation is `armed` or `disarmed`. Creation and explicit resume arm activation; pause, completion, blocking, and clear disarm it. Edits preserve activation and any blocker reason; resume and completion clear that reason. Activation is never part of the persisted snapshot. ### Durable record and replay -Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `context/message` containing a versioned full snapshot. Clear appends a revisioned tombstone. The context source is `{ kind: 'goal', goalId, revision, round: 0 }`; metadata and rendered `...` content must agree exactly. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. +Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `context/message` containing a versioned full snapshot. Clear appends a revisioned tombstone. The context source is `{ kind: 'goal', goalId, revision, round: 0 }`; metadata and rendered `...` content must agree exactly. This descriptive delimiter follows the repository's existing `` convention and [Anthropic's published guidance to structure mixed prompt content with consistent descriptive XML tags](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags). That is public model-experience prior art, not evidence about any provider's proprietary training corpus. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. The replay fold validates JSON shape, source attribution, rendered content, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds are positive sequential `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. @@ -26,7 +26,7 @@ When `Agent.inject()` defers a mutation inside an active tool batch, the service ### Lifecycle and live activation -At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a stopped phase or a disarmed active goal only when the round cap has remaining capacity; budget limiting requires the admitted count to have reached the cap. +At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a paused or blocked phase, or a disarmed active goal, only when the round cap has remaining capacity. The domain validates blocker reason shape but deliberately leaves reason codes and the decision to block to policy consumers. A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. Resume and fork therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. @@ -36,7 +36,7 @@ The service accepts only the exact live `Agent` object registered under its id. ## Testing -Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. ## Alternatives considered @@ -52,7 +52,7 @@ Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set r - Resume and fork expose the same durable phase while remaining operationally inert until an explicit resume mutation arms activation. - Full snapshots simplify inspection and strict replay but repeat the objective and state fields in model history until compaction shadows them. - Revision and lifecycle validation reject tampered, partially written, or producer-inconsistent goal records early. -- Round caps bound continuation count only; token, currency, time, and provider limits remain separate policy concerns. +- Round caps bound continuation count only; policy consumers map round, token, currency, time, and provider limits to blocked reasons when they stop work. ## Known limitations and deferred work diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index 8bcedc6cab..fd7e31f4b6 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含品牌化 id、目标描述、持久阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`resolveCreate()` 在变更前将其解析为完整值。 +位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含品牌化 id、目标描述、持久阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`create()` 在变更前于内部将其解析为完整值,而不会把解析过程暴露为额外的服务动词。 -持久阶段包括 `active`、`paused`、`blocked`、`usage-limited`、`budget-limited` 和 `complete`。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞、达到限制和清除都会解除激活。编辑保留激活态。持久快照绝不包含激活态。 +持久阶段包括 `active`、`paused`、`blocked` 和 `complete`。阻塞快照包含由策略提供的 kebab-case 小写代码和规范化自由文本消息,因此用量限制、回合上限、执行失败和等待人工输入可以共享一个生命周期状态而不丢失原因。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞和清除都会解除激活。编辑保留激活态及阻塞原因;恢复和完成会清除该原因。持久快照绝不包含激活态。 ### 持久记录与回放 -每次非清除变更都通过 `Agent.inject()` 追加一条原始且模型可见的 `context/message`,其中包含带版本的完整快照。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `...` 内容完全一致。会话日志是唯一的持久事实来源,因此持久化和 fork 会继承目标记录,而无需另设数据库或头字段。 +每次非清除变更都通过 `Agent.inject()` 追加一条原始且模型可见的 `context/message`,其中包含带版本的完整快照。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `...` 内容完全一致。这个描述性分隔符沿用了仓库已有的 `` 约定,也符合 [Anthropic 关于用一致且描述明确的 XML 标签组织混合提示词内容的公开指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags)。这是公开的模型体验先例,并非对任何提供方专有训练语料的推断。会话日志是唯一的持久事实来源,因此持久化和 fork 会继承目标记录,而无需另设数据库或头字段。 回放折叠会校验 JSON 形状、来源归属、渲染内容、新 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 @@ -26,7 +26,7 @@ Status: implemented ### 生命周期与实时激活态 -最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,停止阶段或已解除激活的活跃目标才能恢复;只有已接纳回合数达到上限后,才能标记预算受限。 +最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,暂停或阻塞阶段以及已解除激活的活跃目标才能恢复。领域层校验阻塞原因的形状,但会把原因代码和是否阻塞的决策留给策略消费者。 从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。因此,恢复和 fork 会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 @@ -36,7 +36,7 @@ Status: implemented ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 @@ -52,7 +52,7 @@ Status: implemented - 恢复与 fork 会暴露同一持久阶段,但在显式恢复变更激活目标前不会执行任何操作。 - 完整快照便于检查和严格回放,但在压缩隐藏它们之前,会在模型历史中重复目标描述与状态字段。 - 修订号与生命周期校验会尽早拒绝遭篡改、部分写入或生产者不一致的目标记录。 -- 回合上限只约束继续执行次数;token、费用、时间和提供方限制仍属于独立策略。 +- 回合上限只约束继续执行次数;当回合、token、费用、时间或提供方限制停止工作时,策略消费者会把它们映射为不同的阻塞原因。 ## 已知限制与延期工作 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c90eacde5e..92f3b16240 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -471,7 +471,7 @@ Goal mutation accepted by one live agent. The matching context event is already Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/types.ts:166`](../../packages/goal/goal/src/types.ts) +Source: [`packages/goal/goal/src/types.ts:167`](../../packages/goal/goal/src/types.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7d33e49c13..bf639bed08 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -484,13 +484,6 @@ Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) Goal service (`ctx.goals`) backed exclusively by the owning session log. ```ts cordis-catalog -/** - * Materialize deployment defaults and validate one create request. - * @param request - objective plus optional caller-selected round cap. - * @returns detached, fully resolved create specification. - */ -resolveCreate(request: CreateGoalRequest): CreateGoalSpec - /** * Read the current goal for one exact live agent. * @param agent - owning live agent. @@ -546,25 +539,10 @@ complete(agent: Agent, ref: GoalRef): GoalView * Mark an active goal blocked and disarm it. * @param agent - owning live agent. * @param ref - expected current revision. - * @returns the blocked view. + * @param reason - policy-owned stable code and human-readable explanation. + * @returns the blocked view with its durable reason. */ -block(agent: Agent, ref: GoalRef): GoalView - -/** - * Mark an active goal stopped by an external usage limit. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the usage-limited view. - */ -markUsageLimited(agent: Agent, ref: GoalRef): GoalView - -/** - * Mark an active goal stopped at its configured round cap. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the budget-limited view. - */ -markBudgetLimited(agent: Agent, ref: GoalRef): GoalView +block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView /** * Clear the current goal while retaining a durable tombstone and history. @@ -575,9 +553,9 @@ markBudgetLimited(agent: Agent, ref: GoalRef): GoalView clear(agent: Agent, ref: GoalRef): GoalRef ``` -Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalSpec](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) +Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:104`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:131`](../../packages/goal/goal/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index d7b8b25fb4..6c4ac165f7 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -24,11 +24,21 @@ type GoalPhase = | 'active' | 'paused' | 'blocked' - | 'usage-limited' - | 'budget-limited' | 'complete' ``` +Blocking is the single durable stopped-by-a-problem state. Its policy-owned reason carries a stable lower-kebab-case code for routing and a free-form explanation for humans and models. + +```ts type-equiv +/** Machine-routable and human-readable explanation for a blocked goal. */ +interface GoalBlockReason { + /** Stable lower-kebab-case classification chosen by the blocking policy. */ + readonly code: string + /** Non-empty explanation shown to humans and models. */ + readonly message: string +} +``` + ```ts type-equiv /** Full durable state written by every non-clear goal mutation. */ interface GoalSnapshot extends GoalRef { @@ -36,6 +46,8 @@ interface GoalSnapshot extends GoalRef { readonly objective: string /** Durable lifecycle phase. */ readonly phase: GoalPhase + /** Present exactly while `phase` is `blocked`. */ + readonly blockedReason?: GoalBlockReason /** Total admitted goal-round cap. */ readonly maxGoalRounds: number } @@ -98,7 +110,7 @@ interface GoalMessageSource { ## Requests and notifications -Creation separates caller omission from the resolved deployment choice. An edit is a partial replacement whose runtime validator requires at least one field. Every mutation notification carries the accepted operation and exact revision; clear omits `goal`. +Creation separates caller omission from the deployment choice, which `create()` resolves internally. An edit is a partial replacement whose runtime validator requires at least one field. Every mutation notification carries the accepted operation and exact revision; clear omits `goal`. ```ts type-equiv /** Input whose omitted round cap is resolved by the service configuration. */ @@ -108,14 +120,6 @@ interface CreateGoalRequest { } ``` -```ts type-equiv -/** Validated create input with every deployment default materialized. */ -interface CreateGoalSpec { - readonly objective: string - readonly maxGoalRounds: number -} -``` - ```ts type-equiv /** Fields changed by an edit; at least one must be present. */ interface EditGoalRequest { diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8f1fd333fe..e0d5914f6c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:166`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | +| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/glossary.md b/docs/glossary.md index 0166f01f63..13b800d9a8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -18,7 +18,7 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i ## goal -- **goal** — one durable completion objective attached to an existing session, with a revisioned lifecycle phase and a goal-round cap. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. +- **goal** — one durable completion objective attached to an existing session, with a revisioned `active` / `paused` / `blocked` / `complete` phase and a goal-round cap; `blocked` retains a policy code and explanation. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. - **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. - **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 38242445df..ba53f5c0ec 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -254,10 +254,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'goals', summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.', methods: [ - { - signature: 'resolveCreate(request: CreateGoalRequest): CreateGoalSpec', - jsDoc: '/**\n * Materialize deployment defaults and validate one create request.\n * @param request - objective plus optional caller-selected round cap.\n * @returns detached, fully resolved create specification.\n */', - }, { signature: 'get(agent: Agent): GoalView | undefined', jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */', @@ -283,16 +279,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */', }, { - signature: 'block(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the blocked view.\n */', - }, - { - signature: 'markUsageLimited(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Mark an active goal stopped by an external usage limit.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the usage-limited view.\n */', - }, - { - signature: 'markBudgetLimited(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Mark an active goal stopped at its configured round cap.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the budget-limited view.\n */', + signature: 'block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView', + jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */', }, { signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', @@ -1137,10 +1125,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateGoalRequest', declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}', }, - { - name: 'CreateGoalSpec', - declaration: 'export interface CreateGoalSpec {\n readonly objective: string;\n readonly maxGoalRounds: number;\n}', - }, { name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}', @@ -1241,13 +1225,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GoalActivation', declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';', }, + { + name: 'GoalBlockReason', + declaration: 'export interface GoalBlockReason {\n readonly code: string;\n readonly message: string;\n}', + }, { name: 'GoalId', declaration: 'export type GoalId = Branded<\'GoalId\'>;', }, { name: 'GoalPhase', - declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'usage-limited\' | \'budget-limited\' | \'complete\';', + declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'complete\';', }, { name: 'GoalRef', @@ -1255,7 +1243,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'GoalSnapshot', - declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly maxGoalRounds: number;\n}', + declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly blockedReason?: GoalBlockReason;\n readonly maxGoalRounds: number;\n}', }, { name: 'GoalView', diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 0e0807f684..175d0d0d79 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -11,13 +11,13 @@ Event-sourced same-session goal state. The service retains one current completio defaultMaxGoalRounds: 256 ``` -`defaultMaxGoalRounds` must be a positive safe integer. `resolveCreate()` materializes this deployment default before `create()` commits a goal; a request-level value overrides it. +`defaultMaxGoalRounds` must be a positive safe integer. `create()` materializes this deployment default internally before committing a goal; a request-level value overrides it. ## Service contract -`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). +`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is an internal implementation step, not an additional public verb. -At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; an active armed goal rejects the redundant operation. +At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation. Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. @@ -35,7 +35,7 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve #### What the model sees -Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. +Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus. #### Token effect diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index d7256a38db..5f80e7fd4c 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -6,6 +6,7 @@ import { renderGoalChange } from './render.ts' import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts' import type { FoldedGoal, + GoalBlockReason, GoalChangeMeta, GoalClearChangeMeta, GoalMessageSource, @@ -25,17 +26,8 @@ const SNAPSHOT_OPERATIONS: ReadonlySet> = new Se 'resume', 'complete', 'block', - 'mark-usage-limited', - 'mark-budget-limited', -]) -const PHASES: ReadonlySet = new Set([ - 'active', - 'paused', - 'blocked', - 'usage-limited', - 'budget-limited', - 'complete', ]) +const PHASES: ReadonlySet = new Set(['active', 'paused', 'blocked', 'complete']) /** Mutable accumulator kept private to the pure fold. */ export interface GoalFoldState { @@ -83,13 +75,24 @@ function nonNegativeInteger(value: unknown, field: string): number { return value } +/** Decode one canonical blocker explanation. */ +function decodeBlockReason(value: unknown): GoalBlockReason { + if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') { + throw new Error('goal change goal.blockedReason has an invalid shape') + } + if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) { + throw new Error('goal change goal.blockedReason.code must be lower-kebab-case') + } + if (typeof value['message'] !== 'string' || value['message'].trim().length === 0 + || value['message'] !== value['message'].trim()) { + throw new Error('goal change goal.blockedReason.message must be non-empty and normalized') + } + return { code: value['code'], message: value['message'] } +} + /** Decode and validate one snapshot. */ function decodeSnapshot(value: unknown): GoalSnapshot { if (!isRecord(value)) throw new Error('goal change goal must be a record') - const keys = Object.keys(value).sort() - if (keys.join(',') !== 'id,maxGoalRounds,objective,phase,revision') { - throw new Error('goal change goal has an invalid shape') - } if (typeof value['id'] !== 'string' || value['id'].length === 0) { throw new Error('goal change goal.id must be a non-empty string') } @@ -100,12 +103,20 @@ function decodeSnapshot(value: unknown): GoalSnapshot { if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) { throw new Error('goal change goal.phase is invalid') } + const phase = value['phase'] as GoalPhase + const expectedKeys = phase === 'blocked' + ? 'blockedReason,id,maxGoalRounds,objective,phase,revision' + : 'id,maxGoalRounds,objective,phase,revision' + if (Object.keys(value).sort().join(',') !== expectedKeys) { + throw new Error('goal change goal has an invalid shape') + } return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'goal.revision'), objective: value['objective'], - phase: value['phase'] as GoalPhase, + phase, maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'), + ...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {}, } } @@ -208,7 +219,10 @@ function validateSnapshotTransition( } switch (change.operation) { case 'edit': - if (next.phase !== current.phase) throw new Error('goal edit cannot change phase') + if (next.phase !== current.phase + || JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) { + throw new Error('goal edit cannot change phase or blocked reason') + } break case 'pause': requireSameDefinition(current, next, change.operation) @@ -220,8 +234,6 @@ function validateSnapshotTransition( 'active', 'paused', 'blocked', - 'usage-limited', - 'budget-limited', ]) if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) { throw new Error('goal resume has an invalid phase transition or exhausted round budget') @@ -236,19 +248,6 @@ function validateSnapshotTransition( requireSameDefinition(current, next, change.operation) if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition') break - case 'mark-usage-limited': - requireSameDefinition(current, next, change.operation) - if (current.phase !== 'active' || next.phase !== 'usage-limited') { - throw new Error('goal mark-usage-limited has an invalid phase transition') - } - break - case 'mark-budget-limited': - requireSameDefinition(current, next, change.operation) - if (current.phase !== 'active' || next.phase !== 'budget-limited' - || state.roundsStarted < next.maxGoalRounds) { - throw new Error('goal mark-budget-limited has an invalid phase transition or remaining round budget') - } - break /* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */ case 'create': throw new Error('goal create cannot be validated as a current-goal transition') diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index c8583894ef..dc485bcc8a 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -27,9 +27,9 @@ import { } from './runtime.ts' import type { CreateGoalRequest, - CreateGoalSpec, EditGoalRequest, GoalActivation, + GoalBlockReason, GoalChangeMeta, GoalChanged, GoalClearChangeMeta, @@ -79,6 +79,12 @@ interface GoalCache { readonly pending: PendingGoalChange[] } +/** Validated create input with every deployment default materialized. */ +interface ResolvedCreateGoal { + readonly objective: string + readonly maxGoalRounds: number +} + /** Validate a caller-visible positive safe-integer round cap. */ function resolveMaxGoalRounds(value: number): number { if (!Number.isSafeInteger(value) || value < 1) { @@ -95,6 +101,31 @@ function resolveObjective(value: string): string { return value.trim() } +/** Materialize deployment defaults and validate one create request. */ +function resolveCreateGoal(request: CreateGoalRequest, defaultMaxGoalRounds: number): ResolvedCreateGoal { + return { + objective: resolveObjective(request.objective), + maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds), + } +} + +/** Validate and detach one policy-owned blocker explanation. */ +function resolveBlockReason(reason: unknown): GoalBlockReason { + const record = typeof reason === 'object' && reason !== null && !Array.isArray(reason) + ? reason as Record + : undefined + const code = record?.['code'] + const message = record?.['message'] + if (typeof code !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code) + || typeof message !== 'string' || message.trim().length === 0) { + throw new GoalError( + 'goal block reason requires a lower-kebab-case code and a non-empty message', + 'GOAL_INVALID_BLOCK_REASON', + ) + } + return { code, message: message.trim() } +} + /** Compare the complete canonical payloads used for deferred reconciliation. */ function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean { return JSON.stringify(left) === JSON.stringify(right) @@ -121,18 +152,6 @@ export class GoalService extends Service { }) } - /** - * Materialize deployment defaults and validate one create request. - * @param request - objective plus optional caller-selected round cap. - * @returns detached, fully resolved create specification. - */ - resolveCreate(request: CreateGoalRequest): CreateGoalSpec { - return { - objective: resolveObjective(request.objective), - maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? this.resolved.defaultMaxGoalRounds), - } - } - /** * Read the current goal for one exact live agent. * @param agent - owning live agent. @@ -154,7 +173,7 @@ export class GoalService extends Service { * @returns the created live view. */ create(agent: Agent, request: CreateGoalRequest): GoalView { - const spec = this.resolveCreate(request) + const spec = resolveCreateGoal(request, this.resolved.defaultMaxGoalRounds) const cache = this.prepareMutation(agent) const current = cache.state.goal if (current !== undefined && current.phase !== 'complete') { @@ -213,7 +232,7 @@ export class GoalService extends Service { resume(agent: Agent, ref: GoalRef): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) - const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'] + const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked'] if (!resumable.includes(current.phase)) { throw this.transitionError(current, 'resume', resumable) } @@ -240,7 +259,7 @@ export class GoalService extends Service { agent, ref, 'complete', - ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'], + ['active', 'paused', 'blocked'], 'complete', 'disarmed', ) @@ -250,45 +269,20 @@ export class GoalService extends Service { * Mark an active goal blocked and disarm it. * @param agent - owning live agent. * @param ref - expected current revision. - * @returns the blocked view. + * @param reason - policy-owned stable code and human-readable explanation. + * @returns the blocked view with its durable reason. */ - block(agent: Agent, ref: GoalRef): GoalView { - return this.transition(agent, ref, 'block', ['active'], 'blocked', 'disarmed') - } - - /** - * Mark an active goal stopped by an external usage limit. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the usage-limited view. - */ - markUsageLimited(agent: Agent, ref: GoalRef): GoalView { - return this.transition(agent, ref, 'mark-usage-limited', ['active'], 'usage-limited', 'disarmed') - } - - /** - * Mark an active goal stopped at its configured round cap. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the budget-limited view. - */ - markBudgetLimited(agent: Agent, ref: GoalRef): GoalView { + block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) if (current.phase !== 'active') { - throw this.transitionError(current, 'mark-budget-limited', ['active']) - } - if (cache.state.roundsStarted < current.maxGoalRounds) { - throw new GoalError( - `goal "${current.id}" has started ${cache.state.roundsStarted}/${current.maxGoalRounds} rounds`, - 'GOAL_INVALID_TRANSITION', - ) + throw this.transitionError(current, 'block', ['active']) } return this.commitCurrent( agent, cache, - 'mark-budget-limited', - this.withPhase(current, 'budget-limited'), + 'block', + { ...this.withPhase(current, 'blocked'), blockedReason: resolveBlockReason(reason) }, 'disarmed', ) } @@ -384,7 +378,13 @@ export class GoalService extends Service { /** Build a new revision with one replacement phase. */ private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot { - return { ...current, revision: current.revision + 1, phase } + return { + id: current.id, + revision: current.revision + 1, + objective: current.objective, + phase, + maxGoalRounds: current.maxGoalRounds, + } } /** Shared validated phase transition. */ diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index ef40e997ef..2c6798718d 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -22,16 +22,24 @@ export type GoalPhase = | 'active' | 'paused' | 'blocked' - | 'usage-limited' - | 'budget-limited' | 'complete' +/** Machine-routable and human-readable explanation for a blocked goal. */ +export interface GoalBlockReason { + /** Stable lower-kebab-case classification chosen by the blocking policy. */ + readonly code: string + /** Non-empty explanation shown to humans and models. */ + readonly message: string +} + /** Full durable state written by every non-clear goal mutation. */ export interface GoalSnapshot extends GoalRef { /** Human-requested completion objective. */ readonly objective: string /** Durable lifecycle phase. */ readonly phase: GoalPhase + /** Present exactly while `phase` is `blocked`. */ + readonly blockedReason?: GoalBlockReason /** Total admitted goal-round cap. */ readonly maxGoalRounds: number } @@ -59,8 +67,6 @@ export type GoalOperation = | 'resume' | 'complete' | 'block' - | 'mark-usage-limited' - | 'mark-budget-limited' | 'clear' /** Full-snapshot goal mutation retained in a model-visible context event. */ @@ -121,12 +127,6 @@ export interface CreateGoalRequest { readonly maxGoalRounds?: number } -/** Validated create input with every deployment default materialized. */ -export interface CreateGoalSpec { - readonly objective: string - readonly maxGoalRounds: number -} - /** Fields changed by an edit; at least one must be present. */ export interface EditGoalRequest { readonly objective?: string @@ -149,6 +149,7 @@ export type GoalErrorCode = | 'GOAL_STALE_REVISION' | 'GOAL_INVALID_OBJECTIVE' | 'GOAL_INVALID_MAX_ROUNDS' + | 'GOAL_INVALID_BLOCK_REASON' | 'GOAL_INVALID_EDIT' | 'GOAL_INVALID_TRANSITION' diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 4dcae8938a..eac96e0374 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -111,17 +111,13 @@ function appendRound(session: Session, ref: GoalRef, round: number): void { } describe('GoalService creation and replay', () => { - it('resolves the configured default and writes one balanced raw context snapshot', async () => { + it('applies the configured default and writes one balanced raw context snapshot', async () => { vi.useFakeTimers() vi.setSystemTime(1_700_000_000_000) const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) const seen: string[] = [] ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) - expect(ctx.goals.resolveCreate({ objective: ' finish the feature ' })).toEqual({ - objective: 'finish the feature', - maxGoalRounds: 17, - }) const goal = ctx.goals.create(agent, { objective: ' finish the feature ' }) expect(goal).toMatchObject({ @@ -151,18 +147,19 @@ describe('GoalService creation and replay', () => { vi.useRealTimers() }) - it('uses 256 rounds by default and validates create input at the owning resolver', async () => { + it('uses 256 rounds by default and validates create input inside create', async () => { const { ctx, agent } = await harness() - expect(ctx.goals.resolveCreate({ objective: 'x' })).toEqual({ objective: 'x', maxGoalRounds: 256 }) - expect(() => ctx.goals.resolveCreate({ objective: ' ' })).toThrow(expect.objectContaining({ + expect(() => ctx.goals.create(agent, { objective: ' ' })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_OBJECTIVE', })) - expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({ + expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_MAX_ROUNDS', })) - expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError) - expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError) - expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1 })).toThrow(GoalError) + expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError) + expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError) + expect(() => ctx.goals.create(agent, { + objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1, + })).toThrow(GoalError) expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256) }) @@ -170,9 +167,10 @@ describe('GoalService creation and replay', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const goals = new GoalService(ctx) - expect(goals.resolveCreate({ objective: 'direct' })).toEqual({ - objective: 'direct', - maxGoalRounds: 256, + const stub = stubAgent('goal-direct-construction') + ctx.agents.register(stub.agent) + expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({ + objective: 'direct', maxGoalRounds: 256, }) }) @@ -287,18 +285,19 @@ describe('GoalService mutations', () => { })) }) - it('supports pause, resume, block, usage-limit, and completion transitions', async () => { + it('supports pause, resume, block, and completion transitions', async () => { const { ctx, agent } = await harness() let goal = ctx.goals.create(agent, { objective: 'lifecycle' }) goal = ctx.goals.pause(agent, goal) expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 }) goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 }) - goal = ctx.goals.block(agent, goal) - expect(goal).toMatchObject({ phase: 'blocked', activation: 'disarmed' }) - goal = ctx.goals.resume(agent, goal) - goal = ctx.goals.markUsageLimited(agent, goal) - expect(goal.phase).toBe('usage-limited') + goal = ctx.goals.block(agent, goal, { code: 'needs-input', message: 'A choice is required.' }) + expect(goal).toMatchObject({ + phase: 'blocked', + blockedReason: { code: 'needs-input', message: 'A choice is required.' }, + activation: 'disarmed', + }) goal = ctx.goals.resume(agent, goal) goal = ctx.goals.pause(agent, goal) goal = ctx.goals.complete(agent, goal) @@ -307,15 +306,13 @@ describe('GoalService mutations', () => { }) it('allows completion from every stopped phase and replacement only after completion', async () => { - const phases = ['paused', 'blocked', 'usage-limited'] as const + const phases = ['paused', 'blocked'] as const for (const phase of phases) { const { ctx, agent } = await harness() let goal = ctx.goals.create(agent, { objective: phase }) goal = phase === 'paused' ? ctx.goals.pause(agent, goal) - : phase === 'blocked' - ? ctx.goals.block(agent, goal) - : ctx.goals.markUsageLimited(agent, goal) + : ctx.goals.block(agent, goal, { code: 'test-blocker', message: 'Blocked for the test.' }) const complete = ctx.goals.complete(agent, goal) const replacement = ctx.goals.create(agent, { objective: `after ${phase}` }) expect(complete.phase).toBe('complete') @@ -333,32 +330,45 @@ describe('GoalService mutations', () => { expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) const paused = ctx.goals.pause(agent, goal) expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) - expect(() => ctx.goals.block(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) - expect(() => ctx.goals.markUsageLimited(agent, paused)).toThrow(expect.objectContaining({ - code: 'GOAL_INVALID_TRANSITION', - })) - expect(() => ctx.goals.markBudgetLimited(agent, paused)).toThrow(expect.objectContaining({ + expect(() => ctx.goals.block(agent, paused, { + code: 'test-blocker', message: 'Blocked for the test.', + })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION', })) }) - it('enforces the goal-round cap before budget limiting and resuming', async () => { + it('records canonical blocker reasons and enforces the round cap on resume', async () => { const { ctx, agent, session } = await harness() let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 }) + for (const reason of [null, [], { code: 1, message: 'invalid code' }, { code: 'round-limit', message: 1 }]) { + expect(() => ctx.goals.block(agent, goal, reason as never)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_BLOCK_REASON', + })) + } + expect(() => ctx.goals.block(agent, goal, { + code: 'Not Canonical', message: 'invalid code', + })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' })) + expect(() => ctx.goals.block(agent, goal, { + code: 'round-limit', message: ' ', + })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' })) appendRound(session, goal, 1) expect(ctx.goals.get(agent)?.roundsStarted).toBe(1) - expect(() => ctx.goals.markBudgetLimited(agent, goal)).toThrow(expect.objectContaining({ - code: 'GOAL_INVALID_TRANSITION', - })) appendRound(session, goal, 2) - goal = ctx.goals.markBudgetLimited(agent, goal) - expect(goal).toMatchObject({ phase: 'budget-limited', roundsStarted: 2, activation: 'disarmed' }) + goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: ' Goal round limit reached. ' }) + expect(goal).toMatchObject({ + phase: 'blocked', + blockedReason: { code: 'round-limit', message: 'Goal round limit reached.' }, + roundsStarted: 2, + activation: 'disarmed', + }) expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 }) + expect(goal.blockedReason).toEqual({ code: 'round-limit', message: 'Goal round limit reached.' }) goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' }) + expect(goal.blockedReason).toBeUndefined() appendRound(session, goal, 3) - goal = ctx.goals.markBudgetLimited(agent, goal) + goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: 'Goal round limit reached.' }) expect(ctx.goals.complete(agent, goal).phase).toBe('complete') }) @@ -598,7 +608,16 @@ describe('goal replay validation', () => { return { ...current, operation, - goal: { ...current.goal, revision: current.goal.revision + 1, phase }, + goal: { + id: current.goal.id, + revision: current.goal.revision + 1, + objective: current.goal.objective, + phase, + ...phase === 'blocked' + ? { blockedReason: { code: 'test-blocker', message: 'Blocked for replay validation.' } } + : {}, + maxGoalRounds: current.goal.maxGoalRounds, + }, updatedAt: current.updatedAt + 1, ...overrides, } @@ -694,9 +713,6 @@ describe('goal replay validation', () => { mutation(base, 'resume', 'paused'), mutation(base, 'complete', 'active'), mutation(base, 'block', 'active'), - mutation(base, 'mark-usage-limited', 'active'), - mutation(base, 'mark-budget-limited', 'active'), - mutation(base, 'mark-budget-limited', 'budget-limited'), ] for (const change of invalid) expect(() => foldPair(base, change)).toThrow() @@ -782,6 +798,12 @@ describe('goal replay validation', () => { { ...base.goal, objective: ' ' }, { ...base.goal, objective: ' padded ' }, { ...base.goal, phase: 'unknown' }, + { ...base.goal, blockedReason: { code: 'unexpected', message: 'Only blocked goals have reasons.' } }, + { ...base.goal, phase: 'blocked' }, + { ...base.goal, phase: 'blocked', blockedReason: null }, + { ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: 'Valid.', extra: true } }, + { ...base.goal, phase: 'blocked', blockedReason: { code: 'NOT_CANONICAL', message: 'Bad code.' } }, + { ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: ' padded ' } }, { ...base.goal, revision: 0 }, { ...base.goal, maxGoalRounds: -1 }, ] diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 289a2e9778..ac59617e98 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -69,8 +69,8 @@ export const LINK_MAP: Record = { FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', CreateGoalRequest: 'goal.md', - CreateGoalSpec: 'goal.md', EditGoalRequest: 'goal.md', + GoalBlockReason: 'goal.md', GoalChanged: 'goal.md', GoalRef: 'goal.md', GoalView: 'goal.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1092c6c0b0..36b1e3816f 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -29,13 +29,13 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalRef", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalPhase", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalBlockReason", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshot", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshotChangeMeta", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalClearChangeMeta", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalMessageSource", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", "source": "packages/goal/goal/src/types.ts" }, - { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalSpec", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index ebe71dc559..6c7bcb3603 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -543,7 +543,7 @@ Goal mutation accepted by one live agent. The matching context event is already - `agent` — agent whose session owns the goal. - `change` — fresh current projection or clear tombstone. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/types.ts#L166) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/types.ts#L167) ## llm/* diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md index 8789074e36..38c2daaafc 100644 --- a/website/zh-CN/api/harness/goals.md +++ b/website/zh-CN/api/harness/goals.md @@ -6,26 +6,7 @@ Goal service (`ctx.goals`) backed exclusively by the owning session log. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L104) - -### ctx.goals.resolveCreate(request) - -```ts website-api -/** - * Materialize deployment defaults and validate one create request. - * @param request - objective plus optional caller-selected round cap. - * @returns detached, fully resolved create specification. - */ -resolveCreate(request: CreateGoalRequest): CreateGoalSpec -``` - -Materialize deployment defaults and validate one create request. - -- `request` — objective plus optional caller-selected round cap. - -**Returns** detached, fully resolved create specification. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L129) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L131) ### ctx.goals.get(agent) @@ -45,7 +26,7 @@ Read the current goal for one exact live agent. **Returns** a fresh view or `undefined` when no goal is current. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L142) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L157) ### ctx.goals.create(agent, request) @@ -67,7 +48,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha **Returns** the created live view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L171) ### ctx.goals.edit(agent, ref, request) @@ -90,7 +71,7 @@ Edit objective and/or round cap without changing phase. **Returns** the edited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L181) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L196) ### ctx.goals.pause(agent, ref) @@ -111,7 +92,7 @@ Pause an active goal and disarm automatic continuation. **Returns** the paused view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L202) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L217) ### ctx.goals.resume(agent, ref) @@ -133,7 +114,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg **Returns** the active view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L213) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L228) ### ctx.goals.complete(agent, ref) @@ -154,70 +135,30 @@ Mark a current non-complete goal complete and disarm it. **Returns** the completed view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L238) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L253) -### ctx.goals.block(agent, ref) +### ctx.goals.block(agent, ref, reason) ```ts website-api /** * Mark an active goal blocked and disarm it. * @param agent - owning live agent. * @param ref - expected current revision. - * @returns the blocked view. + * @param reason - policy-owned stable code and human-readable explanation. + * @returns the blocked view with its durable reason. */ -block(agent: Agent, ref: GoalRef): GoalView +block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView ``` Mark an active goal blocked and disarm it. - `agent` — owning live agent. - `ref` — expected current revision. +- `reason` — policy-owned stable code and human-readable explanation. -**Returns** the blocked view. +**Returns** the blocked view with its durable reason. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L255) - -### ctx.goals.markUsageLimited(agent, ref) - -```ts website-api -/** - * Mark an active goal stopped by an external usage limit. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the usage-limited view. - */ -markUsageLimited(agent: Agent, ref: GoalRef): GoalView -``` - -Mark an active goal stopped by an external usage limit. - -- `agent` — owning live agent. -- `ref` — expected current revision. - -**Returns** the usage-limited view. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L265) - -### ctx.goals.markBudgetLimited(agent, ref) - -```ts website-api -/** - * Mark an active goal stopped at its configured round cap. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the budget-limited view. - */ -markBudgetLimited(agent: Agent, ref: GoalRef): GoalView -``` - -Mark an active goal stopped at its configured round cap. - -- `agent` — owning live agent. -- `ref` — expected current revision. - -**Returns** the budget-limited view. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L275) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L271) ### ctx.goals.clear(agent, ref) @@ -238,4 +179,4 @@ Clear the current goal while retaining a durable tombstone and history. **Returns** the tombstone ref whose revision is one past the cleared snapshot. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L302) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L292) From 63d1f7679cbc6bbbdec710f5660fdade1a625145 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:50:29 +0800 Subject: [PATCH 08/10] docs(goal): refresh generated service references --- docs/cordis-catalog/services.md | 2 +- website/zh-CN/api/harness/goals.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index bf639bed08..67884e71c2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -555,7 +555,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:131`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md index 38c2daaafc..ee86d7be62 100644 --- a/website/zh-CN/api/harness/goals.md +++ b/website/zh-CN/api/harness/goals.md @@ -6,7 +6,7 @@ Goal service (`ctx.goals`) backed exclusively by the owning session log. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L131) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135) ### ctx.goals.get(agent) @@ -26,7 +26,7 @@ Read the current goal for one exact live agent. **Returns** a fresh view or `undefined` when no goal is current. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L157) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L161) ### ctx.goals.create(agent, request) @@ -48,7 +48,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha **Returns** the created live view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L171) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L175) ### ctx.goals.edit(agent, ref, request) @@ -71,7 +71,7 @@ Edit objective and/or round cap without changing phase. **Returns** the edited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L196) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L200) ### ctx.goals.pause(agent, ref) @@ -92,7 +92,7 @@ Pause an active goal and disarm automatic continuation. **Returns** the paused view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L217) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L221) ### ctx.goals.resume(agent, ref) @@ -114,7 +114,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg **Returns** the active view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L228) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L232) ### ctx.goals.complete(agent, ref) @@ -135,7 +135,7 @@ Mark a current non-complete goal complete and disarm it. **Returns** the completed view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L253) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L257) ### ctx.goals.block(agent, ref, reason) @@ -158,7 +158,7 @@ Mark an active goal blocked and disarm it. **Returns** the blocked view with its durable reason. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L271) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L275) ### ctx.goals.clear(agent, ref) @@ -179,4 +179,4 @@ Clear the current goal while retaining a durable tombstone and history. **Returns** the tombstone ref whose revision is one past the cleared snapshot. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L292) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L296) From 109df5210e2b8107ea517fb4ce9a328fe0c5ff2f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:13:39 +0800 Subject: [PATCH 09/10] docs(goal): align current-base contracts --- .../2026-07-19-persisted-same-session-goal-domain.i18n.yaml | 4 ++-- docs/architecture.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index b960f69e71..5049947f18 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -2,5 +2,5 @@ # 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-19-persisted-same-session-goal-domain.md: 75355aa8d94789e0cc227d393c50139708a73f6a -2026-07-19-persisted-same-session-goal-domain.zh.md: fd7e31f4b6a5acec1d2b44fb3088e301b36abd5c +2026-07-19-persisted-same-session-goal-domain.md: b0149016ab1b2a21c6d21798bf8b2117472a0f6c +2026-07-19-persisted-same-session-goal-domain.zh.md: 33f44136da3f0045d9f4baad5154797a6e746bcc diff --git a/docs/architecture.md b/docs/architecture.md index 9a83346924..3ada0d1ba3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ The **DeepSeek Harness SDK** builds on Cordis: **everything is a plugin**, inclu ## Overview -A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompt, tool, provider, adapter, and listener registrations. +Harnesses are [Cordis](cordis-primer.md) contexts. Packages contribute services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompts, tools, providers, adapters, and listeners. `packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins. @@ -110,7 +110,7 @@ forever: Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through turn close and flush, and discards later steering while preserving queued prompts. +Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after results. Steering drains; before signal closure, `agent/post-step` observes durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through close/flush, and discards later steering while preserving queued prompts. Optional pruning precedes summaries; retry requires durable surface progress; cancellation wins ([decision](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). @@ -176,7 +176,7 @@ New behavior should attach to a documented extension point; changing the shipped | Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | -| Manage a same-session objective | call `ctx.goals`; drive continuation through `Agent` and `agent/*` seams | +| Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | | Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) | From f6dfc5d3c3df5c643578e665d7f86de33ad61ce5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:23:37 +0800 Subject: [PATCH 10/10] docs: retire stale stdio package links --- .../simplification/2026-07-04-fold-stdio-ui-helper.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index c44201b4e6..b05dd22357 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -12,9 +12,9 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/repl-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). +At the time, the helper moved into `@deepseek-ai/dsh-stdio` as the terminal-channel plugin. `createStdioChat`, its `StdioRuntime` test seam, and its unit tests moved with it, keeping EOF handling, rendering, disposal, and piped-vs-TTY behavior under the per-file coverage gate without hijacking process globals. The module kept the named `name`/`inject`/`Config`/`apply` export shape consumed by the app mount, while the then-current Echo and REPL Loader smokes proved the composed tree and the plugin-shape suite pinned explicit `unwrapExports` behavior. The superseding removal note above owns the current package and example state. -The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. +The earlier support helper package was removed: its manifest, tsconfig references, module-graph rows, and README rows disappeared, while the remaining documentation described the in-package module. ## Alternatives considered