From b8080268599b33090c150786feb0b9438517f716 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 00:51:19 +0800
Subject: [PATCH 01/14] feat(workflow): add fresh-agent Ralph tool
---
docs/capability-seams.md | 7 +-
docs/config-catalog.md | 18 +
docs/core-data-structures/workflow.md | 8 +-
docs/glossary.md | 6 +
docs/module-graph.md | 8 +
docs/rfc/INDEX.md | 1 +
...-fresh-agent-ralph-workflow-tool.i18n.yaml | 6 +
...6-07-19-fresh-agent-ralph-workflow-tool.md | 67 +++
...7-19-fresh-agent-ralph-workflow-tool.zh.md | 67 +++
docs/tool-catalog.md | 30 ++
examples/acp-agent/composition.md | 3 +
examples/acp-agent/cordis.yml | 3 +
.../system-prompt.expected.md | 9 +
.../tool-schemas.expected.json | 20 +
.../both-mode-turn/system-prompt.expected.md | 9 +
.../both-mode-turn/tool-schemas.expected.json | 20 +
.../code-mode-turn/system-prompt.expected.md | 9 +
.../system-prompt.expected.md | 9 +
.../model-switching/system-prompt.expected.md | 4 +
.../tool-schemas.expected.json | 40 ++
.../system-prompt.expected.md | 4 +
.../tool-schemas.expected.json | 40 ++
.../skill-load/system-prompt.expected.md | 2 +
.../skill-load/tool-schemas.expected.json | 20 +
.../text-turn/system-prompt.expected.md | 2 +
.../text-turn/tool-schemas.expected.json | 20 +
.../system-prompt.expected.md | 2 +
.../tool-schemas.expected.json | 20 +
.../workspace-edit/system-prompt.expected.md | 2 +
.../workspace-edit/tool-schemas.expected.json | 20 +
examples/headless-agent/README.md | 2 +-
examples/headless-agent/composition.md | 3 +
examples/headless-agent/cordis.yml | 5 +
examples/package.json | 1 +
examples/repl-agent/README.md | 4 +-
examples/repl-agent/composition.md | 3 +
examples/repl-agent/cordis.yml | 3 +
examples/tui-agent/tests/tui.snapshot.ts | 2 +
packages/README.md | 2 +-
.../cordis/tool-cordis/src/api-catalog.ts | 2 +-
.../core/tools/tests/gen-tool-catalog.spec.ts | 2 +-
packages/workflow/README.md | 3 +-
packages/workflow/tool-ralph/README.md | 87 ++++
packages/workflow/tool-ralph/package.json | 53 +++
packages/workflow/tool-ralph/src/index.ts | 384 ++++++++++++++++++
.../tool-ralph/tests/integration.spec.ts | 92 +++++
.../tool-ralph/tests/tool-ralph.spec.ts | 319 +++++++++++++++
packages/workflow/tool-ralph/tsconfig.json | 39 ++
.../workflow/workflow-workerthread/README.md | 4 +-
.../workflow-workerthread/src/index.ts | 3 +-
.../tests/built-worker.e2e.ts | 22 +-
.../tests/workflow-workerthread.spec.ts | 20 +
packages/workflow/workflow/README.md | 2 +-
packages/workflow/workflow/src/types.ts | 6 +
pnpm-lock.yaml | 55 +++
scripts/gen-doc-graphs.ts | 8 +-
scripts/gen-tool-catalog.ts | 18 +-
tsconfig.json | 1 +
58 files changed, 1599 insertions(+), 22 deletions(-)
create mode 100644 docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml
create mode 100644 docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
create mode 100644 docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
create mode 100644 packages/workflow/tool-ralph/README.md
create mode 100644 packages/workflow/tool-ralph/package.json
create mode 100644 packages/workflow/tool-ralph/src/index.ts
create mode 100644 packages/workflow/tool-ralph/tests/integration.spec.ts
create mode 100644 packages/workflow/tool-ralph/tests/tool-ralph.spec.ts
create mode 100644 packages/workflow/tool-ralph/tsconfig.json
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 50cb528951..c03469e83e 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -83,6 +83,7 @@ flowchart LR
pkg_subagent_spawn["subagent-spawn"]
pkg_subagent_fork["subagent-fork"]
pkg_subagent_acp["subagent-acp"]
+ pkg_tool_ralph["tool-ralph"]
pkg_tasks["tasks"]
svc_tasks["ctx.tasks
Background task registry"]
pkg_tool_tasks["tool-tasks"]
@@ -186,6 +187,7 @@ flowchart LR
svc_sessions --> pkg_subagent_inprocess
svc_skills --> pkg_tool_skill
svc_spillStore --> pkg_spill_policy
+ svc_subagents --> pkg_tool_ralph
svc_subagents --> pkg_tool_subagent
svc_systemPrompt --> pkg_agent_loop
svc_systemPrompt --> pkg_tool_fs
@@ -209,6 +211,7 @@ flowchart LR
svc_userInteraction --> pkg_stdio_demo
svc_userInteraction --> pkg_tool_ask_user
svc_web --> pkg_tool_web
+ svc_workflows --> pkg_tool_ralph
svc_workflows --> pkg_tool_workflow
svc_fs -. event gate .-> pkg_fs_policy
```
@@ -236,10 +239,10 @@ flowchart LR
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). |
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. |
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. |
-| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. |
+| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. |
| `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
-| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. |
+| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. |
Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard.
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 0cbe1854e1..dbf7ded411 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1133,6 +1133,24 @@ export interface Config {
Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts)
+## `@deepseek-ai/dsh-tool-ralph`
+
+Requires: `tools` · `workflows` · `subagents` · `systemPrompt`
+
+```ts config-catalog
+/** Deployment policy for the fixed Ralph workflow. */
+export interface Config {
+ /** Fresh structured-output provider used for every round (default `spawn`). */
+ subagentProvider?: string
+ /** Default and deployment ceiling for one call's round count (default 256). */
+ maxRounds?: number
+ /** Maximum serialized characters in one structured handoff (default 16384). */
+ maxHandoffChars?: number
+}
+```
+
+Source: [`packages/workflow/tool-ralph/src/index.ts:22`](../packages/workflow/tool-ralph/src/index.ts)
+
## `@deepseek-ai/dsh-tool-skill`
Requires: `tools` · `skills`
diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md
index 40b17a3112..6991ecbd00 100644
--- a/docs/core-data-structures/workflow.md
+++ b/docs/core-data-structures/workflow.md
@@ -8,7 +8,7 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work
## The start request
-What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, meta, args }` call plus the calling agent; `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)).
+What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` for the run, but the script cannot observe or replace it. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)).
```ts type-equiv
/**
@@ -26,6 +26,12 @@ interface WorkflowStartRequest {
meta: WorkflowMeta
/** Optional input exposed verbatim to the script as the `args` global. */
args?: unknown
+ /**
+ * Optional engine-wide child-provider override for this run. The workflow
+ * script cannot observe or replace it; omission uses the engine's configured
+ * provider.
+ */
+ subagentProvider?: string
/** The agent on whose behalf the run executes (parent of every child). */
parent: Agent
/** Cancels the run when aborted (the tool's `exec.signal`). */
diff --git a/docs/glossary.md b/docs/glossary.md
index 9a27ead799..945eb60ef1 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -34,3 +34,9 @@ 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.
+
+## Ralph
+
+- **Ralph loop** — one foreground fresh-agent workflow run toward an immutable objective. It is a model-facing tool policy composed from workflow and subagent primitives, not a same-session goal, agent-loop mode, scheduler, or generic workflow-script feature.
+- **Ralph round** — one fresh child session in a [Ralph loop](#ralph-loop). The child receives no parent or prior-child conversation seed; the shared workspace and one bounded [Ralph handoff](#ralph-handoff) carry cross-round state.
+- **Ralph handoff** — the normalized bounded structured report passed from one continuing Ralph round to the next, containing status, summary, evidence, next steps, and blocker text. It supplements the shared workspace rather than replacing it as authority.
diff --git a/docs/module-graph.md b/docs/module-graph.md
index a9b4876334..95562bebca 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -154,6 +154,7 @@ flowchart TD
pkg_tool_tasks["tool-tasks"]
end
subgraph group_workflow["packages/workflow"]
+ pkg_tool_ralph["tool-ralph"]
pkg_tool_workflow["tool-workflow"]
pkg_workflow["workflow"]
pkg_workflow_workerthread["workflow-workerthread"]
@@ -434,6 +435,12 @@ flowchart TD
pkg_agent_spine_demo --> pkg_tool_tasks
pkg_agent_spine_demo --> pkg_tools
pkg_agent_spine_demo --> pkg_workspace_context
+ pkg_tool_ralph --> pkg_agent
+ pkg_tool_ralph --> pkg_llm
+ pkg_tool_ralph --> pkg_subagent
+ pkg_tool_ralph --> pkg_system_prompt
+ pkg_tool_ralph --> pkg_tools
+ pkg_tool_ralph --> pkg_workflow
pkg_workflow_workerthread --> pkg_agent
pkg_workflow_workerthread --> pkg_brand
pkg_workflow_workerthread --> pkg_llm
@@ -572,6 +579,7 @@ flowchart TD
| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
+| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md
index 90e508da27..6bb0cc8202 100644
--- a/docs/rfc/INDEX.md
+++ b/docs/rfc/INDEX.md
@@ -90,6 +90,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 |
+| [Fresh-agent Ralph workflow tool](implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) | 2026-07-19 |
| [Human `/goal` command](implemented/feature/2026-07-19-human-goal-command.md) | 2026-07-19 |
| [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 |
| [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 |
diff --git a/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml
new file mode 100644
index 0000000000..4b56f36113
--- /dev/null
+++ b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.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-fresh-agent-ralph-workflow-tool.md: 71f83f25018fc930fd058777f6909ea94c5a77cb
+2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 0f3fc7910dc726436605f12e292f18bee84be0dd
diff --git a/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
new file mode 100644
index 0000000000..71f83f2501
--- /dev/null
+++ b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
@@ -0,0 +1,67 @@
+# RFC: Fresh-agent Ralph workflow tool
+
+Status: implemented
+
+English | [中文](2026-07-19-fresh-agent-ralph-workflow-tool.zh.md)
+
+## Problem
+
+Same-session goals preserve conversation and let one agent continue a durable objective, while the general workflow tool lets the model write a fan-out orchestration script. Neither is the Ralph pattern: repeatedly give the same objective to a completely fresh worker, use the shared workspace as long-term memory, and carry only a small explicit handoff until work completes or a limit is reached.
+
+Adding Ralph behavior to `dsh-agent-loop`, the goal driver, or the public model-written workflow language would couple one policy to unrelated execution machinery. Letting each child inherit the parent conversation would also defeat context reset and make replay depend on a growing implicit prefix. The feature needs a fixed, reviewable policy built from existing plugin primitives, with cancellation quiescence, bounded cross-round data, a generous configurable cap, and no novel human-facing goal state.
+
+## Decision
+
+Add `@deepseek-ai/dsh-tool-ralph` as a separate consumer package under `packages/workflow/`. It registers `ralph({ objective, maxRounds? })`, owns a fixed workflow script, and depends only on `ctx.tools`, `ctx.systemPrompt`, `ctx.workflows`, and `ctx.subagents`. A Ralph run is not a session goal, creates no goal state, and requires no branch in the concrete agent loop.
+
+The tool is foreground-only. The calling agent parents every child for cwd and lineage, the parent tool call waits for the complete run, and the parent step's abort signal cancels the workflow. `run.dispose()` is awaited on every path, so cancellation reaches the worker engine's bounded settlement and child quiescence before the call returns.
+
+### Per-run workflow provider route
+
+`WorkflowStartRequest` gains optional `subagentProvider`. The worker-thread engine resolves that explicit per-run value before falling back to its configured provider and uses the result for every `agent()` call in the run. The script cannot observe or replace this route. The ordinary `workflow` tool leaves the field unset and exposes no new model argument, so general workflow behavior and provider policy stay unchanged.
+
+The Ralph plugin's `subagentProvider` defaults to `spawn`. Immediately before a call it requires the named provider to exist, support structured output, and report `inheritsParentContext: false`; a fork-like or incapable provider fails loudly before workflow start. Provider lookup remains call-time because effect-scoped provider registration can change under HMR.
+
+### Ralph rounds and handoff
+
+The hierarchy is Ralph Run → Ralph Round → fresh child Turn → Step. One Ralph round creates exactly one child through the selected provider. Spawn gives that child a distinct session with no seed while preserving the parent's cwd, so the shared working tree is the durable authority and neither parent conversation nor prior child history enters the request.
+
+The fixed prompt passes only the immutable objective, current round and cap, a workspace-as-authority instruction, and the previous structured report. A `RalphRoundReport` contains `status: continue | complete | blocked`, `summary`, `evidence`, `nextSteps`, and `blocker`. Strings must be normalized; `continue` requires next steps and no blocker, `complete` requires evidence with no next steps or blocker, and `blocked` requires a concrete blocker. The script validates semantics and serialized size before the report can become the next handoff; the consumer validates the materialized terminal value again across the workflow seam.
+
+`maxRounds` defaults to `256` and is also the deployment ceiling for a call override. `maxHandoffChars` defaults to `16384`. Both are positive safe-integer config values, and oversized reports fail rather than being silently truncated. After a `continue` report at the last permitted round, the fixed script returns `budget-limited`; `complete` and `blocked` return immediately with the final report and number of rounds started.
+
+### Model and UI surface
+
+The model may supply only `objective` and optional `maxRounds`; provider selection, report schema, handoff cap, and script are deployment-owned. A fixed prompt section says to use `ralph` only when the direct human explicitly asks for Ralph or fresh-agent iteration, and distinguishes it from same-session goals, bounded delegation, and general fan-out workflows. This is guidance rather than a new goal UX state machine.
+
+ACP and terminal presentation use a generic `ralph` card whose raw input is the objective. The parent transcript retains the original tool call and one bounded terminal report, not intermediate child messages. Shipped headless, REPL/TUI, and ACP compositions load the plugin beside the existing workflow engine; JSON-RPC remains unchanged because its default composition does not expose workflows.
+
+## Testing
+
+Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing, all three terminal outcomes, malformed and oversized boundary values, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove that a per-run provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node.
+
+A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, terminal completion, and disposal of both children. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface.
+
+## Alternatives considered
+
+- **Put Ralph in the same-session goal driver** — rejected because goal rounds intentionally preserve one conversation, while Ralph's defining property is a fresh context per round; combining them would make goal lifecycle and child orchestration inseparable.
+- **Expose a `fresh` or loop flag on the general workflow tool** — rejected because the model-written script surface should remain general and provider-neutral; Ralph's fixed report protocol and stop policy deserve one reviewable consumer.
+- **Use `subagent_fork` for replay convenience** — rejected because inherited completed turns are implicit, growing handoff state and violate the fresh-context contract. The workspace plus one structured report is replayable without inserting artificial cancellation records.
+- **Call the subagent seam directly from the tool** — rejected because the existing workflow engine already owns foreground orchestration, structured children, cancellation propagation, worker termination, events, and quiescent disposal. Reusing it demonstrates plugin composition instead of building a second loop runtime.
+- **Silently truncate a large report** — rejected because truncation can remove status evidence or next steps while still looking like an authoritative handoff. A producer must emit a valid report within the configured bound.
+
+## Consequences
+
+- Fresh-agent iteration is a first-class model tool implemented entirely as a removable plugin over existing seams.
+- Goal rounds and Ralph rounds stay different concepts: the former is one same-session continuation turn, while the latter is one fresh child inside a foreground workflow.
+- The workspace becomes authoritative cross-round memory, so workers must inspect and verify it rather than trusting a narrative handoff.
+- A generous round ceiling permits substantial autonomous work, while deployment config still bounds child count and every handoff remains size-limited.
+- Provider routing becomes an explicit workflow start concern without expanding the script or ordinary workflow tool surface.
+
+## Known limitations and deferred work
+
+- Completion and blocker status are worker self-declarations. An independent evaluator, evaluator-driven feedback round, completion certificate, or adversarial verifier is intentionally deferred.
+- Runs are foreground and process-local. Background collection, persistence/resume, scheduling, and restart recovery are absent.
+- Round count is the only aggregate budget. Token, currency, elapsed-time, and provider-usage budgets remain separate future policy.
+- One round creates one child. Within-round fan-out, evaluator/worker role separation, dynamic provider or model selection, and cross-run journals are deferred.
+- Prompt guidance asks models not to invoke Ralph recursively; a structural child-tool restriction would require a separately designed workflow child-policy surface.
diff --git a/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
new file mode 100644
index 0000000000..0f3fc7910d
--- /dev/null
+++ b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
@@ -0,0 +1,67 @@
+# RFC:全新 agent Ralph 工作流工具
+
+Status: implemented
+
+[English](2026-07-19-fresh-agent-ralph-workflow-tool.md) | 中文
+
+## 问题
+
+同会话目标会保留对话,让一个 agent 持续完成持久目标;通用工作流工具则让模型编写扇出编排脚本。两者都不是 Ralph 模式:把同一目标反复交给完全全新的工作者,以共享工作区作为长期记忆,并且在各轮之间只传递一份小型显式交接,直到工作完成或触及限制。
+
+如果把 Ralph 行为加入 `dsh-agent-loop`、目标驱动器或面向模型的公开工作流语言,就会让一项策略与无关的执行机制耦合。让每个子 agent 继承父对话也会破坏上下文重置,并让重放依赖不断增长的隐式前缀。此功能需要一项由现有插件原语组合而成的固定、可评审策略,同时具备取消静止性、有界跨轮数据、宽裕且可配置的上限,并且不引入新颖的面向人类目标状态。
+
+## 决策
+
+在 `packages/workflow/` 下新增独立消费者包 `@deepseek-ai/dsh-tool-ralph`。它注册 `ralph({ objective, maxRounds? })`,拥有固定工作流脚本,并且只依赖 `ctx.tools`、`ctx.systemPrompt`、`ctx.workflows` 和 `ctx.subagents`。Ralph 运行不是会话目标,不会创建目标状态,也不要求在具体 agent loop 中增加分支。
+
+该工具仅以前台方式运行。调用 agent 作为每个子 agent 的父级以提供 cwd 和谱系,父工具调用等待整次运行结束,父步骤的中止信号会取消工作流。每条路径都会等待 `run.dispose()`,因此调用返回前,取消会经过工作流引擎的有界收敛并达到子 agent 静止状态。
+
+### 每次运行的工作流 provider 路由
+
+`WorkflowStartRequest` 新增可选的 `subagentProvider`。工作线程引擎先解析这个显式的每次运行值,再回退到引擎配置的 provider,并把结果用于该运行中的每次 `agent()` 调用。脚本无法观察或替换此路由。普通 `workflow` 工具不设置该字段,也不暴露新的模型参数,因此通用工作流行为和 provider 策略保持不变。
+
+Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要求具名 provider 已存在、支持结构化输出且报告 `inheritsParentContext: false`;类似 fork 或能力不足的 provider 会在工作流启动前响亮失败。provider 查找保留在调用期,因为效果作用域内的 provider 注册可能随 HMR 改变。
+
+### Ralph 轮次与交接
+
+层级为 Ralph 运行 → Ralph 轮次 → 全新子 agent 回合 → 步骤。每个 Ralph 轮次恰好通过所选 provider 创建一个子 agent。Spawn 给该子 agent 一个没有种子的独立会话,同时保留父级 cwd,因此共享工作树是持久权威,父对话和先前子 agent 历史都不会进入请求。
+
+固定提示只传递不可变目标、当前轮次与上限、以工作区为权威的指令,以及上一份结构化报告。`RalphRoundReport` 包含 `status: continue | complete | blocked`、`summary`、`evidence`、`nextSteps` 和 `blocker`。字符串必须规范化;`continue` 要求存在下一步且没有阻塞项,`complete` 要求存在证据且没有下一步或阻塞项,`blocked` 要求具体阻塞项。报告成为下一次交接前,脚本会验证语义与序列化大小;消费者还会跨工作流接缝再次验证实体化的终止值。
+
+`maxRounds` 默认为 `256`,同时也是调用覆盖值的部署上限。`maxHandoffChars` 默认为 `16384`。两者都是正安全整数配置值;过大的报告会失败,而不会被静默截断。最后一个允许轮次报告 `continue` 后,固定脚本返回 `budget-limited`;`complete` 和 `blocked` 会立即返回最终报告与已启动轮次数。
+
+### 模型与 UI 表面
+
+模型只能提供 `objective` 和可选的 `maxRounds`;provider 选择、报告 schema、交接上限和脚本都由部署拥有。固定提示区段说明,只有直接人类明确要求 Ralph 或全新 agent 迭代时才使用 `ralph`,并将其与同会话目标、有界委派和通用扇出工作流区分开。这是指导,而不是新的目标 UX 状态机。
+
+ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入。父转录只保留原始工具调用和一份有界终止报告,不包含中间子 agent 消息。发布的无头、REPL/TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。
+
+## 测试
+
+单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由、三种终止结果、畸形及过大边界值、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明,每次运行的 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。
+
+一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、终止完成以及两个子 agent 都被处置。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。
+
+## 考虑过的替代方案
+
+- **把 Ralph 放进同会话目标驱动器** — 拒绝,因为目标轮次有意保留同一段对话,而 Ralph 的定义性属性是每轮使用全新上下文;合并两者会让目标生命周期与子 agent 编排无法分离。
+- **在通用工作流工具上暴露 `fresh` 或循环标志** — 拒绝,因为模型编写的脚本表面应保持通用且与 provider 无关;Ralph 的固定报告协议和停止策略值得拥有一个可评审消费者。
+- **为了方便重放而使用 `subagent_fork`** — 拒绝,因为继承的已完成回合是隐式、不断增长的交接状态,并违反全新上下文契约。工作区加一份结构化报告即可重放,无需插入人为取消记录。
+- **让工具直接调用 subagent 接缝** — 拒绝,因为现有工作流引擎已经拥有前台编排、结构化子 agent、取消传播、工作线程终止、事件和静止处置。复用它可以展示插件组合,而不是构建第二个循环运行时。
+- **静默截断大型报告** — 拒绝,因为截断可能删除状态证据或下一步,却仍看似权威交接。生产者必须在配置边界内发出有效报告。
+
+## 后果
+
+- 全新 agent 迭代成为一项一等模型工具,并完全以现有接缝之上的可移除插件实现。
+- 目标轮次与 Ralph 轮次保持不同概念:前者是一次同会话续行回合,后者是前台工作流中的一个全新子 agent。
+- 工作区成为权威跨轮记忆,因此工作者必须检查和验证工作区,而不能信任叙事性交接。
+- 宽裕的轮次上限允许大量自治工作,而部署配置仍会限制子 agent 数量,并且每次交接始终受大小约束。
+- provider 路由成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。
+
+## 已知限制与推迟工作
+
+- 完成与阻塞状态由工作者自行声明。独立 evaluator、evaluator 驱动的反馈轮次、完成证书或对抗式 verifier 被有意推迟。
+- 运行位于前台且只存在于进程内。后台收集、持久化/恢复、调度和重启恢复均不存在。
+- 轮次数是唯一聚合预算。token、货币、耗时和 provider 用量预算仍属于未来的独立策略。
+- 每轮创建一个子 agent。轮内扇出、evaluator/工作者角色分离、动态 provider 或模型选择,以及跨运行日志均被推迟。
+- 提示指导模型不要递归调用 Ralph;结构化的子 agent 工具限制需要另行设计工作流子策略表面。
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 64fd5e692d..8d73477bd5 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
+| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
@@ -482,6 +483,35 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.
+## `@deepseek-ai/dsh-tool-ralph`
+
+### `ralph`
+
+Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+}
+```
+
+Source: [`packages/workflow/tool-ralph/src/index.ts`](../packages/workflow/tool-ralph/src/index.ts)
+
+A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap.
+
## `@deepseek-ai/dsh-tool-skill`
### `skill`
diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md
index 194ff3dee0..56074f9b99 100644
--- a/examples/acp-agent/composition.md
+++ b/examples/acp-agent/composition.md
@@ -41,6 +41,8 @@ flowchart LR
cfg --> plugin_acp_workflow_workerthread
plugin_acp_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"]
cfg --> plugin_acp_tool_workflow
+ plugin_acp_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"]
+ cfg --> plugin_acp_tool_ralph
plugin_acp_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"]
cfg --> plugin_acp_tool_todo
plugin_acp_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"]
@@ -66,6 +68,7 @@ flowchart LR
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
+| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
| `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` |
| `hooks-claude` | `@deepseek-ai/dsh-hooks-claude` |
diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml
index cb8890dae7..5c70e5851b 100644
--- a/examples/acp-agent/cordis.yml
+++ b/examples/acp-agent/cordis.yml
@@ -86,6 +86,9 @@
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
+
+- id: tool-ralph
+ name: '@deepseek-ai/dsh-tool-ralph'
# `todo_write` replaces the logged whole list and surfaces an ACP `plan` update.
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md
index 5ba28dca73..867bbcc7b7 100644
--- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md
@@ -16,6 +16,8 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+
## Writing code for run_code
Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
@@ -72,6 +74,13 @@ declare const tools: {
}): Promise;
/** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */
get_goal(args: Record): Promise;
+ /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */
+ ralph(args: {
+ /** The immutable completion objective for every fresh Ralph round. */
+ objective: string;
+ /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
+ maxRounds?: number;
+ }): Promise;
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
skill(args: {
/** The exact skill name from the available skills list. */
diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json
index c27f1788e7..bebf7b25df 100644
--- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json
@@ -130,6 +130,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "run_code",
"description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.",
diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md
index 9b14172e43..e974d937e7 100644
--- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md
@@ -16,6 +16,8 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+
## Writing code for run_code
Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
@@ -55,6 +57,13 @@ declare const tools: {
}): Promise;
/** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */
get_goal(args: Record): Promise;
+ /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */
+ ralph(args: {
+ /** The immutable completion objective for every fresh Ralph round. */
+ objective: string;
+ /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
+ maxRounds?: number;
+ }): Promise;
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
skill(args: {
/** The exact skill name from the available skills list. */
diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json
index 5112f55a25..2ffcf4f04a 100644
--- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json
@@ -73,6 +73,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "run_code",
"description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.",
diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md
index 9b14172e43..e974d937e7 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md
@@ -16,6 +16,8 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+
## Writing code for run_code
Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
@@ -55,6 +57,13 @@ declare const tools: {
}): Promise;
/** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */
get_goal(args: Record): Promise;
+ /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */
+ ralph(args: {
+ /** The immutable completion objective for every fresh Ralph round. */
+ objective: string;
+ /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
+ maxRounds?: number;
+ }): Promise;
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
skill(args: {
/** The exact skill name from the available skills list. */
diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md
index ec0b4430e1..ec7e2758a8 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md
@@ -22,6 +22,8 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+
## Writing code for run_code
Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
@@ -72,6 +74,13 @@ declare const tools: {
}): Promise;
/** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */
get_goal(args: Record): Promise;
+ /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */
+ ralph(args: {
+ /** The immutable completion objective for every fresh Ralph round. */
+ objective: string;
+ /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
+ maxRounds?: number;
+ }): Promise;
/** Read a UTF-8 text file and return line-numbered content. */
read(args: {
/** Path to read, resolved by the filesystem backend. */
diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md
index f81876701a..751040c92e 100644
--- a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md
@@ -16,6 +16,8 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+
You are an AI agent powered by the DeepSeek Harness SDK.
@@ -35,3 +37,5 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json
index bae7be5f6b..09b42a7ac1 100644
--- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json
@@ -73,6 +73,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
@@ -417,6 +437,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md
index 82335a7f1e..472db36968 100644
--- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md
@@ -15,6 +15,8 @@ Use goal tools for one long-running completion objective in the current session.
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+
You are an AI agent powered by the DeepSeek Harness SDK.
@@ -34,3 +36,5 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json
index bae7be5f6b..09b42a7ac1 100644
--- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json
@@ -73,6 +73,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
@@ -417,6 +437,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md
index 87818d5b6d..6bc89dccd5 100644
--- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md
@@ -15,3 +15,5 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json
index 0680b50e05..99c8f338a3 100644
--- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json
@@ -73,6 +73,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md
index 87818d5b6d..6bc89dccd5 100644
--- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md
@@ -15,3 +15,5 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json
index 0680b50e05..99c8f338a3 100644
--- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json
@@ -73,6 +73,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md
index 6f49fcf1c2..cc8fc47873 100644
--- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md
@@ -21,3 +21,5 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
index 0ef5307126..0fcb95895c 100644
--- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
@@ -103,6 +103,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "read",
"description": "Read a UTF-8 text file and return line-numbered content.",
diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md
index 4af39f6b4b..dc934714b9 100644
--- a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md
@@ -21,3 +21,5 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json
index 0ef5307126..0fcb95895c 100644
--- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json
@@ -103,6 +103,26 @@
"properties": {}
}
},
+ {
+ "name": "ralph",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The immutable completion objective for every fresh Ralph round."
+ },
+ "maxRounds": {
+ "type": "number",
+ "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
+ }
+ },
+ "required": [
+ "objective"
+ ]
+ }
+ },
{
"name": "read",
"description": "Read a UTF-8 text file and return line-numbered content.",
diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md
index 285263146f..10667a6101 100644
--- a/examples/headless-agent/README.md
+++ b/examples/headless-agent/README.md
@@ -1,6 +1,6 @@
# headless-agent
-Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door.
+Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door.
## Run it
diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md
index 9533af28d3..3e5876c2b6 100644
--- a/examples/headless-agent/composition.md
+++ b/examples/headless-agent/composition.md
@@ -37,6 +37,8 @@ flowchart LR
cfg --> plugin_headless_workflow_workerthread
plugin_headless_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"]
cfg --> plugin_headless_tool_workflow
+ plugin_headless_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"]
+ cfg --> plugin_headless_tool_ralph
plugin_headless_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"]
cfg --> plugin_headless_tool_todo
plugin_headless_fs_local["fs-local
@deepseek-ai/dsh-fs-local"]
@@ -60,6 +62,7 @@ flowchart LR
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
+| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml
index 7f48c529c4..766e8b7891 100644
--- a/examples/headless-agent/cordis.yml
+++ b/examples/headless-agent/cordis.yml
@@ -81,6 +81,11 @@
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
+# A separate fixed consumer demonstrates fresh-agent Ralph iteration without
+# changing the workflow tool or same-session goal behavior.
+- id: tool-ralph
+ name: '@deepseek-ai/dsh-tool-ralph'
+
# `todo_write` replaces the logged whole list.
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
diff --git a/examples/package.json b/examples/package.json
index f893ab6bc2..04931f2fc1 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-tool-fs": "workspace:*",
"@deepseek-ai/dsh-tool-fs-search": "workspace:*",
"@deepseek-ai/dsh-tool-goal": "workspace:*",
+ "@deepseek-ai/dsh-tool-ralph": "workspace:*",
"@deepseek-ai/dsh-tool-subagent": "workspace:*",
"@deepseek-ai/dsh-tool-todo": "workspace:*",
"@deepseek-ai/dsh-tool-workflow": "workspace:*",
diff --git a/examples/repl-agent/README.md b/examples/repl-agent/README.md
index 7145873e9a..d86b18dcb4 100644
--- a/examples/repl-agent/README.md
+++ b/examples/repl-agent/README.md
@@ -1,6 +1,6 @@
# repl-agent
-The repl-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door.
+The repl-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door.
## Run it
@@ -52,7 +52,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the configured terminal channel + a pre-created `main` agent. This leaf fixes `ui.mode` to `readline`; `tui-agent` owns the corresponding TUI leaf |
| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix |
| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) |
-| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend |
+| `workflow-workerthread`, `tool-workflow`, `tool-ralph` | the worker-thread engine, general model-written `workflow` tool, and separate fixed fresh-agent `ralph` consumer, with children routed through spawn |
| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a persistent TUI plan or readline checklist |
| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace |
diff --git a/examples/repl-agent/composition.md b/examples/repl-agent/composition.md
index af3f810585..faa5825d9f 100644
--- a/examples/repl-agent/composition.md
+++ b/examples/repl-agent/composition.md
@@ -41,6 +41,8 @@ flowchart LR
cfg --> plugin_repl_workflow_workerthread
plugin_repl_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"]
cfg --> plugin_repl_tool_workflow
+ plugin_repl_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"]
+ cfg --> plugin_repl_tool_ralph
plugin_repl_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"]
cfg --> plugin_repl_tool_todo
plugin_repl_fs_local["fs-local
@deepseek-ai/dsh-fs-local"]
@@ -74,6 +76,7 @@ flowchart LR
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
+| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml
index 4428d5f3a8..5a7ca0f62f 100644
--- a/examples/repl-agent/cordis.yml
+++ b/examples/repl-agent/cordis.yml
@@ -94,6 +94,9 @@
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
+
+- id: tool-ralph
+ name: '@deepseek-ai/dsh-tool-ralph'
# `todo_write` replaces the logged whole list and renders as a stdio checklist or ACP plan.
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts
index 2c62ba25e6..a9f937f883 100644
--- a/examples/tui-agent/tests/tui.snapshot.ts
+++ b/examples/tui-agent/tests/tui.snapshot.ts
@@ -22,6 +22,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
+import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
import { createTuiChat } from '@deepseek-ai/dsh-tui'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -180,6 +181,7 @@ async function mountScenarioContext(
await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false })
await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' })
await ctx.plugin(ToolWorkflow)
+ await ctx.plugin(ToolRalph)
await ctx.plugin(CommandService)
if (scenario.composition === 'code' || scenario.composition === 'advanced') {
await ctx.plugin(WorkerCodeRuntime, {})
diff --git a/packages/README.md b/packages/README.md
index f2298a47fa..d9b11a1f3b 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -20,7 +20,7 @@ Packages live at `packages///`; groups are containers, while names r
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
-| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface |
+| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index d2e88e0707..0becd20a65 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -1791,7 +1791,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'WorkflowStartRequest',
- declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n parent: Agent;\n signal?: AbortSignal;\n}',
+ declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n subagentProvider?: string;\n parent: Agent;\n signal?: AbortSignal;\n}',
},
{
name: 'WorkflowStopReason',
diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts
index c6b7f9a804..3c99ecbd4d 100644
--- a/packages/core/tools/tests/gen-tool-catalog.spec.ts
+++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
- expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
+ expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
diff --git a/packages/workflow/README.md b/packages/workflow/README.md
index 98fa3cfe04..7c503b1aaf 100644
--- a/packages/workflow/README.md
+++ b/packages/workflow/README.md
@@ -7,7 +7,8 @@ The workflow seam: a model-written JavaScript orchestration script that fans out
| `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` |
| `workflow-workerthread/` | `node:worker_threads` engine: one worker per run; the script's vm context lives inside the worker, `agent()` bridges to `ctx.subagents` over the message port | (provides `ctx.workflows`) |
| `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) |
+| `tool-ralph/` | Fixed fresh-agent Ralph policy over `ctx.workflows` and a fresh structured-output subagent provider | (registers on `ctx.tools`) |
The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The worker thread isolates the SCRIPT — the host never blocks on it, and a cancelled run's post-grace termination is real — but it is NOT a security boundary; an isolated-vm/separate-process engine (actual sandboxing) swaps in behind the same interface if that ever matters.
-The proposal, decisions, and deferred work: [docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md](../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md).
+The general script engine's proposal, decisions, and deferred work: [docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md](../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md). The separate Ralph consumer fixes the script and fresh-provider policy rather than adding another engine or an agent-loop mode.
diff --git a/packages/workflow/tool-ralph/README.md b/packages/workflow/tool-ralph/README.md
new file mode 100644
index 0000000000..4b42da7cf5
--- /dev/null
+++ b/packages/workflow/tool-ralph/README.md
@@ -0,0 +1,87 @@
+# @deepseek-ai/dsh-tool-ralph
+
+The model-facing `ralph` tool runs a fixed foreground workflow that gives one immutable objective to a sequence of fresh child agents. It demonstrates a specialized orchestration policy as an ordinary plugin over [`ctx.workflows`](../workflow/README.md) and [`ctx.subagents`](../../subagent/subagent/README.md): no Ralph mode or fresh-agent loop is added to `agent-loop`, and the same-session [goal domain](../../goal/goal/README.md) remains independent.
+
+## Contract
+
+`ralph({ objective, maxRounds? })` waits for the entire run. The deployment config's `maxRounds` is both the default and a ceiling on a call override. Every Ralph round starts one child through `subagentProvider`; that provider must exist, support structured output, and report `inheritsParentContext: false`. The configured provider is carried as `WorkflowStartRequest.subagentProvider`, so the fixed script cannot inspect or change routing and the ordinary model-written `workflow` tool gains no provider selector.
+
+Each child receives only the immutable objective, its current Ralph round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded. Reports have `status: continue | complete | blocked`, a non-empty summary, evidence, next steps, and blocker text. Status-specific semantics and the serialized `maxHandoffChars` ceiling are validated inside the fixed workflow and again at the consumer boundary. Invalid, missing, or oversized reports fail the workflow instead of being truncated or mistaken for cap exhaustion.
+
+The terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. Child self-declaration determines completion in this cut. A workflow failure or cancellation is an error result; partial output is never success.
+
+## Lifecycle and cancellation
+
+The caller's agent is the parent of every fresh child, preserving cwd and lineage without copying its conversation. `exec.signal` enters the workflow engine and is also bridged to `run.cancel()` for implementation independence. The tool awaits `run.result` and calls `run.dispose()` in `finally`, so a cancelled parent step waits for the engine's bounded termination and child quiescence before returning.
+
+## Render intent
+
+The pending call is a `generic` card titled `ralph`; the immutable objective is its `rawInput`. The result keeps the generic card. Both presentation functions depend only on tool arguments and the settled tool envelope.
+
+## Config
+
+| Key | Default | Meaning |
+|---|---|---|
+| `subagentProvider` | `spawn` | Fresh structured-output provider used for every round. |
+| `maxRounds` | `256` | Default and deployment ceiling for one Ralph run. |
+| `maxHandoffChars` | `16384` | Maximum serialized characters in one round report. |
+
+All config values are normalized and validated when the plugin applies, including direct application outside Loader schema normalization. Provider capabilities are resolved immediately before each call because provider registration can change under plugin lifecycle and HMR.
+
+## Model Experience
+
+### System prompt
+
+#### What the model sees
+
+Every parent request in this plugin's registration scope receives the fixed routing guidance below.
+
+##### Ralph guidance
+
+```markdown
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+```
+
+#### Token effect
+
+Small fixed guidance cost per request while the plugin is active.
+
+#### KV Cache effect
+
+Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
+
+### Tool schema
+
+#### What the model sees
+
+The generated [`ralph` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ralph) exposes one required `objective` string and one optional `maxRounds` number. Provider choice, handoff size, report schema, workflow script, and orchestration behavior are deployment-owned and absent from the call surface.
+
+#### Token effect
+
+Small fixed schema cost on each request where the tool is visible.
+
+#### KV Cache effect
+
+Prefix-stable while the definition and visibility are unchanged.
+
+### Child requests and parent result
+
+#### What the model sees
+
+Each child sees the standalone fixed round prompt plus the structured-output capture contract. The parent sees only the original call and one terminal result containing status, round count, and pretty-printed final report; intermediate child messages and reports do not enter the parent conversation.
+
+#### Token effect
+
+Every round pays for a fresh child context. The parent result is bounded indirectly by `maxHandoffChars`; child work remains outside the parent context.
+
+#### KV Cache effect
+
+Each fresh child has an independent request cache. The parent result appends after the reusable request prefix.
+
+## Known Limitations and Deferred Work
+
+- **Completion is worker self-declaration** — there is no independent evaluator or verifier deciding whether the objective is actually complete; evaluator policy and evaluator-driven continuation are deferred.
+- **Foreground only** — there is no task id, background collection, process-resume checkpoint, scheduler, or wall-clock start policy.
+- **The workspace is the only cross-round long-term memory** — one bounded report is the explicit handoff, and uncommitted conversational reasoning disappears with each child.
+- **One round is one fresh child** — there is no within-round fan-out, model/provider switching, fork context, or model-call-selected provider.
+- **Only round count bounds aggregate effort** — token, price, and elapsed-time budgets are deferred.
diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json
new file mode 100644
index 0000000000..31c368b6f4
--- /dev/null
+++ b/packages/workflow/tool-ralph/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@deepseek-ai/dsh-tool-ralph",
+ "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams",
+ "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-llm": "^0.0.1",
+ "@deepseek-ai/dsh-subagent": "^0.0.1",
+ "@deepseek-ai/dsh-system-prompt": "^0.0.1",
+ "@deepseek-ai/dsh-tools": "^0.0.1",
+ "@deepseek-ai/dsh-workflow": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "dependencies": {
+ "schemastery": "^3.18.0"
+ },
+ "devDependencies": {
+ "@cordisjs/plugin-loader": "workspace:^",
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-agent-loop": "workspace:^",
+ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-subagent": "workspace:^",
+ "@deepseek-ai/dsh-subagent-inprocess": "workspace:^",
+ "@deepseek-ai/dsh-subagent-spawn": "workspace:^",
+ "@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-tools": "workspace:^",
+ "@deepseek-ai/dsh-workflow": "workspace:^",
+ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts
new file mode 100644
index 0000000000..83da19b119
--- /dev/null
+++ b/packages/workflow/tool-ralph/src/index.ts
@@ -0,0 +1,384 @@
+/**
+ * Model-facing foreground Ralph loop over the workflow and subagent seams. A
+ * fixed script starts one fresh structured-output child per round, carrying
+ * only the immutable objective and the previous bounded handoff between them.
+ * @module @deepseek-ai/dsh-tool-ralph
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
+import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
+// Declaration merge only: makes ctx.systemPrompt visible for section registration.
+import type {} from '@deepseek-ai/dsh-system-prompt'
+
+export const name = 'tool-ralph'
+export const inject = ['tools', 'workflows', 'subagents', 'systemPrompt']
+
+/** Deployment policy for the fixed Ralph workflow. */
+export interface Config {
+ /** Fresh structured-output provider used for every round (default `spawn`). */
+ subagentProvider?: string
+ /** Default and deployment ceiling for one call's round count (default 256). */
+ maxRounds?: number
+ /** Maximum serialized characters in one structured handoff (default 16384). */
+ maxHandoffChars?: number
+}
+
+/** Schemastery configuration for the Ralph tool. */
+export const Config: z = z.object({
+ subagentProvider: z.string().default('spawn'),
+ maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256),
+ maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),
+})
+
+interface ResolvedConfig {
+ readonly subagentProvider: string
+ readonly maxRounds: number
+ readonly maxHandoffChars: number
+}
+
+type RalphRoundStatus = 'continue' | 'complete' | 'blocked'
+
+interface RalphRoundReport {
+ readonly status: RalphRoundStatus
+ readonly summary: string
+ readonly evidence: string[]
+ readonly nextSteps: string[]
+ readonly blocker: string
+}
+
+type RalphRunStatus = 'complete' | 'blocked' | 'budget-limited'
+
+interface RalphRunResult {
+ readonly status: RalphRunStatus
+ readonly roundsStarted: number
+ readonly report: RalphRoundReport
+}
+
+interface RalphCallArgs {
+ objective: string
+ maxRounds?: number
+}
+
+const RALPH_META = {
+ name: 'ralph-loop',
+ description: 'Iterate toward one objective with a fresh child and bounded structured handoff per round.',
+ phases: [{ title: 'Fresh-agent rounds', detail: 'One clean child context per Ralph round.' }],
+}
+
+/**
+ * Fixed, deployment-owned orchestration. The model supplies data only; it
+ * cannot alter the loop, provider route, schema, or handoff validation.
+ */
+const RALPH_SCRIPT = String.raw`
+const reportSchema = {
+ type: 'object',
+ properties: {
+ status: { type: 'string', enum: ['continue', 'complete', 'blocked'] },
+ summary: { type: 'string' },
+ evidence: { type: 'array', items: { type: 'string' } },
+ nextSteps: { type: 'array', items: { type: 'string' } },
+ blocker: { type: 'string' },
+ },
+ required: ['status', 'summary', 'evidence', 'nextSteps', 'blocker'],
+ additionalProperties: false,
+}
+
+function normalizedText(value) {
+ return typeof value === 'string' && value.length > 0 && value === value.trim()
+}
+
+function normalizedList(value) {
+ return Array.isArray(value) && value.every(normalizedText)
+}
+
+function validateReport(report) {
+ if (report === null || typeof report !== 'object' || Array.isArray(report)) {
+ throw new Error('Ralph child returned no structured round report')
+ }
+ if (!normalizedText(report.summary)) {
+ throw new Error('Ralph round report summary must be non-empty and normalized')
+ }
+ if (!normalizedList(report.evidence) || !normalizedList(report.nextSteps)) {
+ throw new Error('Ralph round report evidence and nextSteps must contain only non-empty normalized strings')
+ }
+ if (typeof report.blocker !== 'string' || report.blocker !== report.blocker.trim()) {
+ throw new Error('Ralph round report blocker must be a normalized string')
+ }
+ switch (report.status) {
+ case 'continue':
+ if (report.nextSteps.length === 0 || report.blocker !== '') {
+ throw new Error('a continuing Ralph report needs nextSteps and an empty blocker')
+ }
+ break
+ case 'complete':
+ if (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '') {
+ throw new Error('a complete Ralph report needs evidence, no nextSteps, and an empty blocker')
+ }
+ break
+ case 'blocked':
+ if (!normalizedText(report.blocker)) {
+ throw new Error('a blocked Ralph report needs a concrete blocker')
+ }
+ break
+ default:
+ throw new Error('Ralph round report status is invalid')
+ }
+ const serialized = JSON.stringify(report)
+ if (serialized.length > args.maxHandoffChars) {
+ throw new Error('Ralph round report exceeds maxHandoffChars (' + serialized.length + ' > ' + args.maxHandoffChars + ')')
+ }
+ return report
+}
+
+let previous
+for (let round = 1; round <= args.maxRounds; round += 1) {
+ phase('Fresh-agent rounds')
+ const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous)
+ const prompt = [
+ 'You are one fresh worker in a foreground Ralph loop. You receive no parent conversation and no prior child session. Do not call the ralph tool: this round already is its worker.',
+ 'Immutable objective:\n' + args.objective,
+ 'Ralph round: ' + round + ' of ' + args.maxRounds + '.',
+ 'The shared workspace and its current working tree are the long-term memory and source of truth. Inspect them before acting, preserve existing work, perform concrete in-scope work, and verify what you change. Treat the previous report only as a bounded handoff; confirm it against the workspace.',
+ 'Previous structured handoff:\n' + prior,
+ 'Return one report with exact normalized strings. Use status continue with at least one nextSteps entry while useful work remains; complete only with concrete evidence and no nextSteps; blocked only when no meaningful progress is possible without human input or an external-state change. blocker must be empty unless blocked.',
+ ].join('\n\n')
+ const report = validateReport(await agent(prompt, {
+ label: 'Ralph round ' + round,
+ phase: 'Fresh-agent rounds',
+ schema: reportSchema,
+ }))
+ if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report }
+ if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report }
+ previous = report
+}
+return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previous }
+`
+
+const DESCRIPTION = 'Run a foreground fresh-agent Ralph loop toward one immutable objective. '
+ + 'Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round '
+ + 'opens a new child with no parent conversation or prior child session; the shared workspace is '
+ + 'long-term memory, and only a bounded structured report crosses rounds. The call returns on '
+ + 'completion, a concrete blocker, or the round limit. Ordinary long-running same-session work '
+ + 'belongs to goal tools.'
+
+/** Validate defaults even when a caller invokes apply() without Loader normalization. */
+function resolveConfig(config: Config): ResolvedConfig {
+ const subagentProvider = config.subagentProvider ?? 'spawn'
+ const maxRounds = config.maxRounds ?? 256
+ const maxHandoffChars = config.maxHandoffChars ?? 16_384
+ if (subagentProvider.length === 0 || subagentProvider !== subagentProvider.trim()) {
+ throw new TypeError('subagentProvider must be a non-empty normalized string')
+ }
+ if (!Number.isSafeInteger(maxRounds) || maxRounds < 1) {
+ throw new TypeError('maxRounds must be a positive safe integer')
+ }
+ if (!Number.isSafeInteger(maxHandoffChars) || maxHandoffChars < 1) {
+ throw new TypeError('maxHandoffChars must be a positive safe integer')
+ }
+ return { subagentProvider, maxRounds, maxHandoffChars }
+}
+
+/** Resolve one model-selected cap against the deployment ceiling. */
+function resolveMaxRounds(requested: number | undefined, ceiling: number): number {
+ const value = requested ?? ceiling
+ if (!Number.isSafeInteger(value) || value < 1) {
+ throw new TypeError('Ralph maxRounds must be a positive safe integer')
+ }
+ if (value > ceiling) {
+ throw new TypeError(`Ralph maxRounds ${value} exceeds the deployment ceiling ${ceiling}`)
+ }
+ return value
+}
+
+/** Require the configured route to mean a genuinely fresh structured child. */
+function requireFreshProvider(ctx: Context, name: string): SubagentProvider {
+ const provider = ctx.subagents.getProvider(name)
+ if (provider === undefined) {
+ throw new Error(`Ralph subagent provider "${name}" is not registered`)
+ }
+ if (!provider.capabilities.outputSchema) {
+ throw new Error(`Ralph subagent provider "${name}" does not support structured output`)
+ }
+ if (provider.inheritsParentContext) {
+ throw new Error(`Ralph subagent provider "${name}" inherits parent context; Ralph requires a fresh provider`)
+ }
+ return provider
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function normalizedText(value: unknown): value is string {
+ return typeof value === 'string' && value.length > 0 && value === value.trim()
+}
+
+function normalizedList(value: unknown): value is string[] {
+ return Array.isArray(value) && value.every(normalizedText)
+}
+
+/** Defensively decode the fixed script's report across an implementation seam. */
+function readReport(value: unknown, expectedStatus: RalphRoundStatus, maxChars: number): RalphRoundReport {
+ if (!isRecord(value)
+ || Object.keys(value).sort().join(',') !== 'blocker,evidence,nextSteps,status,summary'
+ || value['status'] !== expectedStatus
+ || !normalizedText(value['summary'])
+ || !normalizedList(value['evidence'])
+ || !normalizedList(value['nextSteps'])
+ || typeof value['blocker'] !== 'string'
+ || value['blocker'] !== value['blocker'].trim()) {
+ throw new Error('Ralph workflow returned a malformed round report')
+ }
+ const report: RalphRoundReport = {
+ status: expectedStatus,
+ summary: value['summary'],
+ evidence: value['evidence'],
+ nextSteps: value['nextSteps'],
+ blocker: value['blocker'],
+ }
+ if (expectedStatus === 'continue' && (report.nextSteps.length === 0 || report.blocker !== '')) {
+ throw new Error('Ralph workflow returned an invalid continuing report')
+ }
+ if (expectedStatus === 'complete'
+ && (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '')) {
+ throw new Error('Ralph workflow returned an invalid completion report')
+ }
+ if (expectedStatus === 'blocked' && !normalizedText(report.blocker)) {
+ throw new Error('Ralph workflow returned an invalid blocked report')
+ }
+ const chars = JSON.stringify(report).length
+ if (chars > maxChars) {
+ throw new Error(`Ralph workflow returned an oversized handoff (${chars} > ${maxChars})`)
+ }
+ return report
+}
+
+/** Defensively decode the fixed script's terminal value. */
+function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphRunResult {
+ if (!isRecord(value)
+ || Object.keys(value).sort().join(',') !== 'report,roundsStarted,status'
+ || typeof value['roundsStarted'] !== 'number'
+ || !Number.isSafeInteger(value['roundsStarted'])
+ || value['roundsStarted'] < 1
+ || value['roundsStarted'] > maxRounds) {
+ throw new Error('Ralph workflow returned a malformed terminal result')
+ }
+ const roundsStarted = value['roundsStarted']
+ switch (value['status']) {
+ case 'complete':
+ return { status: 'complete', roundsStarted, report: readReport(value['report'], 'complete', maxHandoffChars) }
+ case 'blocked':
+ return { status: 'blocked', roundsStarted, report: readReport(value['report'], 'blocked', maxHandoffChars) }
+ case 'budget-limited':
+ if (roundsStarted !== maxRounds) {
+ throw new Error('Ralph workflow returned budget-limited before the round limit')
+ }
+ return { status: 'budget-limited', roundsStarted, report: readReport(value['report'], 'continue', maxHandoffChars) }
+ default:
+ throw new Error('Ralph workflow returned an unknown terminal status')
+ }
+}
+
+/** A non-clean workflow finish is an error, never a partial Ralph success. */
+function stopReasonError(result: WorkflowResult): string | undefined {
+ switch (result.stopReason) {
+ case 'completed':
+ return undefined
+ case 'cancelled':
+ return `Ralph workflow was cancelled${result.error === undefined ? '' : ` (${result.error})`}`
+ case 'error':
+ return `Ralph workflow failed: ${result.error ?? 'unknown error'}`
+ /* v8 ignore start -- WorkflowStopReason is closed; a future variant must fail loud here. */
+ default:
+ return `Ralph workflow ended abnormally (${String(result.stopReason satisfies never)})`
+ /* v8 ignore stop */
+ }
+}
+
+/** Render the fixed terminal envelope without dropping the bounded report. */
+function renderResult(result: RalphRunResult): string {
+ const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? '' : 's'}`
+ switch (result.status) {
+ case 'complete':
+ return `Ralph completed after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
+ case 'blocked':
+ return `Ralph blocked after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
+ case 'budget-limited':
+ return `Ralph reached its ${rounds} limit with work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
+ }
+}
+
+function presentCall(args: RalphCallArgs): ToolCallView {
+ return { card: 'generic', title: 'ralph', rawInput: args.objective }
+}
+
+function presentResult(args: RalphCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView {
+ void args
+ void result
+ return { card: 'generic' }
+}
+
+/** Register the fixed Ralph tool and its explicit-ask usage policy. */
+export function apply(ctx: Context, config: Config): void {
+ const resolved = resolveConfig(config)
+ ctx.systemPrompt.section({
+ name: 'tool:ralph',
+ order: 116,
+ text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.',
+ })
+ ctx.tools.register(defineTool({
+ name: 'ralph',
+ description: DESCRIPTION,
+ parameters: {
+ objective: {
+ type: 'string',
+ required: true,
+ description: 'The immutable completion objective for every fresh Ralph round.',
+ },
+ maxRounds: {
+ type: 'number',
+ description: 'Optional positive safe-integer round cap, bounded by the deployment ceiling.',
+ },
+ },
+ async execute(args, exec): Promise {
+ const parent = exec.agent
+ if (parent === undefined) {
+ throw new Error('Ralph tool requires a calling agent (exec.agent was undefined)')
+ }
+ const objective = args.objective.trim()
+ if (objective.length === 0) throw new Error('Ralph objective must be a non-empty string')
+ const maxRounds = resolveMaxRounds(args.maxRounds, resolved.maxRounds)
+ void requireFreshProvider(ctx, resolved.subagentProvider)
+
+ const run: WorkflowRun = ctx.workflows.start({
+ script: RALPH_SCRIPT,
+ meta: RALPH_META,
+ args: { objective, maxRounds, maxHandoffChars: resolved.maxHandoffChars },
+ subagentProvider: resolved.subagentProvider,
+ parent,
+ ...exec.signal === undefined ? {} : { signal: exec.signal },
+ })
+ const onAbort = (): void => { run.cancel('parent step aborted') }
+ exec.signal?.addEventListener('abort', onAbort, { once: true })
+ if (exec.signal?.aborted) run.cancel('parent step aborted')
+
+ try {
+ const settled = await run.result
+ const error = stopReasonError(settled)
+ if (error !== undefined) throw new Error(error)
+ const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars)
+ return [{ type: 'text', text: renderResult(value) }]
+ } finally {
+ exec.signal?.removeEventListener('abort', onAbort)
+ await run.dispose()
+ }
+ },
+ presentCall,
+ presentResult,
+ }))
+}
diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts
new file mode 100644
index 0000000000..f984e67d22
--- /dev/null
+++ b/packages/workflow/tool-ralph/tests/integration.spec.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import * as Invariants from '@deepseek-ai/dsh-invariants'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import SubagentService from '@deepseek-ai/dsh-subagent'
+import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
+import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
+import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
+import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import * as toolRalph from '../src/index.ts'
+
+describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
+ it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', async () => {
+ const firstReport = {
+ status: 'continue',
+ summary: 'ROUND_ONE_HANDOFF',
+ evidence: ['Created migration-a.ts.'],
+ nextSteps: ['Finish migration-b.ts.'],
+ blocker: '',
+ }
+ const finalReport = {
+ status: 'complete',
+ summary: 'Both migration slices are complete.',
+ evidence: ['Focused migration tests pass.'],
+ nextSteps: [],
+ blocker: '',
+ }
+ const ctx = new Context()
+ const adapter = new MockAdapter([
+ textResponse('PARENT_HISTORY_MARKER'),
+ toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport),
+ toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport),
+ ])
+ await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(Invariants)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SubagentService)
+ await ctx.plugin(spawn, { providerName: 'spawn' })
+ await ctx.plugin(WorkerWorkflowEngine, {})
+ await ctx.plugin(toolRalph, { maxRounds: 2 })
+ ctx.llm.registerAdapter(['mock'], adapter)
+
+ const parentHandle = await ctx.agents.create({
+ sessionId: SessionId('ralph-parent'),
+ meta: { cwd: '/tmp/ralph-shared-workspace' },
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
+ const parent = parentHandle.agent
+ parent.send([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }])
+ await parent.whenIdle()
+
+ const children: Agent[] = []
+ ctx.on('workflow/agent-start', (_run, child) => {
+ const agent = ctx.agents.get(child.childId)
+ expect(agent).toBeDefined()
+ children.push(agent!)
+ })
+ const result = await ctx.tools.execute({
+ callId: CallId('ralph-integration'),
+ name: 'ralph',
+ arguments: { objective: 'Complete both migration slices.', maxRounds: 2 },
+ agent: parent,
+ })
+
+ expect(result.isError).toBe(false)
+ expect((result.content[0] as { text: string }).text).toContain('Ralph completed after 2 rounds.')
+ expect(children).toHaveLength(2)
+ expect(new Set(children.map(child => child.id)).size).toBe(2)
+ for (const child of children) {
+ expect(child.session.header.cwd).toBe('/tmp/ralph-shared-workspace')
+ expect(child.session.header.parentSession).toBe(parent.session.header.id)
+ expect(child.session.header.seedLength).toBeUndefined()
+ expect(ctx.agents.get(child.id)).toBeUndefined()
+ }
+
+ expect(adapter.requests).toHaveLength(3)
+ const firstChildRequest = JSON.stringify(adapter.requests[1]!.messages)
+ const secondChildRequest = JSON.stringify(adapter.requests[2]!.messages)
+ expect(firstChildRequest).not.toContain('PARENT_PROMPT_MARKER')
+ expect(firstChildRequest).not.toContain('PARENT_HISTORY_MARKER')
+ expect(firstChildRequest).not.toContain('ROUND_ONE_HANDOFF')
+ expect(secondChildRequest).not.toContain('PARENT_PROMPT_MARKER')
+ expect(secondChildRequest).not.toContain('PARENT_HISTORY_MARKER')
+ expect(secondChildRequest).toContain('ROUND_ONE_HANDOFF')
+
+ await parentHandle.dispose()
+ })
+})
diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts
new file mode 100644
index 0000000000..b7a70fd7d7
--- /dev/null
+++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts
@@ -0,0 +1,319 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import SubagentService from '@deepseek-ai/dsh-subagent'
+import type { SubagentCapabilities, SubagentProvider, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
+import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
+import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
+import * as toolRalph from '../src/index.ts'
+
+class StubEngine extends WorkflowService {
+ requests: WorkflowStartRequest[] = []
+ cancels: string[] = []
+ disposed = 0
+ settle!: (result: WorkflowResult) => void
+ startError: Error | undefined
+
+ start(request: WorkflowStartRequest): WorkflowRun {
+ if (this.startError !== undefined) throw this.startError
+ this.requests.push(request)
+ const result = new Promise((resolve) => { this.settle = resolve })
+ return {
+ id: WorkflowRunId(`ralph-${this.requests.length}`),
+ meta: request.meta,
+ result,
+ cancel: (reason?: string) => {
+ this.cancels.push(reason ?? 'cancelled')
+ this.settle({
+ value: null,
+ stopReason: 'cancelled',
+ ...reason === undefined ? {} : { error: reason },
+ agentsStarted: 0,
+ })
+ },
+ dispose: () => {
+ this.disposed += 1
+ return Promise.resolve()
+ },
+ }
+ }
+}
+
+class StubProvider implements SubagentProvider {
+ readonly name = 'fresh'
+ readonly capabilities: SubagentCapabilities
+ readonly inheritsParentContext: boolean
+
+ constructor(options?: { outputSchema?: boolean; inheritsParentContext?: boolean }) {
+ this.capabilities = {
+ outputSchema: options?.outputSchema ?? true,
+ depthLimit: true,
+ toolFilter: true,
+ persona: true,
+ }
+ this.inheritsParentContext = options?.inheritsParentContext ?? false
+ }
+
+ start(_request: SubagentStartRequest): Promise {
+ return Promise.reject(new Error('StubProvider.start must not be reached behind StubEngine'))
+ }
+}
+
+interface SetupOptions {
+ config?: toolRalph.Config
+ provider?: StubProvider | false
+}
+
+async function setup(options?: SetupOptions) {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(SubagentService)
+ const provider = options?.provider === false ? undefined : options?.provider ?? new StubProvider()
+ if (provider !== undefined) ctx.subagents.registerProvider(provider)
+ await ctx.plugin(StubEngine)
+ const config: toolRalph.Config = { subagentProvider: 'fresh' }
+ if (options?.config?.subagentProvider !== undefined) config.subagentProvider = options.config.subagentProvider
+ if (options?.config?.maxRounds !== undefined) config.maxRounds = options.config.maxRounds
+ if (options?.config?.maxHandoffChars !== undefined) config.maxHandoffChars = options.config.maxHandoffChars
+ const fiber = await ctx.plugin(toolRalph, config)
+ const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
+ return { ctx, engine: ctx.workflows as StubEngine, parent, fiber }
+}
+
+function execute(
+ ctx: Context,
+ args: unknown,
+ extra?: { agent?: Agent; signal?: AbortSignal },
+): Promise {
+ return ctx.tools.execute({
+ callId: CallId('ralph-call'),
+ name: 'ralph',
+ arguments: args,
+ ...extra?.agent === undefined ? {} : { agent: extra.agent },
+ ...extra?.signal === undefined ? {} : { signal: extra.signal },
+ })
+}
+
+const CONTINUE = {
+ status: 'continue',
+ summary: 'Implemented the first slice.',
+ evidence: ['Focused tests pass.'],
+ nextSteps: ['Implement the second slice.'],
+ blocker: '',
+}
+
+const COMPLETE = {
+ status: 'complete',
+ summary: 'The objective is complete.',
+ evidence: ['All required gates pass.'],
+ nextSteps: [],
+ blocker: '',
+}
+
+const BLOCKED = {
+ status: 'blocked',
+ summary: 'No local work can progress.',
+ evidence: ['The required remote service is unavailable.'],
+ nextSteps: ['Retry after service recovery.'],
+ blocker: 'The required remote service is unavailable.',
+}
+
+async function settleCompleted(
+ engine: StubEngine,
+ pending: Promise,
+ value: unknown,
+ agentsStarted = 1,
+): Promise {
+ await vi.waitFor(() => { expect(engine.requests.length).toBeGreaterThan(0) })
+ engine.settle({ value, stopReason: 'completed', agentsStarted })
+ return pending
+}
+
+describe('dsh-tool-ralph', () => {
+ it('starts the fixed workflow through the configured fresh provider and renders completion', async () => {
+ const { ctx, engine, parent } = await setup({ config: { maxRounds: 9, maxHandoffChars: 9000 } })
+ const pending = execute(ctx, { objective: ' Finish the migration. ', maxRounds: 4 }, { agent: parent })
+ await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
+ expect(engine.requests[0]).toMatchObject({
+ meta: { name: 'ralph-loop' },
+ args: { objective: 'Finish the migration.', maxRounds: 4, maxHandoffChars: 9000 },
+ subagentProvider: 'fresh',
+ parent,
+ })
+ expect(engine.requests[0]!.script).toContain("status: 'budget-limited'")
+ const result = await settleCompleted(engine, pending, {
+ status: 'complete',
+ roundsStarted: 1,
+ report: COMPLETE,
+ })
+ expect(result.isError).toBe(false)
+ expect((result.content[0] as { text: string }).text).toContain('Ralph completed after 1 round.')
+ expect((result.content[0] as { text: string }).text).toContain('All required gates pass.')
+ expect(engine.disposed).toBe(1)
+ })
+
+ it('renders blocked and budget-limited terminal outcomes as bounded successful results', async () => {
+ const { ctx, engine, parent } = await setup({ config: { maxRounds: 2 } })
+ const blocked = execute(ctx, { objective: 'Ship it.' }, { agent: parent })
+ const blockedResult = await settleCompleted(engine, blocked, {
+ status: 'blocked',
+ roundsStarted: 2,
+ report: BLOCKED,
+ }, 2)
+ expect((blockedResult.content[0] as { text: string }).text).toContain('Ralph blocked after 2 rounds.')
+
+ const limited = execute(ctx, { objective: 'Ship it.' }, { agent: parent })
+ await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) })
+ const limitedResult = await settleCompleted(engine, limited, {
+ status: 'budget-limited',
+ roundsStarted: 2,
+ report: CONTINUE,
+ }, 2)
+ expect((limitedResult.content[0] as { text: string }).text)
+ .toContain('Ralph reached its 2 rounds limit with work remaining.')
+ })
+
+ it('maps workflow error and cancellation reasons to tool errors and always disposes', async () => {
+ const { ctx, engine, parent } = await setup()
+ const failed = execute(ctx, { objective: 'Work.' }, { agent: parent })
+ await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
+ engine.settle({ value: null, stopReason: 'error', error: 'child report malformed', agentsStarted: 1 })
+ expect(((await failed).content[0] as { text: string }).text)
+ .toContain('Ralph workflow failed: child report malformed')
+
+ const unknown = execute(ctx, { objective: 'Work.' }, { agent: parent })
+ await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) })
+ engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 })
+ expect(((await unknown).content[0] as { text: string }).text).toContain('unknown error')
+
+ const cancelled = execute(ctx, { objective: 'Work.' }, { agent: parent })
+ await vi.waitFor(() => { expect(engine.requests).toHaveLength(3) })
+ engine.settle({ value: null, stopReason: 'cancelled', error: 'user stopped', agentsStarted: 0 })
+ expect(((await cancelled).content[0] as { text: string }).text).toContain('cancelled (user stopped)')
+
+ const bare = execute(ctx, { objective: 'Work.' }, { agent: parent })
+ await vi.waitFor(() => { expect(engine.requests).toHaveLength(4) })
+ engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 })
+ expect(((await bare).content[0] as { text: string }).text).toMatch(/cancelled$/)
+ expect(engine.disposed).toBe(4)
+ })
+
+ it('bridges mid-flight and already-aborted parent signals to cancellation', async () => {
+ const { ctx, engine, parent } = await setup()
+ const controller = new AbortController()
+ const pending = execute(ctx, { objective: 'Work.' }, { agent: parent, signal: controller.signal })
+ await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
+ controller.abort()
+ expect((await pending).isError).toBe(true)
+
+ const already = new AbortController()
+ already.abort()
+ expect((await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: already.signal })).isError).toBe(true)
+ expect(engine.cancels).toEqual(['parent step aborted', 'parent step aborted'])
+ expect(engine.disposed).toBe(2)
+ })
+
+ it('rejects absent authority, empty objectives, bad round caps, and schema-invalid calls before start', async () => {
+ const { ctx, engine, parent } = await setup({ config: { maxRounds: 3 } })
+ expect((await execute(ctx, { objective: 'Work.' })).isError).toBe(true)
+ expect((await execute(ctx, { objective: ' ' }, { agent: parent })).isError).toBe(true)
+ for (const maxRounds of [0, 1.5, Number.NaN, 4]) {
+ expect((await execute(ctx, { objective: 'Work.', maxRounds }, { agent: parent })).isError).toBe(true)
+ }
+ const missing = await execute(ctx, {}, { agent: parent })
+ expect(missing.error?.code).toBe('INVALID_ARGS')
+ expect(engine.requests).toHaveLength(0)
+ })
+
+ it('rejects missing, unstructured, and parent-context-inheriting provider routes', async () => {
+ const missing = await setup({ provider: false })
+ expect(((await execute(missing.ctx, { objective: 'Work.' }, { agent: missing.parent })).content[0] as { text: string }).text)
+ .toContain('is not registered')
+ expect(missing.engine.requests).toHaveLength(0)
+
+ const unstructured = await setup({ provider: new StubProvider({ outputSchema: false }) })
+ expect(((await execute(unstructured.ctx, { objective: 'Work.' }, { agent: unstructured.parent })).content[0] as { text: string }).text)
+ .toContain('does not support structured output')
+
+ const inherited = await setup({ provider: new StubProvider({ inheritsParentContext: true }) })
+ expect(((await execute(inherited.ctx, { objective: 'Work.' }, { agent: inherited.parent })).content[0] as { text: string }).text)
+ .toContain('inherits parent context')
+ })
+
+ it('rejects invalid direct-apply config before touching injected services', () => {
+ expect(() => { toolRalph.apply(new Context(), { subagentProvider: ' ' }) }).toThrow('non-empty normalized')
+ expect(() => { toolRalph.apply(new Context(), { maxRounds: 0 }) }).toThrow('positive safe integer')
+ expect(() => { toolRalph.apply(new Context(), { maxHandoffChars: 1.5 }) }).toThrow('positive safe integer')
+ })
+
+ it('turns malformed fixed-workflow terminal values and reports into errors', async () => {
+ const cases: { value: unknown; message: string; config?: toolRalph.Config }[] = [
+ { value: null, message: 'malformed terminal result' },
+ { value: { status: 'complete', roundsStarted: 0, report: COMPLETE }, message: 'malformed terminal result' },
+ { value: { status: 'complete', roundsStarted: 3, report: COMPLETE }, message: 'malformed terminal result', config: { maxRounds: 2 } },
+ { value: { status: 'mystery', roundsStarted: 1, report: COMPLETE }, message: 'unknown terminal status' },
+ { value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE }, message: 'before the round limit', config: { maxRounds: 2 } },
+ { value: { status: 'complete', roundsStarted: 1, report: null }, message: 'malformed round report' },
+ { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, status: 'continue' } }, message: 'malformed round report' },
+ { value: { status: 'budget-limited', roundsStarted: 1, report: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 1 } },
+ { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, evidence: [] } }, message: 'invalid completion report' },
+ { value: { status: 'blocked', roundsStarted: 1, report: { ...BLOCKED, blocker: '' } }, message: 'invalid blocked report' },
+ { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, summary: 'x'.repeat(500) } }, message: 'oversized handoff', config: { maxHandoffChars: 100 } },
+ ]
+ for (const testCase of cases) {
+ const { ctx, engine, parent } = await setup(
+ testCase.config === undefined ? undefined : { config: testCase.config },
+ )
+ const result = await settleCompleted(
+ engine,
+ execute(ctx, { objective: 'Work.', ...testCase.config?.maxRounds === undefined ? {} : { maxRounds: testCase.config.maxRounds } }, { agent: parent }),
+ testCase.value,
+ )
+ expect(result.isError).toBe(true)
+ expect((result.content[0] as { text: string }).text).toContain(testCase.message)
+ }
+ })
+
+ it('surfaces a synchronous engine start failure without inventing a run', async () => {
+ const { ctx, engine, parent } = await setup()
+ engine.startError = new Error('engine refused fixed script')
+ const result = await execute(ctx, { objective: 'Work.' }, { agent: parent })
+ expect(result.isError).toBe(true)
+ expect((result.content[0] as { text: string }).text).toContain('engine refused fixed script')
+ expect(engine.disposed).toBe(0)
+ })
+
+ it('registers scoped guidance and pure replay-safe generic presentation', async () => {
+ const { ctx, fiber } = await setup()
+ const section = (await ctx.systemPrompt.assemble()).sections.find(candidate => candidate.name === 'tool:ralph')
+ expect(section?.text).toContain('ONLY when the direct human explicitly asks')
+ const tool = ctx.tools.get('ralph')!
+ expect(tool.presentCall!({ objective: 'Finish it.' })).toEqual({
+ card: 'generic',
+ title: 'ralph',
+ rawInput: 'Finish it.',
+ })
+ expect(tool.presentResult!({ objective: 'Finish it.' }, { content: [], isError: false })).toEqual({ card: 'generic' })
+ expect(tool.presentCall!({ nope: true })).toBeUndefined()
+ await fiber.dispose()
+ expect(ctx.tools.get('ralph')).toBeUndefined()
+ expect((await ctx.systemPrompt.assemble()).sections.some(candidate => candidate.name === 'tool:ralph')).toBe(false)
+ })
+
+ it('has the namespace-plugin export shape', () => {
+ expect('default' in toolRalph).toBe(false)
+ expect(toolRalph.name).toBe('tool-ralph')
+ expect(toolRalph.inject).toEqual(['tools', 'workflows', 'subagents', 'systemPrompt'])
+ const loader = Object.create(Loader.prototype) as Loader
+ const unwrapped = loader.unwrapExports(toolRalph) as Record
+ expect(unwrapped).toBe(toolRalph)
+ expect(typeof unwrapped.apply).toBe('function')
+ })
+})
diff --git a/packages/workflow/tool-ralph/tsconfig.json b/packages/workflow/tool-ralph/tsconfig.json
new file mode 100644
index 0000000000..294a851a5b
--- /dev/null
+++ b/packages/workflow/tool-ralph/tsconfig.json
@@ -0,0 +1,39 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../../core/agent"
+ },
+ {
+ "path": "../../llm/llm"
+ },
+ {
+ "path": "../../subagent/subagent"
+ },
+ {
+ "path": "../../core/system-prompt"
+ },
+ {
+ "path": "../../core/tools"
+ },
+ {
+ "path": "../workflow"
+ }
+ ]
+}
diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md
index f7fcd6e6d2..23fa849888 100644
--- a/packages/workflow/workflow-workerthread/README.md
+++ b/packages/workflow/workflow-workerthread/README.md
@@ -39,7 +39,7 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide
For each `agent()` call:
1. The worker sends `child-start` with a plain-data prompt and options.
-2. The host calls the configured provider through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal.
+2. The host calls the start request's provider override, or otherwise the configured provider, through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal. Provider choice applies to every child in that run and is not visible to the script.
3. If start rejects, the host sends `child-start-error`; provider startup has already reached quiescence and no child lifecycle event is emitted.
4. If start fulfills while the workflow still admits work, the host records the run, observes `result`, then sends `child-started`. Even an already-settled result is forwarded afterward, preserving start-before-result order.
5. The worker emits paired `workflow/agent-start` and `workflow/agent-end` narration and requests child disposal after collection.
@@ -81,6 +81,8 @@ The host keeps a ledger of forwarded child starts. A graceful worker supplies th
| `syncTimeoutMs` | `5000` | VM timeout for the script's initial synchronous slice. |
| `disposeGraceMs` | `5000` | Bound before force-settlement/termination and for public disposal. |
+An owning consumer may set `WorkflowStartRequest.subagentProvider` for one run. This is an engine-level route, not a script hook or a model-facing option; the ordinary `workflow` tool leaves it unset.
+
## Model Experience
### Child-agent requests
diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts
index 9fed7d3450..604d8ba754 100644
--- a/packages/workflow/workflow-workerthread/src/index.ts
+++ b/packages/workflow/workflow-workerthread/src/index.ts
@@ -137,6 +137,7 @@ class WorkerWorkflowEngine extends WorkflowService {
// the now-inactive engine fiber and break the seam's holder-owned lifetime.
const runCtx = this.ctx
const subagents = runCtx.subagents
+ const subagentProvider = request.subagentProvider ?? this.config.provider
const workerRun = new WorkerRun(
runCtx,
subagents,
@@ -144,7 +145,7 @@ class WorkerWorkflowEngine extends WorkflowService {
meta,
request.parent,
init,
- this.config.provider,
+ subagentProvider,
this.config.disposeGraceMs,
{
phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) },
diff --git a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts
index e9d9a9b4e1..e330b3ee83 100644
--- a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts
+++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts
@@ -27,16 +27,30 @@ import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
const ctx = new Context()
await ctx.plugin(SubagentService)
-await ctx.plugin(WorkerWorkflowEngine, {})
+let selectedStarts = 0
+ctx.subagents.registerProvider({
+ name: 'built-selected',
+ capabilities: { outputSchema: true, depthLimit: false, toolFilter: false, persona: false },
+ inheritsParentContext: false,
+ async start() {
+ selectedStarts += 1
+ return {
+ id: 'built-child',
+ result: Promise.resolve({ output: [], structured: { answer: 42 }, stopReason: 'completed' }),
+ dispose: () => Promise.resolve(),
+ }
+ },
+})
+await ctx.plugin(WorkerWorkflowEngine, { provider: 'must-not-be-used' })
const run = ctx.workflows.start({
- script: 'return 6 * 7',
+ script: "const value = await agent('answer', { schema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] } }); return value.answer",
meta: { name: 'built-smoke', description: 'built worker smoke' },
- // A zero-agent script never touches the provider.
+ subagentProvider: 'built-selected',
parent: { id: 'built-smoke-parent', options: {} },
})
const result = await run.result
await run.dispose()
-if (result.stopReason !== 'completed' || result.value !== 42) {
+if (result.stopReason !== 'completed' || result.value !== 42 || selectedStarts !== 1) {
console.error('unexpected result: ' + JSON.stringify(result))
process.exit(1)
}
diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts
index 2f52cd7b6b..e2cc227704 100644
--- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts
+++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts
@@ -232,6 +232,26 @@ describe('dsh-workflow-workerthread', () => {
expect(provider.runs[0]!.request.agentOptions).toEqual({ provider: 'openai' })
})
+ it('a start-request provider override selects every child without changing the engine default', async () => {
+ const { ctx, parent, provider } = await setup()
+ const selected = new StubProvider('selected', () => text('selected reply'))
+ ctx.subagents.registerProvider(selected)
+
+ const overridden = ctx.workflows.start({
+ ...scripted("return await agent('route this run')"),
+ parent,
+ subagentProvider: 'selected',
+ })
+ expect((await overridden.result).value).toBe('selected reply')
+ await overridden.dispose()
+ expect(selected.runs).toHaveLength(1)
+ expect(provider.runs).toHaveLength(0)
+
+ const ordinary = await run(ctx, parent, scripted("return await agent('use the default')"))
+ expect(ordinary.value).toBe('stub reply')
+ expect(provider.runs).toHaveLength(1)
+ })
+
it('a fatal hook error inside the worker kills the script and reports the error', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, scripted("return await parallel([() => agent('x', { isolation: 'worktree' })])"))
diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md
index 9f2b557811..e21ebcd701 100644
--- a/packages/workflow/workflow/README.md
+++ b/packages/workflow/workflow/README.md
@@ -10,7 +10,7 @@ The workflow seam (`ctx.workflows`) executes a model-written orchestration scrip
A run is holder-owned. Engine-plugin unload prevents new starts but does not revoke accepted runs. The holder must call `dispose()` on every path; disposal cancels remaining work and reaches or abandons quiescence within the documented bound.
-`WorkflowStartRequest` contains `{ meta, script, args?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `meta` and `args` are plain data, not script fragments.
+`WorkflowStartRequest` contains `{ meta, script, args?, subagentProvider?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `subagentProvider` optionally routes every child in that run without exposing provider choice to the script; omission uses the engine's configured provider. `meta` and `args` are plain data, not script fragments.
`WorkflowRun` exposes `{ id, meta, result, cancel(reason?), dispose() }`. `WorkflowResult` contains `{ value, stopReason, error?, agentsStarted }`; `value` is plain JSON data or `null`.
diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts
index faef18aeb0..de6af8f838 100644
--- a/packages/workflow/workflow/src/types.ts
+++ b/packages/workflow/workflow/src/types.ts
@@ -70,6 +70,12 @@ export interface WorkflowStartRequest {
meta: WorkflowMeta
/** Optional input exposed verbatim to the script as the `args` global. */
args?: unknown
+ /**
+ * Optional engine-wide child-provider override for this run. The workflow
+ * script cannot observe or replace it; omission uses the engine's configured
+ * provider.
+ */
+ subagentProvider?: string
/** The agent on whose behalf the run executes (parent of every child). */
parent: Agent
/** Cancels the run when aborted (the tool's `exec.signal`). */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1a45c8b505..2ecae79efe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -200,6 +200,9 @@ importers:
'@deepseek-ai/dsh-tool-goal':
specifier: workspace:*
version: link:../packages/goal/tool-goal
+ '@deepseek-ai/dsh-tool-ralph':
+ specifier: workspace:*
+ version: link:../packages/workflow/tool-ralph
'@deepseek-ai/dsh-tool-subagent':
specifier: workspace:*
version: link:../packages/subagent/tool-subagent
@@ -2514,6 +2517,58 @@ importers:
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/workflow/tool-ralph:
+ dependencies:
+ schemastery:
+ specifier: ^3.18.0
+ version: 3.18.0
+ devDependencies:
+ '@cordisjs/plugin-loader':
+ specifier: workspace:^
+ version: link:../../../vendor/loader
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-agent-loop':
+ specifier: workspace:^
+ version: link:../../core/agent-loop
+ '@deepseek-ai/dsh-agent-loop-testkit':
+ specifier: workspace:^
+ version: link:../../support/agent-loop-testkit
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-subagent':
+ specifier: workspace:^
+ version: link:../../subagent/subagent
+ '@deepseek-ai/dsh-subagent-inprocess':
+ specifier: workspace:^
+ version: link:../../subagent/subagent-inprocess
+ '@deepseek-ai/dsh-subagent-spawn':
+ specifier: workspace:^
+ version: link:../../subagent/subagent-spawn
+ '@deepseek-ai/dsh-system-prompt':
+ specifier: workspace:^
+ version: link:../../core/system-prompt
+ '@deepseek-ai/dsh-tools':
+ specifier: workspace:^
+ version: link:../../core/tools
+ '@deepseek-ai/dsh-workflow':
+ specifier: workspace:^
+ version: link:../workflow
+ '@deepseek-ai/dsh-workflow-workerthread':
+ specifier: workspace:^
+ version: link:../workflow-workerthread
+ cordis:
+ specifier: ^4.0.0-rc.7
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
+
packages/workflow/tool-workflow:
dependencies:
schemastery:
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index bec93c790a..d271192516 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -262,8 +262,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Subagent provider registry',
mode: 'seam',
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'],
- consumers: ['tool-subagent'],
- note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
+ consumers: ['tool-subagent', 'tool-ralph'],
+ note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.',
},
{
key: 'tasks',
@@ -297,8 +297,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Workflow script engine',
mode: 'seam',
implementations: ['workflow-workerthread'],
- consumers: ['tool-workflow'],
- note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.',
+ consumers: ['tool-workflow', 'tool-ralph'],
+ note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
},
]
diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts
index 1921dbf27c..c1fe87eb04 100644
--- a/scripts/gen-tool-catalog.ts
+++ b/scripts/gen-tool-catalog.ts
@@ -37,6 +37,7 @@ import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
+import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
const root = resolve(import.meta.dirname, '..')
@@ -46,7 +47,7 @@ const OUT = 'docs/tool-catalog.md'
function registerCatalogSubagentProvider(ctx: Context, name: string): void {
const provider: SubagentProvider = {
name,
- capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
+ capabilities: { outputSchema: true, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')),
}
@@ -195,6 +196,21 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.',
},
+ {
+ pkg: '@deepseek-ai/dsh-tool-ralph',
+ dir: 'tool-ralph',
+ source: 'packages/workflow/tool-ralph/src/index.ts',
+ requires: ['ctx.tools', 'ctx.workflows', 'ctx.subagents', 'ctx.systemPrompt', 'a calling Agent (exec.agent parents every fresh round)'],
+ writes: ['tool/call', 'tool/result', 'workflow and child session events during execution'],
+ async mount(ctx) {
+ await ctx.plugin(SubagentService)
+ registerCatalogSubagentProvider(ctx, 'mock')
+ await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
+ await ctx.plugin(ToolRalph, { subagentProvider: 'mock' })
+ },
+ note:
+ 'A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap.',
+ },
{
pkg: '@deepseek-ai/dsh-tool-skill',
dir: 'tool-skill',
diff --git a/tsconfig.json b/tsconfig.json
index 085c533130..cfda7192e7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -106,6 +106,7 @@
{ "path": "./packages/workflow/workflow" },
{ "path": "./packages/workflow/workflow-workerthread" },
{ "path": "./packages/workflow/tool-workflow" },
+ { "path": "./packages/workflow/tool-ralph" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/cordis/tool-cordis" },
From fffd8474ad988088824a1da07bdc2c3bba352cc6 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 02:19:36 +0800
Subject: [PATCH 02/14] docs(agent-note): record implemented goal execution
stack
---
.../2026-07-16-harness-level-loop.i18n.yaml | 4 +-
.../feature/2026-07-16-harness-level-loop.md | 126 +++++++
.../2026-07-16-harness-level-loop.zh.md | 126 +++++++
.../feature/2026-07-16-harness-level-loop.md | 343 ------------------
.../2026-07-16-harness-level-loop.zh.md | 343 ------------------
5 files changed, 254 insertions(+), 688 deletions(-)
rename .agents/notes/{proposed => implemented}/feature/2026-07-16-harness-level-loop.i18n.yaml (65%)
create mode 100644 .agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
create mode 100644 .agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
delete mode 100644 .agents/notes/proposed/feature/2026-07-16-harness-level-loop.md
delete mode 100644 .agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md
diff --git a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
similarity index 65%
rename from .agents/notes/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml
rename to .agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
index 743d50cc5e..9f519419e7 100644
--- a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml
+++ b/.agents/notes/implemented/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: 97041b1c08d5fb85a222824ee6d8dfd55e74f4bd
-2026-07-16-harness-level-loop.zh.md: 6460254b07b0e185ff2a40c415d34bd25b6ad925
+2026-07-16-harness-level-loop.md: 76b4efbe42bd0938fcaff14e96ad4e73883e602d
+2026-07-16-harness-level-loop.zh.md: 443501cfaf8a37a210786d4b251e58116c508ba0
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
new file mode 100644
index 0000000000..76b4efbe42
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
@@ -0,0 +1,126 @@
+# Agent Note: Harness-level goal-based execution
+
+Status: implemented
+
+English | [中文](2026-07-16-harness-level-loop.zh.md)
+
+## Problem
+
+The concrete agent loop owns one turn: it drains admitted input, performs one or more model-and-tool steps, and stops. Substantial objectives often need an outer policy that can begin another turn, retain progress, stop at a budget, and remain intelligible to humans. A timed prompt, a same-session continuation, and a fresh-agent Ralph attempt all repeat work, but they do not share the same state, authority, memory, or lifecycle.
+
+Treating every repeated action as one generic “loop” obscures those differences. Same-session work must persist the human objective in the existing transcript while preserving conversation context. Ralph work must intentionally discard conversation context and use the workspace plus a bounded handoff. Human-facing status must not imply that reopening a session silently authorizes more work. Completion and blocker claims also need an explicit trust boundary rather than being smuggled into a scheduler abstraction.
+
+The repository therefore needs goal-based execution above the turn/step loop, but it does not need a speculative universal loop service that combines persistence, evaluation, budgeting, scheduling, handoff, background tasks, and UI.
+
+## Decision
+
+This proposal is implemented in amended form as two explicit plugin policies over existing seams:
+
+1. **Same-session goals** retain one durable objective in the current session and admit goal-attributed continuation turns only while live activation is armed.
+2. **Fresh-agent Ralph runs** execute a fixed foreground workflow whose rounds each spawn a new structured child with no conversation seed.
+
+There is no `packages/loop/` family, `LoopDriver`, `LoopId`, universal `StopCondition`, or model-facing generic `loop` tool. The two policies share the repository's ordinary agent, session, tools, workflow, subagent, and UI extension seams, but they do not pretend that one lifecycle fits both.
+
+### Vocabulary and policy boundary
+
+The same-session hierarchy is **Goal → Goal Round → Turn → Step**. A goal round is one continuation cycle admitted for the current goal and materialized as one goal-sourced turn. Human or unrelated turns in the same session do not consume the goal-round cap, and a turn may still contain multiple model/tool steps.
+
+The fresh-agent hierarchy is **Ralph Run → Ralph Round → fresh child Turn → Step**. One Ralph round creates one child session. The parent transcript and prior child transcripts are not seed context; the shared workspace and one bounded structured report carry cross-round state.
+
+“Round” is therefore an outer policy iteration, not a synonym for every session turn. The concrete `dsh-agent-loop` remains the turn/step engine. The same-session driver uses public agent and session events; its only core addition is the generic observe-before-cancel `agent/cancel-requested` notification needed by any lifecycle policy that must settle cancellation safely.
+
+Time-based `/loop` or scheduled execution is a third policy and is not implemented by this decision. It belongs with a scheduler rather than either goal family.
+
+### Package topology and owning verbs
+
+| Package | Repository category | Owned structures and verbs |
+|---|---|---|
+| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`, domain service | Owns `GoalId`, compare-and-set `GoalRef`, `GoalSnapshot`, `GoalPhase`, process-local `GoalActivation`, replay folding, and `get`, `create`, `edit`, `pause`, `resume`, `complete`, `block`, `markUsageLimited`, `markBudgetLimited`, `clear`, and `disarm` verbs. |
+| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`, model-facing consumer | Registers exclusive `get_goal`, `create_goal`, and `update_goal`; authenticates live turn provenance and narrows autonomous-round authority to completion or blocking reports. |
+| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`, continuation policy | Reserves, fences, admits, attributes, settles, cancels, and quiescently drains same-session goal rounds without importing the concrete loop. |
+| `@deepseek-ai/dsh-commands` | `packages/ui/commands/`, UI registry | Owns `CommandDefinition`, discovery, scoped registration, direct dispatch, `CommandResult`, and request cancellation for human-only commands. |
+| `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`, human-command producer | Registers `/goal` status, creation, edit, pause, resume, and clear over the goal domain for TUI and ACP. |
+| `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`, fixed workflow consumer | Registers `ralph({ objective, maxRounds? })`, validates the fresh structured provider and bounded `RalphRoundReport`, and returns `complete`, `blocked`, or `budget-limited`. |
+
+The detailed contracts live in the [goal-domain](2026-07-19-persisted-same-session-goal-domain.md), [model goal-tools](2026-07-19-model-facing-goal-tools.md), [goal-round driver](2026-07-19-same-session-goal-round-driver.md), [command registry](2026-07-19-plugin-command-registration.md), [human goal-command](2026-07-19-human-goal-command.md), and [Ralph workflow-tool](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Notes.
+
+### Durable goal state and live authority
+
+One session has at most one current goal. Every non-clear mutation appends a full, versioned, model-visible goal snapshot through `Agent.inject()`; clear appends a revisioned tombstone. The session log is the only durable source of truth, so normal persistence, resume, compaction semantics, and `SessionStore.fork()` carry the goal without a second database or an artificial cancellation record.
+
+Durable phases are `active`, `paused`, `blocked`, `usage-limited`, `budget-limited`, and `complete`. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed.
+
+This separation makes session restoration observable and unsurprising. Reopening a session never starts goal work by itself. A later human prompt such as “continue”, “resume the goal”, or an equivalent request in any language gives the runtime-root model a new turn in which it may read the goal and call `update_goal(..., action: 'resume')`. `/goal resume` is the direct human-command path. The runtime authenticates that the request came from a live direct-human turn; prompt policy lets the model interpret whether the wording semantically authorizes creation or resumption.
+
+Forked sessions inherit the durable goal prefix because that is the natural replay result. The fork starts disarmed, so inheritance does not imply execution authority and no synthetic goal cancellation is inserted into history.
+
+`defaultMaxGoalRounds` is configurable and defaults to `256`. The cap counts only admitted goal rounds. `blockedAfterConsecutiveRounds` is separately configurable in the model-tool policy and defaults to `3`; it is a mechanical lower bound before an autonomous round may report a repeated blocker, not an evaluator of semantic sameness.
+
+### Same-session continuation
+
+The goal-round driver owns at most one pending reservation per exact live agent. It admits a reservation only when the goal is active and armed, the agent is idle, no competing human work exists, pending mutations are durable, the exact goal id/revision/round still matches, and downstream prompt policy accepts it. The prompt-submit fence checks those facts both before and after asynchronous listeners, preventing an edit, pause, human message, or unload race from admitting obsolete work.
+
+Only the durable goal-sourced `user/message` charges a round. Stale reservations become rejected zero-step turns without consuming the cap. A concurrent goal revision wins over settlement from an older round.
+
+Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses; rate limiting records `usage-limited`; cap exhaustion records `budget-limited`; other errors, max-token stops, policy rejections, and unknown terminal results block for inspection. The driver never invents an automatic retry after an abnormal outcome. A human can later authorize resume through ordinary language or `/goal resume`.
+
+### Human and model surfaces
+
+The human UX follows the compact current [Codex `/goal` command shape](https://learn.chatgpt.com/docs/developer-commands?surface=cli): `/goal` shows status, `/goal ` creates, and `edit`, `pause`, `resume`, or `clear` perform direct lifecycle actions. Status includes durable phase, admitted/capped rounds, and live armed/disarmed activation. Direct status and command output do not enter model history; accepted domain mutations remain reconstructable because the goal service records them.
+
+The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Direct-human provenance is enforced in code; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective.
+
+TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. Line-oriented stdio does not consume the command plane; its ordinary human text can still authorize model goal tools when that stack is mounted.
+
+### Fresh-agent Ralph execution
+
+Ralph is a first-class model tool in its own plugin, demonstrating that a sophisticated fixed execution policy can be composed without a new loop core. The plugin owns a fixed workflow script over `ctx.workflows` and `ctx.subagents`; it does not create session-goal state or add a branch to `dsh-agent-loop`.
+
+Each round uses an explicit `WorkflowStartRequest.subagentProvider`, defaulting to `spawn`. The provider must exist, support structured output, and declare that it does not inherit parent context. The child inherits cwd and lineage but receives only the immutable objective, round/cap, workspace-as-authority instruction, and previous normalized report.
+
+A report contains status, summary, evidence, next steps, and blocker text. Status-specific invariants and serialized size are validated inside the fixed script and again at the consumer boundary. `maxRounds` is configurable, defaults to `256`, and is the ceiling for a call override. `maxHandoffChars` defaults to `16384`; oversized reports fail rather than being silently truncated.
+
+The tool is foreground and process-local. The parent tool call waits for the terminal result, propagates cancellation into the worker engine, and awaits `run.dispose()` so child work is quiescent before return. The model sees one call and one bounded terminal result; intermediate child conversations remain outside the parent transcript.
+
+### External design lineage
+
+Codex provides the minimal observable goal UX used here: a persistent chat-attached target with set, view, edit, pause, resume, and clear controls. This implementation adopts that discoverability while using this repository's event-sourced goal record, plugin scopes, and runtime authority checks.
+
+Current [Claude Code goals](https://code.claude.com/docs/en/goal) reinforce the distinction between a goal that starts another turn after the previous turn and a timed `/loop`. Claude Code also uses a separate small-model evaluator after each turn. This implementation adopts the policy distinction but intentionally does not copy that evaluator: evaluator inputs, tool access, deterministic checks, provider choice, isolation, and authority need a separately designed plugin contract rather than an implicit self-certification layer.
+
+External products are comparators, not compatibility targets. The local source studies informed the boundaries, while the shipped interfaces follow this repository's “everything is a plugin”, model-visible-is-logged, explicit default resolution, and quiescent teardown rules.
+
+### Verification
+
+The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, command discovery in TUI/ACP, ACP transcript isolation, fixed Ralph provider routing, distinct unseeded children, bounded handoff, terminal outcomes, and cancellation quiescence. Package sources remain under the repository's per-file 100% coverage gate, and the assembled applications are pinned by keyless replay snapshots and built-binary tests.
+
+## Alternatives considered
+
+- **Implement the original universal loop capability seam** — rejected because `Evaluator`, `BudgetPolicy`, `RoundHandoff`, `GoalReflector`, background task ownership, persistence, and scheduling do not form one coherent mandatory abstraction. Building all of them before their first concrete consumers would create broad speculative surface and duplicate existing session, workflow, subagent, and task machinery.
+- **Implement only same-session goals** — rejected because fresh-context iteration is materially different and is a valuable demonstration of the plugin architecture. Ralph belongs as a fixed workflow consumer with explicit context reset.
+- **Put Ralph inside the goal-round driver** — rejected because same-session goals deliberately preserve one conversation while Ralph deliberately removes it. Combining them would make activation, replay, handoff, and UI state ambiguous.
+- **Treat a fork as a fresh Ralph child** — rejected because a fork carries a conversation prefix. Fresh children plus workspace state and one explicit report are easier to bound and replay without a synthetic cancel record.
+- **Copy Claude Code's evaluator into the first goal implementation** — rejected because a transcript-only model evaluator is one useful policy, not a generally trustworthy completion certificate. Deterministic evaluation and isolation must remain possible, so the evaluator is deferred until its authority and provider seam are designed.
+- **Automatically continue after session restore** — rejected because opening a session is observation, not authority to spend resources. Durable state is restored while activation waits for a new human prompt.
+- **Route `/goal` through the model** — rejected because status and explicit lifecycle controls should be deterministic, token-free UI actions; ordinary natural-language prompts remain the semantic model path.
+- **Modify the concrete agent loop with goal or Ralph modes** — rejected because public queue, prompt, session, cancellation, workflow, and subagent seams already support both policies. The generic cancel-requested observation is the only core coordination addition.
+
+## Consequences
+
+- Goal-based execution ships without one overloaded “loop” object: same-session continuation and fresh-agent iteration have explicit, separately testable contracts.
+- Durable goal history is replayable and forkable, while process-local activation prevents accidental work on resume.
+- Humans receive a small Codex-shaped UX; models receive a compact provenance-checked tool surface; deployments can remove either independently.
+- Ralph demonstrates a nontrivial fixed policy entirely as a plugin over existing workflow and subagent primitives.
+- Round limits are generous by default but remain deployment-controlled. They bound iterations, not tokens, price, elapsed time, or external side effects.
+- The original proposal's evaluator, budget, reflector, background-task, CLI, and generic loop-session architecture is intentionally not part of the implemented public surface.
+
+## Known limitations and deferred work
+
+- **Independent evaluation** — same-session completion/blocking and Ralph terminal status are model or worker declarations. A separate evaluator, evaluator-driven feedback round, completion certificate, deterministic checker, adversarial verifier, and criteria/executor/isolation contract remain deferred.
+- **Aggregate budgets** — `maxGoalRounds` and Ralph `maxRounds` are the only aggregate effort limits. Token, currency, elapsed-time, provider-usage, and per-round price admission policies are absent.
+- **No persistent autonomous runner** — same-session goal facts persist, but activation and scheduling are process-local and deliberately wait for human input after restore. Ralph runs are foreground and cannot resume after process loss. Background collection, restart recovery, and unattended resident execution are deferred.
+- **No time scheduler** — interval `/loop`, cron, proactive maintenance, and cloud or desktop scheduling are outside this decision.
+- **No generic loop journal or execution-world rewind** — session replay reconstructs model-visible goal history, not prior files, processes, environment, credentials, or external side effects. Ralph treats the current workspace as authority and carries no cross-run journal.
+- **No goal reflector** — concern events, automatic no-progress heuristics, goal revision by an independent reflector, stuck-pattern detection, and `loop_split` are not implemented. Humans can edit, pause, clear, or resume the goal directly.
+- **Ralph policy remains narrow** — one round creates one fresh child; within-round fan-out, evaluator/worker role separation, dynamic provider/model selection, and structural recursive-Ralph tool denial need separate policy surfaces. Prompt guidance is not enforcement.
+- **Portable UI remains modest** — TUI and ACP render plain-text goal status and generic Ralph cards. There is no continuous status widget, reconnectable command output, modal goal editor, or command plane in stdio/JSON-RPC.
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
new file mode 100644
index 0000000000..443501cfaf
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
@@ -0,0 +1,126 @@
+# Agent Note: Harness 层目标式执行
+
+Status: implemented
+
+[English](2026-07-16-harness-level-loop.md) | 中文
+
+## 问题
+
+具体 agent loop 只拥有一个 Turn:它排空已接纳输入,执行一个或多个模型与工具 Step,然后停止。大型目标通常需要一项外层策略来开始另一个 Turn、保留进度、在预算处停止,并让人类能够理解其状态。定时提示词、同会话续行和全新 agent Ralph 尝试都会重复工作,但它们并不共享相同的状态、权限、记忆或生命周期。
+
+若把每种重复动作都称为一个通用“loop”,就会掩盖这些差异。同会话工作必须在现有转录中持久化人类目标,同时保留对话上下文。Ralph 工作必须有意丢弃对话上下文,只使用工作区和一份有界交接。面向人类的状态不能暗示重新打开会话就会静默授权更多工作。完成与阻塞声明也需要显式信任边界,而不能被偷渡进调度器抽象。
+
+因此,本仓库需要位于 Turn/Step loop 之上的目标式执行,但不需要一个把持久化、评估、预算、调度、交接、后台任务和 UI 组合在一起的推测性通用 loop 服务。
+
+## 决策
+
+本提案以修订后的形式实现为构建在现有接缝之上的两项显式插件策略:
+
+1. **同会话目标**在当前会话中保留一个持久目标,并且只在实时激活态已激活时接纳带目标归属的续行 Turn。
+2. **全新 agent Ralph 运行**执行一个固定前台工作流,其中每个 Round 都生成一个不带对话种子的全新结构化子 agent。
+
+系统中没有 `packages/loop/` 包族、`LoopDriver`、`LoopId`、通用 `StopCondition` 或面向模型的通用 `loop` 工具。两项策略共享本仓库普通的 agent、session、tools、workflow、subagent 与 UI 扩展接缝,但不会假装一种生命周期可以同时适配两者。
+
+### 词汇与策略边界
+
+同会话层级是 **Goal → Goal Round → Turn → Step**。一个 Goal Round 是为当前目标接纳的一次续行周期,并实体化为一个带目标来源的 Turn。同一会话中的人类 Turn 或无关 Turn 不会消耗目标回合上限,而一个 Turn 仍可包含多个模型/工具 Step。
+
+全新 agent 层级是 **Ralph Run → Ralph Round → fresh child Turn → Step**。一个 Ralph Round 创建一个子会话。父转录和此前子转录都不是种子上下文;共享工作区与一份有界结构化报告承载跨 Round 状态。
+
+因此,“Round”是外层策略迭代,不是每个会话 Turn 的同义词。具体 `dsh-agent-loop` 仍是 Turn/Step 引擎。同会话驱动器使用公开 agent 与 session 事件;它对核心唯一的新增项是通用的取消前观察通知 `agent/cancel-requested`,任何需要安全收敛取消的生命周期策略都可以使用它。
+
+基于时间的 `/loop` 或定时执行是第三种策略,本决策不实现它。它应归属于调度器,而不是任一目标包族。
+
+### 包拓扑与所属动词
+
+| 包 | 仓库类别 | 所属结构与动词 |
+|---|---|---|
+| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`,领域服务 | 拥有 `GoalId`、比较并交换 `GoalRef`、`GoalSnapshot`、`GoalPhase`、进程本地 `GoalActivation`、重放折叠,以及 `get`、`create`、`edit`、`pause`、`resume`、`complete`、`block`、`markUsageLimited`、`markBudgetLimited`、`clear` 与 `disarm` 动词。 |
+| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`,面向模型消费者 | 注册互斥的 `get_goal`、`create_goal` 与 `update_goal`;认证实时 Turn 来源,并把自治 Round 权限收窄到完成或阻塞报告。 |
+| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`,续行策略 | 在不导入具体 loop 的情况下,预留、设围栏、接纳、归属、结算、取消并静止排空同会话目标回合。 |
+| `@deepseek-ai/dsh-commands` | `packages/ui/commands/`,UI 注册表 | 拥有面向人类专用命令的 `CommandDefinition`、发现、作用域注册、直接分发、`CommandResult` 与请求取消。 |
+| `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`,人类命令生产方 | 为 TUI 和 ACP 注册构建在目标领域之上的 `/goal` 状态、创建、编辑、暂停、恢复与清除。 |
+| `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`,固定工作流消费者 | 注册 `ralph({ objective, maxRounds? })`,验证全新结构化 provider 与有界 `RalphRoundReport`,并返回 `complete`、`blocked` 或 `budget-limited`。 |
+
+详细契约分别由[目标领域](2026-07-19-persisted-same-session-goal-domain.md)、[模型目标工具](2026-07-19-model-facing-goal-tools.md)、[目标回合驱动器](2026-07-19-same-session-goal-round-driver.md)、[命令注册表](2026-07-19-plugin-command-registration.md)、[人类目标命令](2026-07-19-human-goal-command.md)与 [Ralph 工作流工具](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Note 拥有。
+
+### 持久目标状态与实时权限
+
+一个会话至多有一个当前目标。每次非清除变更都通过 `Agent.inject()` 追加一份完整、带版本且模型可见的目标快照;清除会追加带修订号的墓碑。会话日志是唯一持久事实来源,因此普通持久化、恢复、压缩语义与 `SessionStore.fork()` 会携带目标,无需第二个数据库或人为取消记录。
+
+持久阶段为 `active`、`paused`、`blocked`、`usage-limited`、`budget-limited` 与 `complete`。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 重放、驱动器替换和驱动器拆卸都会让目标保持未激活。
+
+这种分离让会话恢复可观察且符合直觉。重新打开会话绝不会自行开始目标工作。随后的人类提示词,例如“继续”、“恢复目标”或任何语言中的等价请求,会给运行时根 agent 的模型一个新 Turn;模型可在其中读取目标并调用 `update_goal(..., action: 'resume')`。`/goal resume` 是直接人类命令路径。运行时认证请求来自实时直接人类 Turn;提示策略让模型解释措辞在语义上是否授权创建或恢复。
+
+fork 会话会继承持久目标前缀,因为这是自然的重放结果。fork 从未激活状态开始,因此继承不等于执行权限,历史中也不会插入合成目标取消。
+
+`defaultMaxGoalRounds` 可配置且默认为 `256`。该上限只计算已接纳目标回合。`blockedAfterConsecutiveRounds` 在模型工具策略中单独配置且默认为 `3`;它只是在自治 Round 报告重复阻塞前的机械下限,不是对语义相同性的评估器。
+
+### 同会话续行
+
+目标回合驱动器为每个准确实时 agent 至多拥有一个待定预留。只有目标处于活跃且已激活状态、agent 空闲、不存在竞争性人类工作、待定变更已经持久、准确目标 id/修订号/Round 仍匹配,并且下游提示词策略接受时,它才会接纳预留。prompt-submit 围栏在异步监听器前后都检查这些事实,防止编辑、暂停、人类消息或卸载竞争接纳过时工作。
+
+只有持久的目标来源 `user/message` 会计入一个 Round。过时预留会成为未消耗上限的零 Step 拒绝 Turn。并发目标修订会胜过旧 Round 的结算。
+
+普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停;速率限制记录 `usage-limited`;上限耗尽记录 `budget-limited`;其他错误、max-token 停止、策略拒绝和未知终止结果会进入阻塞状态以供检查。驱动器绝不会在异常结果后凭空发起自动重试。人类随后可以通过普通语言或 `/goal resume` 授权恢复。
+
+### 人类与模型表面
+
+人类 UX 遵循当前紧凑的 [Codex `/goal` 命令形态](https://learn.chatgpt.com/docs/developer-commands?surface=cli):`/goal` 显示状态,`/goal ` 创建目标,而 `edit`、`pause`、`resume` 或 `clear` 执行直接生命周期操作。状态包含持久阶段、已接纳/上限 Round 数以及实时已激活/未激活状态。直接状态与命令输出不会进入模型历史;已接受领域变更仍可重建,因为目标服务会记录它们。
+
+模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。
+
+TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。行式 stdio 不消费命令平面;挂载目标栈后,它的普通人类文本仍可授权模型目标工具。
+
+### 全新 agent Ralph 执行
+
+Ralph 是位于自有插件中的一等模型工具,展示了复杂固定执行策略可以在没有新 loop 核心的情况下组合完成。该插件拥有构建在 `ctx.workflows` 与 `ctx.subagents` 之上的固定工作流脚本;它不会创建会话目标状态,也不会为 `dsh-agent-loop` 增加分支。
+
+每个 Round 都使用显式 `WorkflowStartRequest.subagentProvider`,默认为 `spawn`。该 provider 必须存在、支持结构化输出,并声明不继承父上下文。子 agent 继承 cwd 与谱系,但只接收不可变目标、当前 Round/上限、以工作区为权威的指令和上一份规范化报告。
+
+报告包含状态、摘要、证据、下一步与阻塞文本。固定脚本内部和消费者边界都会验证状态专用不变量与序列化大小。`maxRounds` 可配置,默认为 `256`,并作为调用覆盖值的上限。`maxHandoffChars` 默认为 `16384`;过大报告会失败,而不会被静默截断。
+
+该工具位于前台且只存在于进程内。父工具调用等待终止结果,把取消传播到工作线程引擎,并等待 `run.dispose()`,因此返回前子工作已达到静止。模型只看到一次调用和一份有界终止结果;中间子 agent 对话不会进入父转录。
+
+### 外部设计谱系
+
+Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天的持久目标,以及设置、查看、编辑、暂停、恢复与清除控制。本实现采用这种可发现性,但使用本仓库的事件溯源目标记录、插件作用域与运行时权限检查。
+
+当前 [Claude Code goals](https://code.claude.com/docs/en/goal) 进一步验证了“前一 Turn 后启动另一 Turn 的目标”和定时 `/loop` 之间的区别。Claude Code 还会在每个 Turn 后使用独立小模型评估器。本实现采用策略区分,但有意不复制该评估器:评估器输入、工具访问、确定性检查、provider 选择、隔离与权限需要单独设计的插件契约,而不是隐式自我认证层。
+
+外部产品只是比较对象,不是兼容目标。本地源码研究帮助确定边界,而交付接口遵循本仓库“一切皆插件”、模型可见即可记录、显式解析默认值与静止拆卸规则。
+
+### 验证
+
+六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、TUI/ACP 命令发现、ACP 转录隔离、固定 Ralph provider 路由、互不相同且无种子的子 agent、有界交接、终止结果与取消静止性。包源码继续受仓库逐文件 100% 覆盖率门禁约束,组装后应用由无密钥重放快照与构建后二进制测试固定。
+
+## 考虑过的替代方案
+
+- **实现原始通用 loop 能力接缝**——不予采纳,因为 `Evaluator`、`BudgetPolicy`、`RoundHandoff`、`GoalReflector`、后台任务所有权、持久化与调度并不构成一项一致的必选抽象。在出现首个具体消费者前全部构建,会产生宽泛推测性表面,并重复现有 session、workflow、subagent 与 task 机制。
+- **只实现同会话目标**——不予采纳,因为全新上下文迭代在实质上不同,也是插件架构的重要示范。Ralph 应作为带显式上下文重置的固定工作流消费者。
+- **把 Ralph 放进目标回合驱动器**——不予采纳,因为同会话目标有意保留一段对话,而 Ralph 有意移除对话。合并两者会让激活、重放、交接与 UI 状态含糊不清。
+- **把 fork 当成全新 Ralph 子 agent**——不予采纳,因为 fork 会携带对话前缀。全新子 agent 加工作区状态与一份显式报告更容易限制和重放,并且无需合成取消记录。
+- **把 Claude Code 评估器复制进首个目标实现**——不予采纳,因为只读取转录的模型评估器是一项有用策略,但不是普遍可信的完成证书。系统必须仍能支持确定性评估与隔离,因此评估器延期到其权限与 provider 接缝完成设计之后。
+- **会话恢复后自动续行**——不予采纳,因为打开会话是观察行为,不是花费资源的权限。系统恢复持久状态,而激活态等待新的人类提示词。
+- **通过模型路由 `/goal`**——不予采纳,因为状态与显式生命周期控制应是确定、零 token 的 UI 操作;普通自然语言提示词仍是语义模型路径。
+- **为具体 agent loop 增加目标或 Ralph 模式**——不予采纳,因为公开队列、提示词、会话、取消、工作流与 subagent 接缝已经支持两项策略。通用 cancel-requested 观察是唯一核心协调新增项。
+
+## 后果
+
+- 目标式执行在没有单个过载“loop”对象的情况下交付:同会话续行与全新 agent 迭代拥有显式、可独立测试的契约。
+- 持久目标历史可以重放和 fork,而进程本地激活态会防止恢复时意外开始工作。
+- 人类获得小型 Codex 形态 UX;模型获得紧凑、带来源检查的工具表面;部署可以独立移除任一能力。
+- Ralph 展示了非平凡固定策略可以完全作为现有 workflow 与 subagent 原语之上的插件实现。
+- Round 上限默认宽裕,但仍由部署控制。它限制迭代次数,不限制 token、价格、耗时或外部副作用。
+- 原始提案中的评估器、预算、反思器、后台任务、CLI 与通用 loop-session 架构有意不进入已实现公开表面。
+
+## 已知限制与延期工作
+
+- **独立评估**——同会话完成/阻塞和 Ralph 终止状态都是模型或工作者声明。独立评估器、评估器驱动反馈 Round、完成证书、确定性检查器、对抗式 verifier 与 criteria/executor/isolation 契约均予以延期。
+- **聚合预算**——`maxGoalRounds` 与 Ralph `maxRounds` 是唯一聚合工作量限制。token、货币、耗时、provider 用量与逐 Round 价格准入策略均不存在。
+- **没有持久自治运行器**——同会话目标事实会持久化,但激活与调度只存在于进程内,并且有意在恢复后等待人类输入。Ralph 位于前台,进程丢失后无法恢复。后台收集、重启恢复与无人值守常驻执行均予以延期。
+- **没有时间调度器**——间隔 `/loop`、cron、主动维护以及云端或桌面调度不在本决策范围内。
+- **没有通用 loop 日志或执行世界回退**——会话重放会重建模型可见目标历史,而不会恢复此前文件、进程、环境、凭据或外部副作用。Ralph 把当前工作区作为权威,并且没有跨运行日志。
+- **没有目标反思器**——concern 事件、自动无进展启发式、由独立反思器执行的目标修订、卡住模式检测与 `loop_split` 均未实现。人类可以直接编辑、暂停、清除或恢复目标。
+- **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与结构化递归 Ralph 工具禁止都需要独立策略表面。提示词指导不是强制执行。
+- **可移植 UI 仍较朴素**——TUI 与 ACP 渲染纯文本目标状态和通用 Ralph 卡片。系统没有持续状态组件、可重连命令输出、模态目标编辑器,也没有 stdio/JSON-RPC 命令平面。
diff --git a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.md b/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.md
deleted file mode 100644
index 97041b1c08..0000000000
--- a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.md
+++ /dev/null
@@ -1,343 +0,0 @@
-# Agent Note: 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-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.
-
-## 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, 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-sdk loop`
-
-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. **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.
-
-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'; observed: number; maximum: number }
- | { 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 diagnostic and replay capabilities.
-
-- **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 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.
-
-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).
-
-`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 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 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.
-
-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 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 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**:
-
-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**:
-
-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 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**:
-
-- `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"). 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**: `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
-
-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 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, signal: AbortSignal): Promise
-}
-
-export {}
-```
-
-Phase 1 ships the fresh backend; Phase 2 adds the two continuation backends after provider continuation exists:
-
-| 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.
-
-**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-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.
-
-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 }
-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**. 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
-- **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 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: 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 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 })` 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-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 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 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; 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).
-
-One dependency is not yet landed:
-
-- 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 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 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 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).
-
-## 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 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` 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 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.
-
-**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 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
-- `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
-
-**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'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. 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 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.
-
-**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/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md
deleted file mode 100644
index 6460254b07..0000000000
--- a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md
+++ /dev/null
@@ -1,343 +0,0 @@
-# Agent Note: 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-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 决定成败、预算是硬约束、跑完还需要能回看和恢复。
-
-## 提案
-
-**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` 判别联合、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-sdk loop`
-
-设计围绕四个具体问题展开,在每项能力出现调用方的 phase 中通过 service seam 或显式 driver policy 解决:
-
-1. 长跑 loop 出问题后缺诊断和恢复手段。**loop 作为独立 session** 解决。
-2. loop 结束时的 PASS 是否可信决定几小时工作是否作废。同一个 LLM 既生成又自评的架构本身就不可信。**Evaluator 与 Budget 做成 service seam** 解决。
-3. 短任务和长任务需要的记忆策略相反,硬编一种模式会让另一类场景不可用。**RoundHandoff 做成 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 时能自己拆。
-
-术语约定:**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'; observed: number; maximum: number }
- | { 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。得到三种诊断与 replay 能力。
-
-- **从已记录轮次 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 明确提示。通过 `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 只是缓解不是根治。
-
-可信评估同时需要确定性的判断机制,以及与 threat model 匹配的隔离边界:shell exit code、静态分析或外部服务避免 LLM 自评;独立 worktree、只读 mount、容器或远程服务防止 worker 改写 evaluator 输入。具体检查和边界只有用户知道:不同项目 `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 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 {}
-```
-
-**为什么使用显式维度,而不是让用户传自由函数?** spec 强制用户在启动时声明评估什么、由什么执行判断,以及什么隔离边界保护它。自由函数看起来灵活,实际让 evaluator 强度隐性下沉——用户以为在做确定性隔离检查,实际写的是同工作区 LLM 判断。长跑场景下代价是几小时白跑。
-
-criteria shape、executor 与 isolation 是三个正交维度,不是可信度阶梯:rubric 可以由 shell、LLM 或外部服务检查,contract 也可以在同一工作区或容器中运行。`llm-judge` 仍是最弱的 executor,仅用于确实无硬信号的软目标(改稿、翻译质量)。文档明确标注「此 executor 不能挡定向对抗,长跑无人值守场景至少需要一个确定性 evaluator,并配合与 threat model 匹配的隔离边界」。
-
-Driver 强制四条结构约束,不下放给 Evaluator 实现。隔离强度仍是已配置提供方的显式属性,不是 driver 能凭空制造的保证。
-
-**防「同一个 agent 既生成又自评」**:
-
-1. **LLM 评估使用 fresh subagent**:LLM evaluator 每轮开 fresh subagent(用 `dsh-subagent` 的 `spawn`),不继承主循环 context
-
-**防 evaluator subagent 自身被 subverted**:
-
-2. **限制模型可见工具集**:LLM evaluator 的 model-facing tool set 被 driver 剥离到只保留读类工具,写类工具禁用(复用 `SubagentProvider` 的 `toolFilter`)。这会减少意外修改,但不是进程隔离:除非已配置隔离边界拦截,否则 shell、代码运行时或其他 capability 仍能写入
-
-**防 evaluator 报告本身欺骗 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 输入。
-
-**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」)。解析后的 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 让第三方插件先接。
-
-**局限**:`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
-
-每轮之间如何传递 context 是一个两难。完整保留之前对话(continue)连续性好,但对话会持续增长最终撞上 context 上限,且上一轮的错误信息会污染后续每一轮。每轮从零开始(fresh)避免污染,但每次需要重新理解上下文。跑 3 轮改稿与跑 80 轮 overnight 修 bug 需要的策略是相反的。Claude Code、Codex 都硬编一种模式,用户没法按任务类型切换。
-
-做成 service seam:
-
-```ts
-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, signal: AbortSignal): Promise
-}
-
-export {}
-```
-
-Phase 1 交付 fresh backend;Phase 2 在 provider continuation 存在后增加两个 continuation backend:
-
-| 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 明写长跑不适用。
-
-**为什么中间档只有我们能做?** `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-sdk loop` CLI 默认用 fresh,用户在遇到具体问题前不需要理解这些差别。
-
-### 可插拔的 GoalReflector
-
-用户在启动 loop 时给的目标不一定准确。可能基于错误假设(让 agent 用某个已经废弃的 API 实现功能),可能不够清晰(agent 在做的过程中才发现需要澄清),也可能被后来的信息证伪。现在的循环执行框架把 goal 当作启动时冻结的合约,agent 只能沿着原路蛮干,结果是在错的方向上耗尽预算。
-
-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 }
-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 实现前两种;`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
-- **周期性 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 没有通用高优先级 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 能力天然协同:后续 replay 可以从 concern 出现的轮次为新对话提供 seed,并替换 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 })` 通过 `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-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 一起分发:
-
-1. 不允许写 `TODO`、`FAKE`、`PLACEHOLDER` 占位符让 evaluator 表面通过
-2. 不允许写空的 `try/except` 或 `catch(_)` 让 evaluator 忽略错误
-
-这两条无法在 seam 层强制,只是 prompt 层 guidance,不能描述成硬约束。用户可以自定义 system prompt;需要强制这些规则的 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」段互链说明边界)。
-
-依赖尚未落地的一处:
-
-- ACP slash command 基础设施(`available_commands_update` 面)——见 用户面。基础设施落地前,slash command 触发面缺席,其它三个触发面照常工作
-
-拟议中的 [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`;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**: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 反向优化)。
-
-## 备选方案
-
-**扩 `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`。拒绝——criteria/executor/isolation 维度强制用户在启动时声明评估什么、由什么执行判断、由什么边界保护,防止不知不觉滑到更弱的配置。自由函数看起来灵活,实际让 evaluator 强度隐性下沉,长跑场景代价大。
-
-**引入独立记忆引擎(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**:更轻。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 按需引入更合理。
-
-**不做 `loop_split`,用户自己拆**:Phase 1 已经如此。Phase 2 加是因为长跑场景发现 agent 收到过大 goal 会直接跑而不是自己拆,需要显式工具引导。
-
-## 验收标准
-
-- `packages/loop/{loop,loop-driver,loop-tool}` 三包按 capability seam 建成;`dsh-loop` 只导 types 与 registry
-- `StopCondition` 判别覆盖所有分支(单元),`assertNever` 编译期收口
-- 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
-- `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
-
-## 风险
-
-**对话 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 的 `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 指令只是 guidance;用户在自定义 evaluator 中加入「静态检查禁止 TODO 与空 catch」才能获得可强制覆盖。这类问题不是 seam 层能根治的。
-
-**预算估算漂移与在途超限**。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 语义。
-
-**pre-release 允许直接演进**。`SESSION_FORMAT_VERSION=0`,`LoopRoundEvent` schema 可随时改;后端拒收旧格式而非兼容,与 AGENTS.md 顶部 pre-release stance 一致。
From e1c07cbabfba7bfe046edbbfda16748bd6597d3d Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 02:38:14 +0800
Subject: [PATCH 03/14] fix(workflow): include Ralph in clean builds
---
tsconfig.build.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/tsconfig.build.json b/tsconfig.build.json
index c32520a618..05ece689aa 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -94,6 +94,7 @@
{ "path": "./packages/workflow/workflow" },
{ "path": "./packages/workflow/workflow-workerthread" },
{ "path": "./packages/workflow/tool-workflow" },
+ { "path": "./packages/workflow/tool-ralph" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/cordis/tool-cordis" },
From 60eea35df5d4cdba6e21e10d9a44728e513f49e4 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 12:56:09 +0800
Subject: [PATCH 04/14] fix(ralph): harden execution boundaries
---
...-fresh-agent-ralph-workflow-tool.i18n.yaml | 4 +-
...6-07-19-fresh-agent-ralph-workflow-tool.md | 19 +-
...7-19-fresh-agent-ralph-workflow-tool.zh.md | 19 +-
docs/config-catalog.md | 2 +
docs/core-data-structures/workflow.md | 7 +-
docs/tool-catalog.md | 2 +-
.../system-prompt.expected.md | 4 +-
.../tool-schemas.expected.json | 2 +-
.../both-mode-turn/system-prompt.expected.md | 4 +-
.../both-mode-turn/tool-schemas.expected.json | 2 +-
.../code-mode-turn/system-prompt.expected.md | 4 +-
.../system-prompt.expected.md | 4 +-
.../model-switching/system-prompt.expected.md | 4 +-
.../tool-schemas.expected.json | 4 +-
.../system-prompt.expected.md | 4 +-
.../tool-schemas.expected.json | 4 +-
.../skill-load/system-prompt.expected.md | 2 +-
.../skill-load/tool-schemas.expected.json | 2 +-
.../text-turn/system-prompt.expected.md | 2 +-
.../text-turn/tool-schemas.expected.json | 2 +-
.../system-prompt.expected.md | 2 +-
.../tool-schemas.expected.json | 2 +-
.../workspace-edit/system-prompt.expected.md | 2 +-
.../workspace-edit/tool-schemas.expected.json | 2 +-
.../cordis/tool-cordis/src/api-catalog.ts | 2 +-
packages/workflow/tool-ralph/README.md | 14 +-
packages/workflow/tool-ralph/src/index.ts | 102 ++++++++--
.../tool-ralph/tests/integration.spec.ts | 179 +++++++++++++++++-
.../tool-ralph/tests/tool-ralph.spec.ts | 67 ++++++-
.../workflow/workflow-workerthread/README.md | 4 +-
.../workflow-workerthread/src/index.ts | 35 +++-
.../workflow-workerthread/src/runtime.ts | 2 +-
.../tests/session.spec.ts | 1 +
.../tests/source-worker.compat.spec.ts | 8 +
.../tests/workflow-workerthread.spec.ts | 88 ++++++++-
packages/workflow/workflow/README.md | 4 +-
packages/workflow/workflow/src/types.ts | 5 +
37 files changed, 535 insertions(+), 81 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml
index 797f9adef6..f0453085c5 100644
--- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.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-fresh-agent-ralph-workflow-tool.md: b00f433db62ed1a44a14baab056b2fd64a2695c4
-2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 4c5de168ad3a1c05b318078394f6eb1d253d4c74
+2026-07-19-fresh-agent-ralph-workflow-tool.md: 159ad7e39602d8b84ffafdb396ad1083f9c73009
+2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: bb6025952ada35526459101d53b3ea1fea622163
diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
index b00f433db6..159ad7e396 100644
--- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
+++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
@@ -18,29 +18,35 @@ The tool is foreground-only. The calling agent parents every child for cwd and l
### Per-run workflow provider route
-`WorkflowStartRequest` gains optional `subagentProvider`. The worker-thread engine resolves that explicit per-run value before falling back to its configured provider and uses the result for every `agent()` call in the run. The script cannot observe or replace this route. The ordinary `workflow` tool leaves the field unset and exposes no new model argument, so general workflow behavior and provider policy stay unchanged.
+`WorkflowStartRequest` gains optional `subagentProvider`. The worker-thread engine resolves that explicit per-run value before falling back to its configured provider, requires the selected normalized route to be registered before publishing the run, and uses it for every `agent()` call. The script cannot observe or replace this route. The ordinary `workflow` tool leaves the field unset and exposes no new model argument, so general workflow behavior and provider policy stay unchanged.
The Ralph plugin's `subagentProvider` defaults to `spawn`. Immediately before a call it requires the named provider to exist, support structured output, and report `inheritsParentContext: false`; a fork-like or incapable provider fails loudly before workflow start. Provider lookup remains call-time because effect-scoped provider registration can change under HMR.
+### Per-run workflow child ceiling
+
+`WorkflowStartRequest` also gains optional `maxTotalAgents`. The worker-thread engine requires a positive safe integer no greater than its configured deployment ceiling and installs the resolved value in that run's worker limits before publishing the run. Ralph passes its resolved `maxRounds` as this ceiling, so the fixed loop's round budget and the generic runaway-child backstop cannot disagree. The ordinary workflow tool leaves the field unset and keeps the engine default.
+
### Ralph rounds and handoff
The hierarchy is Ralph Run → Ralph Round → fresh child Turn → Step. One Ralph round creates exactly one child through the selected provider. Spawn gives that child a distinct session with no seed while preserving the parent's cwd, so the shared working tree is the durable authority and neither parent conversation nor prior child history enters the request.
The fixed prompt passes only the immutable objective, current round and cap, a workspace-as-authority instruction, and the previous structured report. A `RalphRoundReport` contains `status: continue | complete | blocked`, `summary`, `evidence`, `nextSteps`, and `blocker`. Strings must be normalized; `continue` requires next steps and no blocker, `complete` requires evidence with no next steps or blocker, and `blocked` requires a concrete blocker. The script validates semantics and serialized size before the report can become the next handoff; the consumer validates the materialized terminal value again across the workflow seam.
-`maxRounds` defaults to `256` and is also the deployment ceiling for a call override. `maxHandoffChars` defaults to `16384`. Both are positive safe-integer config values, and oversized reports fail rather than being silently truncated. After a `continue` report at the last permitted round, the fixed script returns `budget-limited`; `complete` and `blocked` return immediately with the final report and number of rounds started.
+`maxRounds` defaults to `256` and is also the deployment ceiling for a call override. `maxHandoffChars` and `maxResultChars` each default to `16384`. All are positive safe-integer config values. Oversized handoffs fail rather than being silently truncated; `maxResultChars` separately bounds the complete successful parent-facing text, including its envelope and truncation marker, without changing cross-round state. After a `continue` report at the last permitted round, the fixed script returns `budget-limited`; `complete` and `blocked` return immediately with the final report and number of rounds started.
+
+The workflow language maps a normally settled but unsuccessful child to `null`. The fixed script detects that value before report validation and returns `round-failed` with the failed round plus the last successful handoff when one exists; the tool turns it into an error instead of misclassifying it as a malformed report or budget exhaustion. Ralph adds no retry policy. Fatal provider-start, transport, worker, and workflow errors remain generic workflow failures because the workflow seam does not carry a recoverable child report on those paths.
### Model and UI surface
The model may supply only `objective` and optional `maxRounds`; provider selection, report schema, handoff cap, and script are deployment-owned. A fixed prompt section says to use `ralph` only when the direct human explicitly asks for Ralph or fresh-agent iteration, and distinguishes it from same-session goals, bounded delegation, and general fan-out workflows. This is guidance rather than a new goal UX state machine.
-ACP and terminal presentation use a generic `ralph` card whose raw input is the objective. The parent transcript retains the original tool call and one bounded terminal report, not intermediate child messages. Shipped headless, REPL/TUI, and ACP compositions load the plugin beside the existing workflow engine; JSON-RPC remains unchanged because its default composition does not expose workflows.
+ACP and terminal presentation use a generic `ralph` card whose raw input is the objective. Successful completion and blocker envelopes say that a worker reported the outcome rather than presenting it as independent certification. The parent transcript retains the original tool call and one bounded successful terminal report or an error, not intermediate child messages. Shipped headless, REPL/TUI, and ACP compositions load the plugin beside the existing workflow engine; JSON-RPC remains unchanged because its default composition does not expose workflows.
## Testing
-Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing, all three terminal outcomes, malformed and oversized boundary values, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove that a per-run provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node.
+Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing and child ceiling, all successful terminal outcomes, ordinary child-failure envelopes, malformed and oversized boundary values, exact successful-result truncation, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove synchronous provider-route validation, per-run child ceilings below the deployment ceiling, and that a provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node.
-A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, terminal completion, and disposal of both children. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface.
+A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface.
## Alternatives considered
@@ -56,7 +62,7 @@ A keyless real-stack integration drives the fixed script through the actual work
- Goal rounds and Ralph rounds stay different concepts: the former is one same-session continuation turn, while the latter is one fresh child inside a foreground workflow.
- The workspace becomes authoritative cross-round memory, so workers must inspect and verify it rather than trusting a narrative handoff.
- A generous round ceiling permits substantial autonomous work, while deployment config still bounds child count and every handoff remains size-limited.
-- Provider routing becomes an explicit workflow start concern without expanding the script or ordinary workflow tool surface.
+- Provider routing and a lowerable per-run child ceiling become explicit workflow start concerns without expanding the script or ordinary workflow tool surface.
## Known limitations and deferred work
@@ -64,4 +70,5 @@ A keyless real-stack integration drives the fixed script through the actual work
- Runs are foreground and process-local. Background collection, persistence/resume, scheduling, and restart recovery are absent.
- Round count is the only aggregate budget. Token, currency, elapsed-time, and provider-usage budgets remain separate future policy.
- One round creates one child. Within-round fan-out, evaluator/worker role separation, dynamic provider or model selection, and cross-run journals are deferred.
+- An ordinary child failure ends the run without retry, while preserving the failed round and last successful handoff. Fatal workflow infrastructure failures can end before the fixed script returns that state; adding retry or richer failure transport requires separate policy and seam design.
- Prompt guidance asks models not to invoke Ralph recursively; a structural child-tool restriction would require a separately designed workflow child-policy surface.
diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
index 4c5de168ad..bb6025952a 100644
--- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
@@ -18,29 +18,35 @@ Status: implemented
### 每次运行的工作流 provider 路由
-`WorkflowStartRequest` 新增可选的 `subagentProvider`。工作线程引擎先解析这个显式的每次运行值,再回退到引擎配置的 provider,并把结果用于该运行中的每次 `agent()` 调用。脚本无法观察或替换此路由。普通 `workflow` 工具不设置该字段,也不暴露新的模型参数,因此通用工作流行为和 provider 策略保持不变。
+`WorkflowStartRequest` 新增可选的 `subagentProvider`。工作线程引擎先解析这个显式的每次运行值,再回退到引擎配置的 provider;在发布运行前,它要求所选规范化路由已注册,并把结果用于每次 `agent()` 调用。脚本无法观察或替换此路由。普通 `workflow` 工具不设置该字段,也不暴露新的模型参数,因此通用工作流行为和 provider 策略保持不变。
Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要求具名 provider 已存在、支持结构化输出且报告 `inheritsParentContext: false`;类似 fork 或能力不足的 provider 会在工作流启动前响亮失败。provider 查找保留在调用期,因为效果作用域内的 provider 注册可能随 HMR 改变。
+### 每次运行的工作流子 agent 上限
+
+`WorkflowStartRequest` 还新增可选的 `maxTotalAgents`。工作线程引擎要求它是正安全整数且不高于已配置的部署上限,并在发布运行前把解析值装入该运行的工作线程限制。Ralph 把解析后的 `maxRounds` 作为此上限,因此固定循环的轮次预算不会与通用失控子 agent 后备限制冲突。普通工作流工具不设置该字段并保留引擎默认值。
+
### Ralph 轮次与交接
层级为 Ralph 运行 → Ralph 轮次 → 全新子 agent 回合 → 步骤。每个 Ralph 轮次恰好通过所选 provider 创建一个子 agent。Spawn 给该子 agent 一个没有种子的独立会话,同时保留父级 cwd,因此共享工作树是持久权威,父对话和先前子 agent 历史都不会进入请求。
固定提示只传递不可变目标、当前轮次与上限、以工作区为权威的指令,以及上一份结构化报告。`RalphRoundReport` 包含 `status: continue | complete | blocked`、`summary`、`evidence`、`nextSteps` 和 `blocker`。字符串必须规范化;`continue` 要求存在下一步且没有阻塞项,`complete` 要求存在证据且没有下一步或阻塞项,`blocked` 要求具体阻塞项。报告成为下一次交接前,脚本会验证语义与序列化大小;消费者还会跨工作流接缝再次验证实体化的终止值。
-`maxRounds` 默认为 `256`,同时也是调用覆盖值的部署上限。`maxHandoffChars` 默认为 `16384`。两者都是正安全整数配置值;过大的报告会失败,而不会被静默截断。最后一个允许轮次报告 `continue` 后,固定脚本返回 `budget-limited`;`complete` 和 `blocked` 会立即返回最终报告与已启动轮次数。
+`maxRounds` 默认为 `256`,同时也是调用覆盖值的部署上限。`maxHandoffChars` 和 `maxResultChars` 均默认为 `16384`。三者都是正安全整数配置值。过大的交接会失败,而不会被静默截断;`maxResultChars` 单独限制面向父级的完整成功文本,包括外层文本和截断标记,并且不会改变跨轮状态。最后一个允许轮次报告 `continue` 后,固定脚本返回 `budget-limited`;`complete` 和 `blocked` 会立即返回最终报告与已启动轮次数。
+
+工作流语言会把正常结束但未成功的子 agent 映射为 `null`。固定脚本会在报告验证前检测该值,并返回 `round-failed`,其中包含失败轮次,以及存在时的上一份成功交接;工具会把它转成错误,而不会误判为畸形报告或预算耗尽。Ralph 不添加重试策略。致命的 provider 启动、传输、工作线程和工作流错误仍是通用工作流失败,因为这些路径上的工作流接缝不携带可恢复的子报告。
### 模型与 UI 表面
模型只能提供 `objective` 和可选的 `maxRounds`;provider 选择、报告 schema、交接上限和脚本都由部署拥有。固定提示区段说明,只有直接人类明确要求 Ralph 或全新 agent 迭代时才使用 `ralph`,并将其与同会话目标、有界委派和通用扇出工作流区分开。这是指导,而不是新的目标 UX 状态机。
-ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入。父转录只保留原始工具调用和一份有界终止报告,不包含中间子 agent 消息。发布的无头、REPL/TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。
+ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入。成功完成与阻塞的外层文本会说明结果由工作者报告,而不会把它呈现为独立认证。父转录只保留原始工具调用,以及一份有界成功终止报告或一个错误,不包含中间子 agent 消息。发布的无头、REPL/TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。
## 测试
-单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由、三种终止结果、畸形及过大边界值、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明,每次运行的 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。
+单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明 provider 路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。
-一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、终止完成以及两个子 agent 都被处置。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。
+一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、只产生一个阶段事件、终止完成以及两个子 agent 都被处置。同一真实栈还覆盖阻塞与轮次上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后达到子 agent 静止状态。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。
## 考虑过的替代方案
@@ -56,7 +62,7 @@ ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入
- 目标轮次与 Ralph 轮次保持不同概念:前者是一次同会话续行回合,后者是前台工作流中的一个全新子 agent。
- 工作区成为权威跨轮记忆,因此工作者必须检查和验证工作区,而不能信任叙事性交接。
- 宽裕的轮次上限允许大量自治工作,而部署配置仍会限制子 agent 数量,并且每次交接始终受大小约束。
-- provider 路由成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。
+- provider 路由与可降低的每次运行子 agent 上限成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。
## 已知限制与推迟工作
@@ -64,4 +70,5 @@ ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入
- 运行位于前台且只存在于进程内。后台收集、持久化/恢复、调度和重启恢复均不存在。
- 轮次数是唯一聚合预算。token、货币、耗时和 provider 用量预算仍属于未来的独立策略。
- 每轮创建一个子 agent。轮内扇出、evaluator/工作者角色分离、动态 provider 或模型选择,以及跨运行日志均被推迟。
+- 普通子 agent 失败会结束运行且不重试,同时保留失败轮次与上一份成功交接。致命工作流基础设施错误可能在固定脚本返回该状态前结束;增加重试或更丰富的失败传输需要独立的策略与接缝设计。
- 提示指导模型不要递归调用 Ralph;结构化的子 agent 工具限制需要另行设计工作流子策略表面。
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 58679ef712..3d83bc82e1 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1146,6 +1146,8 @@ export interface Config {
maxRounds?: number
/** Maximum serialized characters in one structured handoff (default 16384). */
maxHandoffChars?: number
+ /** Maximum characters in a successful parent-facing terminal text (default 16384). */
+ maxResultChars?: number
}
```
diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md
index cf322251f0..8d8e47fc79 100644
--- a/docs/core-data-structures/workflow.md
+++ b/docs/core-data-structures/workflow.md
@@ -8,7 +8,7 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work
## The start request
-What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` for the run, but the script cannot observe or replace it. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)).
+What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` and lower `maxTotalAgents` for the run, but the script cannot observe or replace either policy. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)).
```ts type-equiv
/**
@@ -32,6 +32,11 @@ interface WorkflowStartRequest {
* provider.
*/
subagentProvider?: string
+ /**
+ * Optional per-run total-child ceiling. Implementations reject values above
+ * their deployment ceiling before publishing the run.
+ */
+ maxTotalAgents?: number
/** The agent on whose behalf the run executes (parent of every child). */
parent: Agent
/** Cancels the run when aborted (the tool's `exec.signal`). */
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 29a026008d..45dcd5821c 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -487,7 +487,7 @@ create, edit, pause, and resume require direct-human root authority; complete an
### `ralph`
-Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.
+Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.
```json
{
diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md
index d6a5d0fa11..b3950c7e88 100644
--- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md
@@ -16,7 +16,7 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
## Writing code for run_code
@@ -74,7 +74,7 @@ declare const tools: {
}): Promise;
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */
get_goal(args: Record): Promise;
- /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */
+ /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
ralph(args: {
/** The immutable completion objective for every fresh Ralph round. */
objective: string;
diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json
index aa4f949726..9881086bcb 100644
--- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json
@@ -132,7 +132,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md
index bbed43d13b..da61a4bf95 100644
--- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md
@@ -16,7 +16,7 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
## Writing code for run_code
@@ -57,7 +57,7 @@ declare const tools: {
}): Promise;
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */
get_goal(args: Record): Promise;
- /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */
+ /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
ralph(args: {
/** The immutable completion objective for every fresh Ralph round. */
objective: string;
diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json
index 44f111c9d4..d8a06f0797 100644
--- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json
@@ -75,7 +75,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md
index bbed43d13b..da61a4bf95 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md
@@ -16,7 +16,7 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
## Writing code for run_code
@@ -57,7 +57,7 @@ declare const tools: {
}): Promise;
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */
get_goal(args: Record): Promise;
- /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */
+ /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
ralph(args: {
/** The immutable completion objective for every fresh Ralph round. */
objective: string;
diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md
index 860803d699..6e6abbc469 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md
@@ -22,7 +22,7 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
## Writing code for run_code
@@ -74,7 +74,7 @@ declare const tools: {
}): Promise;
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */
get_goal(args: Record): Promise;
- /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */
+ /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
ralph(args: {
/** The immutable completion objective for every fresh Ralph round. */
objective: string;
diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md
index 751040c92e..04e048966b 100644
--- a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md
@@ -16,7 +16,7 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
@@ -38,4 +38,4 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json
index 2680ce4d84..cfe5cc4712 100644
--- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json
@@ -75,7 +75,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
@@ -439,7 +439,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md
index 472db36968..d8e7c97143 100644
--- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md
@@ -15,7 +15,7 @@ Use goal tools for one long-running completion objective in the current session.
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
@@ -37,4 +37,4 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json
index 2680ce4d84..cfe5cc4712 100644
--- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json
@@ -75,7 +75,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
@@ -439,7 +439,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md
index 6bc89dccd5..433ef34d3c 100644
--- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md
@@ -16,4 +16,4 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json
index 12e0c50907..7684660b24 100644
--- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json
@@ -75,7 +75,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md
index 6bc89dccd5..433ef34d3c 100644
--- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md
@@ -16,4 +16,4 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json
index 12e0c50907..7684660b24 100644
--- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json
@@ -75,7 +75,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md
index cc8fc47873..80c1783dd0 100644
--- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md
@@ -22,4 +22,4 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
index f89f296f06..f4bcfeda2d 100644
--- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
@@ -105,7 +105,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md
index dc934714b9..b44197f884 100644
--- a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md
+++ b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md
@@ -22,4 +22,4 @@ Approval prompts are disabled in this session: actions that require approval are
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json
index f89f296f06..f4bcfeda2d 100644
--- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json
+++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json
@@ -105,7 +105,7 @@
},
{
"name": "ralph",
- "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.",
+ "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index f4a96176ee..fedd354d3c 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -1791,7 +1791,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'WorkflowStartRequest',
- declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n subagentProvider?: string;\n parent: Agent;\n signal?: AbortSignal;\n}',
+ declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n subagentProvider?: string;\n maxTotalAgents?: number;\n parent: Agent;\n signal?: AbortSignal;\n}',
},
{
name: 'WorkflowStopReason',
diff --git a/packages/workflow/tool-ralph/README.md b/packages/workflow/tool-ralph/README.md
index b264f14a16..230e2db643 100644
--- a/packages/workflow/tool-ralph/README.md
+++ b/packages/workflow/tool-ralph/README.md
@@ -4,11 +4,13 @@ The model-facing `ralph` tool runs a fixed foreground workflow that gives one im
## Contract
-`ralph({ objective, maxRounds? })` waits for the entire run. The deployment config's `maxRounds` is both the default and a ceiling on a call override. Every Ralph round starts one child through `subagentProvider`; that provider must exist, support structured output, and report `inheritsParentContext: false`. The configured provider is carried as `WorkflowStartRequest.subagentProvider`, so the fixed script cannot inspect or change routing and the ordinary model-written `workflow` tool gains no provider selector.
+`ralph({ objective, maxRounds? })` waits for the entire run. The deployment config's `maxRounds` is both the default and a ceiling on a call override. Every Ralph round starts one child through `subagentProvider`; that provider must exist, support structured output, and report `inheritsParentContext: false`. The configured provider is carried as `WorkflowStartRequest.subagentProvider`, so the fixed script cannot inspect or change routing and the ordinary model-written `workflow` tool gains no provider selector. The resolved round cap is also carried as `WorkflowStartRequest.maxTotalAgents`, coordinating the fixed loop with the engine's total-child backstop; the engine rejects a Ralph cap above its deployment ceiling before publishing a run.
Each child receives only the immutable objective, its current Ralph round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded. Reports have `status: continue | complete | blocked`, a non-empty summary, evidence, next steps, and blocker text. Status-specific semantics and the serialized `maxHandoffChars` ceiling are validated inside the fixed workflow and again at the consumer boundary. Invalid, missing, or oversized reports fail the workflow instead of being truncated or mistaken for cap exhaustion.
-The terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. Child self-declaration determines completion in this cut. A workflow failure or cancellation is an error result; partial output is never success.
+The successful terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. Completion and blocker labels explicitly say that a worker reported the outcome; they are not independent certification. `maxResultChars` bounds the complete successful text including its envelope and truncation marker, without altering the validated report used as a cross-round handoff.
+
+An ordinary child failure produces an error naming the failed round and retaining the last successful handoff when one exists. Ralph does not retry that round. Fatal provider-start, transport, worker, or workflow failures remain workflow errors and may settle before the fixed script can return a handoff. Cancellation is also an error; partial output is never success.
## Lifecycle and cancellation
@@ -25,6 +27,7 @@ The pending call is a `generic` card titled `ralph`; the immutable objective is
| `subagentProvider` | `spawn` | Fresh structured-output provider used for every round. |
| `maxRounds` | `256` | Default and deployment ceiling for one Ralph run. |
| `maxHandoffChars` | `16384` | Maximum serialized characters in one round report. |
+| `maxResultChars` | `16384` | Maximum characters in the complete successful parent result. |
All config values are normalized and validated when the plugin applies, including direct application outside Loader schema normalization. Provider capabilities are resolved immediately before each call because provider registration can change under plugin lifecycle and HMR.
@@ -39,7 +42,7 @@ Every parent request in this plugin's registration scope receives the fixed rout
##### Ralph guidance
```markdown
-Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
```
#### Token effect
@@ -68,11 +71,11 @@ Prefix-stable while the definition and visibility are unchanged.
#### What the model sees
-Each child sees the standalone fixed round prompt plus the structured-output capture contract. The parent sees only the original call and one terminal result containing status, round count, and pretty-printed final report; intermediate child messages and reports do not enter the parent conversation.
+Each child sees the standalone fixed round prompt plus the structured-output capture contract. The parent sees only the original call and one terminal result containing a worker-reported status, round count, and pretty-printed final report; intermediate child messages and reports do not enter the parent conversation. A failed ordinary child instead yields an error with its round number and, after round one, the last successful handoff.
#### Token effect
-Every round pays for a fresh child context. The parent result is bounded indirectly by `maxHandoffChars`; child work remains outside the parent context.
+Every round pays for a fresh child context. `maxHandoffChars` bounds cross-round state and `maxResultChars` independently bounds the complete successful parent text; child work remains outside the parent context.
#### KV Cache effect
@@ -84,4 +87,5 @@ Each fresh child has an independent request cache. The parent result appends aft
- **Foreground only** — there is no task id, background collection, process-resume checkpoint, scheduler, or wall-clock start policy.
- **The workspace is the only cross-round long-term memory** — one bounded report is the explicit handoff, and uncommitted conversational reasoning disappears with each child.
- **One round is one fresh child** — there is no within-round fan-out, model/provider switching, fork context, or model-call-selected provider.
+- **Ordinary child failure is terminal for the run** — the fixed script reports the failed round and last successful handoff but does not retry; fatal workflow infrastructure failures can end before that state is returned.
- **Only round count bounds aggregate effort** — token, price, and elapsed-time budgets are deferred.
diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts
index 83da19b119..0f23ae062a 100644
--- a/packages/workflow/tool-ralph/src/index.ts
+++ b/packages/workflow/tool-ralph/src/index.ts
@@ -26,6 +26,8 @@ export interface Config {
maxRounds?: number
/** Maximum serialized characters in one structured handoff (default 16384). */
maxHandoffChars?: number
+ /** Maximum characters in a successful parent-facing terminal text (default 16384). */
+ maxResultChars?: number
}
/** Schemastery configuration for the Ralph tool. */
@@ -33,12 +35,14 @@ export const Config: z = z.object({
subagentProvider: z.string().default('spawn'),
maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256),
maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),
+ maxResultChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),
})
interface ResolvedConfig {
readonly subagentProvider: string
readonly maxRounds: number
readonly maxHandoffChars: number
+ readonly maxResultChars: number
}
type RalphRoundStatus = 'continue' | 'complete' | 'blocked'
@@ -59,6 +63,14 @@ interface RalphRunResult {
readonly report: RalphRoundReport
}
+interface RalphRoundFailure {
+ readonly status: 'round-failed'
+ readonly roundsStarted: number
+ readonly lastReport?: RalphRoundReport
+}
+
+type RalphTerminalResult = RalphRunResult | RalphRoundFailure
+
interface RalphCallArgs {
objective: string
maxRounds?: number
@@ -136,8 +148,8 @@ function validateReport(report) {
}
let previous
+phase('Fresh-agent rounds')
for (let round = 1; round <= args.maxRounds; round += 1) {
- phase('Fresh-agent rounds')
const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous)
const prompt = [
'You are one fresh worker in a foreground Ralph loop. You receive no parent conversation and no prior child session. Do not call the ralph tool: this round already is its worker.',
@@ -147,11 +159,15 @@ for (let round = 1; round <= args.maxRounds; round += 1) {
'Previous structured handoff:\n' + prior,
'Return one report with exact normalized strings. Use status continue with at least one nextSteps entry while useful work remains; complete only with concrete evidence and no nextSteps; blocked only when no meaningful progress is possible without human input or an external-state change. blocker must be empty unless blocked.',
].join('\n\n')
- const report = validateReport(await agent(prompt, {
+ const rawReport = await agent(prompt, {
label: 'Ralph round ' + round,
phase: 'Fresh-agent rounds',
schema: reportSchema,
- }))
+ })
+ if (rawReport === null) {
+ return { status: 'round-failed', roundsStarted: round, lastReport: previous ?? null }
+ }
+ const report = validateReport(rawReport)
if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report }
if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report }
previous = report
@@ -162,8 +178,8 @@ return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previo
const DESCRIPTION = 'Run a foreground fresh-agent Ralph loop toward one immutable objective. '
+ 'Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round '
+ 'opens a new child with no parent conversation or prior child session; the shared workspace is '
- + 'long-term memory, and only a bounded structured report crosses rounds. The call returns on '
- + 'completion, a concrete blocker, or the round limit. Ordinary long-running same-session work '
+ + 'long-term memory, and only a bounded structured report crosses rounds. The call returns when '
+ + 'a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work '
+ 'belongs to goal tools.'
/** Validate defaults even when a caller invokes apply() without Loader normalization. */
@@ -171,6 +187,7 @@ function resolveConfig(config: Config): ResolvedConfig {
const subagentProvider = config.subagentProvider ?? 'spawn'
const maxRounds = config.maxRounds ?? 256
const maxHandoffChars = config.maxHandoffChars ?? 16_384
+ const maxResultChars = config.maxResultChars ?? 16_384
if (subagentProvider.length === 0 || subagentProvider !== subagentProvider.trim()) {
throw new TypeError('subagentProvider must be a non-empty normalized string')
}
@@ -180,7 +197,10 @@ function resolveConfig(config: Config): ResolvedConfig {
if (!Number.isSafeInteger(maxHandoffChars) || maxHandoffChars < 1) {
throw new TypeError('maxHandoffChars must be a positive safe integer')
}
- return { subagentProvider, maxRounds, maxHandoffChars }
+ if (!Number.isSafeInteger(maxResultChars) || maxResultChars < 1) {
+ throw new TypeError('maxResultChars must be a positive safe integer')
+ }
+ return { subagentProvider, maxRounds, maxHandoffChars, maxResultChars }
}
/** Resolve one model-selected cap against the deployment ceiling. */
@@ -259,9 +279,8 @@ function readReport(value: unknown, expectedStatus: RalphRoundStatus, maxChars:
}
/** Defensively decode the fixed script's terminal value. */
-function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphRunResult {
+function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphTerminalResult {
if (!isRecord(value)
- || Object.keys(value).sort().join(',') !== 'report,roundsStarted,status'
|| typeof value['roundsStarted'] !== 'number'
|| !Number.isSafeInteger(value['roundsStarted'])
|| value['roundsStarted'] < 1
@@ -271,14 +290,42 @@ function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: numbe
const roundsStarted = value['roundsStarted']
switch (value['status']) {
case 'complete':
+ if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {
+ throw new Error('Ralph workflow returned a malformed terminal result')
+ }
return { status: 'complete', roundsStarted, report: readReport(value['report'], 'complete', maxHandoffChars) }
case 'blocked':
+ if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {
+ throw new Error('Ralph workflow returned a malformed terminal result')
+ }
return { status: 'blocked', roundsStarted, report: readReport(value['report'], 'blocked', maxHandoffChars) }
case 'budget-limited':
+ if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {
+ throw new Error('Ralph workflow returned a malformed terminal result')
+ }
if (roundsStarted !== maxRounds) {
throw new Error('Ralph workflow returned budget-limited before the round limit')
}
return { status: 'budget-limited', roundsStarted, report: readReport(value['report'], 'continue', maxHandoffChars) }
+ case 'round-failed': {
+ if (Object.keys(value).sort().join(',') !== 'lastReport,roundsStarted,status') {
+ throw new Error('Ralph workflow returned a malformed terminal result')
+ }
+ if (roundsStarted === 1) {
+ if (value['lastReport'] !== null) {
+ throw new Error('Ralph workflow returned an invalid first-round failure')
+ }
+ return { status: 'round-failed', roundsStarted }
+ }
+ if (value['lastReport'] === null) {
+ throw new Error('Ralph workflow returned a round failure without its last handoff')
+ }
+ return {
+ status: 'round-failed',
+ roundsStarted,
+ lastReport: readReport(value['lastReport'], 'continue', maxHandoffChars),
+ }
+ }
default:
throw new Error('Ralph workflow returned an unknown terminal status')
}
@@ -300,17 +347,40 @@ function stopReasonError(result: WorkflowResult): string | undefined {
}
}
-/** Render the fixed terminal envelope without dropping the bounded report. */
-function renderResult(result: RalphRunResult): string {
+const TRUNCATION_NOTICE = '\n… [truncated]'
+
+/** Bound complete parent-facing text, including its envelope and truncation marker. */
+function boundResult(text: string, maxChars: number): string {
+ if (text.length <= maxChars) return text
+ if (maxChars <= TRUNCATION_NOTICE.length) return TRUNCATION_NOTICE.slice(0, maxChars)
+ return `${text.slice(0, maxChars - TRUNCATION_NOTICE.length)}${TRUNCATION_NOTICE}`
+}
+
+/** Render the fixed terminal envelope without presenting self-report as certification. */
+function renderResult(result: RalphRunResult, maxChars: number): string {
const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? '' : 's'}`
+ let text: string
switch (result.status) {
case 'complete':
- return `Ralph completed after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
+ text = `Ralph worker reported completion after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
+ break
case 'blocked':
- return `Ralph blocked after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
+ text = `Ralph worker reported a blocker after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
+ break
case 'budget-limited':
- return `Ralph reached its ${rounds} limit with work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
+ text = `Ralph reached its ${rounds} limit; the worker reported work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
+ break
}
+ return boundResult(text, maxChars)
+}
+
+/** Render an ordinary child failure with the most recent durable handoff. */
+function renderRoundFailure(result: RalphRoundFailure, maxChars: number): string {
+ const header = `Ralph round ${result.roundsStarted} child failed before producing a structured report.`
+ const text = result.lastReport === undefined
+ ? `${header}\nNo previous handoff was available.`
+ : `${header}\nLast successful handoff:\n${JSON.stringify(result.lastReport, null, 2)}`
+ return boundResult(text, maxChars)
}
function presentCall(args: RalphCallArgs): ToolCallView {
@@ -329,7 +399,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.systemPrompt.section({
name: 'tool:ralph',
order: 116,
- text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.',
+ text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.',
})
ctx.tools.register(defineTool({
name: 'ralph',
@@ -360,6 +430,7 @@ export function apply(ctx: Context, config: Config): void {
meta: RALPH_META,
args: { objective, maxRounds, maxHandoffChars: resolved.maxHandoffChars },
subagentProvider: resolved.subagentProvider,
+ maxTotalAgents: maxRounds,
parent,
...exec.signal === undefined ? {} : { signal: exec.signal },
})
@@ -372,7 +443,8 @@ export function apply(ctx: Context, config: Config): void {
const error = stopReasonError(settled)
if (error !== undefined) throw new Error(error)
const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars)
- return [{ type: 'text', text: renderResult(value) }]
+ if (value.status === 'round-failed') throw new Error(renderRoundFailure(value, resolved.maxResultChars))
+ return [{ type: 'text', text: renderResult(value, resolved.maxResultChars) }]
} finally {
exec.signal?.removeEventListener('abort', onAbort)
await run.dispose()
diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts
index f984e67d22..836f023b41 100644
--- a/packages/workflow/tool-ralph/tests/integration.spec.ts
+++ b/packages/workflow/tool-ralph/tests/integration.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -10,9 +10,31 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
-import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as toolRalph from '../src/index.ts'
+type MockScript = ConstructorParameters[0]
+
+/** Mount the shipped Ralph execution stack around one keyless model script. */
+async function mountRalph(script: MockScript, config: toolRalph.Config) {
+ const ctx = new Context()
+ const adapter = new MockAdapter(script)
+ await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(Invariants)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SubagentService)
+ await ctx.plugin(spawn, { providerName: 'spawn' })
+ await ctx.plugin(WorkerWorkflowEngine, {})
+ await ctx.plugin(toolRalph, config)
+ ctx.llm.registerAdapter(['mock'], adapter)
+ const parentHandle = await ctx.agents.create({
+ sessionId: SessionId('ralph-parent'),
+ meta: { cwd: '/tmp/ralph-shared-workspace' },
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
+ return { ctx, adapter, parentHandle, parent: parentHandle.agent }
+}
+
describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', async () => {
const firstReport = {
@@ -54,6 +76,8 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
await parent.whenIdle()
const children: Agent[] = []
+ const phases: string[] = []
+ ctx.on('workflow/phase', (_run, title) => { phases.push(title) })
ctx.on('workflow/agent-start', (_run, child) => {
const agent = ctx.agents.get(child.childId)
expect(agent).toBeDefined()
@@ -67,7 +91,9 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
})
expect(result.isError).toBe(false)
- expect((result.content[0] as { text: string }).text).toContain('Ralph completed after 2 rounds.')
+ expect((result.content[0] as { text: string }).text)
+ .toContain('Ralph worker reported completion after 2 rounds.')
+ expect(phases).toEqual(['Fresh-agent rounds'])
expect(children).toHaveLength(2)
expect(new Set(children.map(child => child.id)).size).toBe(2)
for (const child of children) {
@@ -89,4 +115,151 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
await parentHandle.dispose()
})
+
+ it('reports the failed round and last good handoff when a child fails', async () => {
+ const firstReport = {
+ status: 'continue',
+ summary: 'ROUND_ONE_HANDOFF',
+ evidence: ['Created migration-a.ts.'],
+ nextSteps: ['Finish migration-b.ts.'],
+ blocker: '',
+ }
+ const { ctx, parent, parentHandle } = await mountRalph([
+ toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport),
+ maxTokensResponse('unfinished child output'),
+ ], { maxRounds: 2 })
+ const children: Agent[] = []
+ ctx.on('workflow/agent-start', (_run, child) => {
+ const agent = ctx.agents.get(child.childId)
+ if (agent !== undefined) children.push(agent)
+ })
+
+ const result = await ctx.tools.execute({
+ callId: CallId('ralph-child-failure'),
+ name: 'ralph',
+ arguments: { objective: 'Complete both migration slices.', maxRounds: 2 },
+ agent: parent,
+ })
+
+ expect(result.isError).toBe(true)
+ const text = (result.content[0] as { text: string }).text
+ expect(text).toContain('Ralph round 2 child failed before producing a structured report.')
+ expect(text).toContain('Last successful handoff:')
+ expect(text).toContain('ROUND_ONE_HANDOFF')
+ expect(children).toHaveLength(2)
+ for (const child of children) expect(ctx.agents.get(child.id)).toBeUndefined()
+ await parentHandle.dispose()
+ })
+
+ it.each([
+ {
+ name: 'blocked',
+ report: {
+ status: 'blocked',
+ summary: 'External authorization is required.',
+ evidence: ['The local implementation is ready.'],
+ nextSteps: ['Continue after authorization.'],
+ blocker: 'The required external authorization is unavailable.',
+ },
+ config: { maxRounds: 2 },
+ expectedError: false,
+ expectedText: 'Ralph worker reported a blocker after 1 round.',
+ },
+ {
+ name: 'budget-limited',
+ report: {
+ status: 'continue',
+ summary: 'One slice is complete.',
+ evidence: ['The first focused test passes.'],
+ nextSteps: ['Implement the remaining slice.'],
+ blocker: '',
+ },
+ config: { maxRounds: 1 },
+ expectedError: false,
+ expectedText: 'Ralph reached its 1 round limit; the worker reported work remaining.',
+ },
+ {
+ name: 'unnormalized report',
+ report: {
+ status: 'continue',
+ summary: ' padded summary ',
+ evidence: ['A focused test passes.'],
+ nextSteps: ['Continue implementation.'],
+ blocker: '',
+ },
+ config: { maxRounds: 1 },
+ expectedError: true,
+ expectedText: 'summary must be non-empty and normalized',
+ },
+ {
+ name: 'invalid continuing report',
+ report: {
+ status: 'continue',
+ summary: 'Work remains.',
+ evidence: ['A focused test passes.'],
+ nextSteps: [],
+ blocker: '',
+ },
+ config: { maxRounds: 1 },
+ expectedError: true,
+ expectedText: 'a continuing Ralph report needs nextSteps and an empty blocker',
+ },
+ {
+ name: 'oversized report',
+ report: {
+ status: 'continue',
+ summary: 'x'.repeat(300),
+ evidence: ['A focused test passes.'],
+ nextSteps: ['Continue implementation.'],
+ blocker: '',
+ },
+ config: { maxRounds: 1, maxHandoffChars: 100 },
+ expectedError: true,
+ expectedText: 'Ralph round report exceeds maxHandoffChars',
+ },
+ ])('enforces the fixed script for $name', async ({ report, config, expectedError, expectedText }) => {
+ const { ctx, parent, parentHandle } = await mountRalph([
+ toolCallResponse('round-report', STRUCTURED_OUTPUT_TOOL, report),
+ ], config)
+
+ const result = await ctx.tools.execute({
+ callId: CallId('ralph-script-enforcement'),
+ name: 'ralph',
+ arguments: { objective: 'Complete the scoped work.', maxRounds: config.maxRounds },
+ agent: parent,
+ })
+
+ expect(result.isError).toBe(expectedError)
+ expect((result.content[0] as { text: string }).text).toContain(expectedText)
+ await parentHandle.dispose()
+ })
+
+ it('cancels the real worker and fresh child to quiescence', async () => {
+ const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 })
+ const children: Agent[] = []
+ const outcomes: string[] = []
+ ctx.on('workflow/agent-start', (_run, child) => {
+ const agent = ctx.agents.get(child.childId)
+ if (agent !== undefined) children.push(agent)
+ })
+ ctx.on('workflow/agent-end', (_run, child) => { outcomes.push(child.outcome) })
+ const controller = new AbortController()
+ const pending = ctx.tools.execute({
+ callId: CallId('ralph-real-cancel'),
+ name: 'ralph',
+ arguments: { objective: 'Keep working until cancelled.', maxRounds: 2 },
+ agent: parent,
+ signal: controller.signal,
+ })
+ await vi.waitFor(() => { expect(children).toHaveLength(1) })
+
+ controller.abort()
+ const result = await pending
+
+ expect(result.isError).toBe(true)
+ expect((result.content[0] as { text: string }).text).toContain('Ralph workflow was cancelled')
+ expect(outcomes).toEqual(['cancelled'])
+ expect(ctx.agents.get(children[0]!.id)).toBeUndefined()
+ await parentHandle.dispose()
+ })
})
diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts
index b7a70fd7d7..119da3def3 100644
--- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts
+++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts
@@ -82,6 +82,7 @@ async function setup(options?: SetupOptions) {
if (options?.config?.subagentProvider !== undefined) config.subagentProvider = options.config.subagentProvider
if (options?.config?.maxRounds !== undefined) config.maxRounds = options.config.maxRounds
if (options?.config?.maxHandoffChars !== undefined) config.maxHandoffChars = options.config.maxHandoffChars
+ if (options?.config?.maxResultChars !== undefined) config.maxResultChars = options.config.maxResultChars
const fiber = await ctx.plugin(toolRalph, config)
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
return { ctx, engine: ctx.workflows as StubEngine, parent, fiber }
@@ -145,6 +146,7 @@ describe('dsh-tool-ralph', () => {
meta: { name: 'ralph-loop' },
args: { objective: 'Finish the migration.', maxRounds: 4, maxHandoffChars: 9000 },
subagentProvider: 'fresh',
+ maxTotalAgents: 4,
parent,
})
expect(engine.requests[0]!.script).toContain("status: 'budget-limited'")
@@ -154,7 +156,8 @@ describe('dsh-tool-ralph', () => {
report: COMPLETE,
})
expect(result.isError).toBe(false)
- expect((result.content[0] as { text: string }).text).toContain('Ralph completed after 1 round.')
+ expect((result.content[0] as { text: string }).text)
+ .toContain('Ralph worker reported completion after 1 round.')
expect((result.content[0] as { text: string }).text).toContain('All required gates pass.')
expect(engine.disposed).toBe(1)
})
@@ -167,7 +170,8 @@ describe('dsh-tool-ralph', () => {
roundsStarted: 2,
report: BLOCKED,
}, 2)
- expect((blockedResult.content[0] as { text: string }).text).toContain('Ralph blocked after 2 rounds.')
+ expect((blockedResult.content[0] as { text: string }).text)
+ .toContain('Ralph worker reported a blocker after 2 rounds.')
const limited = execute(ctx, { objective: 'Ship it.' }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) })
@@ -177,7 +181,54 @@ describe('dsh-tool-ralph', () => {
report: CONTINUE,
}, 2)
expect((limitedResult.content[0] as { text: string }).text)
- .toContain('Ralph reached its 2 rounds limit with work remaining.')
+ .toContain('Ralph reached its 2 rounds limit; the worker reported work remaining.')
+ })
+
+ it('bounds the complete parent result and labels worker-reported completion', async () => {
+ const { ctx, engine, parent } = await setup({ config: { maxResultChars: 160 } })
+ const pending = execute(ctx, { objective: 'Ship it.' }, { agent: parent })
+ const result = await settleCompleted(engine, pending, {
+ status: 'complete',
+ roundsStarted: 1,
+ report: { ...COMPLETE, evidence: ['x'.repeat(500)] },
+ })
+ const text = (result.content[0] as { text: string }).text
+ expect(text).toHaveLength(160)
+ expect(text).toContain('Ralph worker reported completion after 1 round.')
+ expect(text).toMatch(/… \[truncated\]$/)
+ })
+
+ it('honors a result limit shorter than the truncation marker', async () => {
+ const { ctx, engine, parent } = await setup({ config: { maxResultChars: 5 } })
+ const result = await settleCompleted(engine, execute(ctx, { objective: 'Ship it.' }, { agent: parent }), {
+ status: 'complete',
+ roundsStarted: 1,
+ report: COMPLETE,
+ })
+ expect((result.content[0] as { text: string }).text).toBe('\n… [t')
+ })
+
+ it('reports an ordinary child failure with the failed round and last durable handoff', async () => {
+ const { ctx, engine, parent } = await setup({ config: { maxRounds: 2 } })
+ const first = execute(ctx, { objective: 'Ship it.', maxRounds: 2 }, { agent: parent })
+ const firstResult = await settleCompleted(engine, first, {
+ status: 'round-failed',
+ roundsStarted: 1,
+ lastReport: null,
+ })
+ expect(firstResult.isError).toBe(true)
+ expect((firstResult.content[0] as { text: string }).text).toContain('Ralph round 1 child failed')
+ expect((firstResult.content[0] as { text: string }).text).toContain('No previous handoff was available.')
+
+ const later = execute(ctx, { objective: 'Ship it.', maxRounds: 2 }, { agent: parent })
+ const laterResult = await settleCompleted(engine, later, {
+ status: 'round-failed',
+ roundsStarted: 2,
+ lastReport: CONTINUE,
+ })
+ expect(laterResult.isError).toBe(true)
+ expect((laterResult.content[0] as { text: string }).text).toContain('Ralph round 2 child failed')
+ expect((laterResult.content[0] as { text: string }).text).toContain('Implemented the first slice.')
})
it('maps workflow error and cancellation reasons to tool errors and always disposes', async () => {
@@ -251,6 +302,7 @@ describe('dsh-tool-ralph', () => {
expect(() => { toolRalph.apply(new Context(), { subagentProvider: ' ' }) }).toThrow('non-empty normalized')
expect(() => { toolRalph.apply(new Context(), { maxRounds: 0 }) }).toThrow('positive safe integer')
expect(() => { toolRalph.apply(new Context(), { maxHandoffChars: 1.5 }) }).toThrow('positive safe integer')
+ expect(() => { toolRalph.apply(new Context(), { maxResultChars: 0 }) }).toThrow('positive safe integer')
})
it('turns malformed fixed-workflow terminal values and reports into errors', async () => {
@@ -261,11 +313,18 @@ describe('dsh-tool-ralph', () => {
{ value: { status: 'mystery', roundsStarted: 1, report: COMPLETE }, message: 'unknown terminal status' },
{ value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE }, message: 'before the round limit', config: { maxRounds: 2 } },
{ value: { status: 'complete', roundsStarted: 1, report: null }, message: 'malformed round report' },
+ { value: { status: 'complete', roundsStarted: 1, report: COMPLETE, extra: true }, message: 'malformed terminal result' },
+ { value: { status: 'blocked', roundsStarted: 1, report: BLOCKED, extra: true }, message: 'malformed terminal result' },
+ { value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE, extra: true }, message: 'malformed terminal result', config: { maxRounds: 1 } },
{ value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, status: 'continue' } }, message: 'malformed round report' },
{ value: { status: 'budget-limited', roundsStarted: 1, report: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 1 } },
{ value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, evidence: [] } }, message: 'invalid completion report' },
{ value: { status: 'blocked', roundsStarted: 1, report: { ...BLOCKED, blocker: '' } }, message: 'invalid blocked report' },
{ value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, summary: 'x'.repeat(500) } }, message: 'oversized handoff', config: { maxHandoffChars: 100 } },
+ { value: { status: 'round-failed', roundsStarted: 1 }, message: 'malformed terminal result' },
+ { value: { status: 'round-failed', roundsStarted: 1, lastReport: CONTINUE }, message: 'invalid first-round failure' },
+ { value: { status: 'round-failed', roundsStarted: 2, lastReport: null }, message: 'without its last handoff', config: { maxRounds: 2 } },
+ { value: { status: 'round-failed', roundsStarted: 2, lastReport: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 2 } },
]
for (const testCase of cases) {
const { ctx, engine, parent } = await setup(
@@ -294,7 +353,9 @@ describe('dsh-tool-ralph', () => {
const { ctx, fiber } = await setup()
const section = (await ctx.systemPrompt.assemble()).sections.find(candidate => candidate.name === 'tool:ralph')
expect(section?.text).toContain('ONLY when the direct human explicitly asks')
+ expect(section?.text).toContain('worker reports, not independent evaluation')
const tool = ctx.tools.get('ralph')!
+ expect(tool.description).toContain('worker reports completion')
expect(tool.presentCall!({ objective: 'Finish it.' })).toEqual({
card: 'generic',
title: 'ralph',
diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md
index 23fa849888..74eacc5fd8 100644
--- a/packages/workflow/workflow-workerthread/README.md
+++ b/packages/workflow/workflow-workerthread/README.md
@@ -34,7 +34,7 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide
## Run sequence
-`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. Source mode installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice.
+`start()` validates meta, parses the body, resolves a registered normalized provider route, and resolves any per-run total-child cap before creating a worker or publishing `workflow/start`. A requested `maxTotalAgents` must be a positive safe integer no greater than the engine's configured deployment ceiling. Source mode installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice.
For each `agent()` call:
@@ -81,7 +81,7 @@ The host keeps a ledger of forwarded child starts. A graceful worker supplies th
| `syncTimeoutMs` | `5000` | VM timeout for the script's initial synchronous slice. |
| `disposeGraceMs` | `5000` | Bound before force-settlement/termination and for public disposal. |
-An owning consumer may set `WorkflowStartRequest.subagentProvider` for one run. This is an engine-level route, not a script hook or a model-facing option; the ordinary `workflow` tool leaves it unset.
+An owning consumer may set `WorkflowStartRequest.subagentProvider` and `WorkflowStartRequest.maxTotalAgents` for one run. These are engine-level policy, not script hooks or model-facing options; the ordinary `workflow` tool leaves both unset. A per-run total-child cap may lower but never raise the configured `maxTotalAgents` ceiling.
## Model Experience
diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts
index 604d8ba754..33c5917acf 100644
--- a/packages/workflow/workflow-workerthread/src/index.ts
+++ b/packages/workflow/workflow-workerthread/src/index.ts
@@ -73,6 +73,36 @@ function assertBodyParses(body: string, name: string): void {
}
}
+/** Resolve one run's provider route before publishing work. */
+function resolveSubagentProvider(ctx: Context, configured: string, override: string | undefined): string {
+ const provider = override ?? configured
+ if (provider.length === 0 || provider !== provider.trim()) {
+ throw new WorkflowError(
+ 'workflow subagentProvider must be a non-empty normalized string',
+ 'INVALID_ARGUMENT',
+ )
+ }
+ if (ctx.subagents.getProvider(provider) === undefined) {
+ throw new WorkflowError(`no subagent provider registered for "${provider}"`, 'AGENT_START')
+ }
+ return provider
+}
+
+/** Resolve one run's total-child cap against the engine deployment ceiling. */
+function resolveMaxTotalAgents(requested: number | undefined, ceiling: number): number {
+ if (requested === undefined) return ceiling
+ if (!Number.isSafeInteger(requested) || requested < 1) {
+ throw new WorkflowError('workflow maxTotalAgents must be a positive safe integer', 'INVALID_ARGUMENT')
+ }
+ if (requested > ceiling) {
+ throw new WorkflowError(
+ `workflow maxTotalAgents ${requested} exceeds the engine ceiling ${ceiling}`,
+ 'INVALID_ARGUMENT',
+ )
+ }
+ return requested
+}
+
/**
* The worker-thread engine service. `start()` validates the script up front
* (meta + a host-side body parse) and returns a {@link WorkflowRun} whose
@@ -113,13 +143,15 @@ class WorkerWorkflowEngine extends WorkflowService {
start(request: WorkflowStartRequest): WorkflowRun {
const meta = validateMeta(request.meta)
assertBodyParses(request.script, meta.name)
+ const subagentProvider = resolveSubagentProvider(this.ctx, this.config.provider, request.subagentProvider)
+ const maxTotalAgents = resolveMaxTotalAgents(request.maxTotalAgents, this.config.maxTotalAgents)
const id = WorkflowRunId(randomUUID())
const info: WorkflowRunInfo = { id, meta }
const limits: WorkerLimits = {
maxConcurrentAgents: this.config.maxConcurrentAgents === 0
? Math.min(16, Math.max(1, availableParallelism() - 2))
: this.config.maxConcurrentAgents,
- maxTotalAgents: this.config.maxTotalAgents,
+ maxTotalAgents,
maxItemsPerCall: this.config.maxItemsPerCall,
syncTimeoutMs: this.config.syncTimeoutMs,
}
@@ -137,7 +169,6 @@ class WorkerWorkflowEngine extends WorkflowService {
// the now-inactive engine fiber and break the seam's holder-owned lifetime.
const runCtx = this.ctx
const subagents = runCtx.subagents
- const subagentProvider = request.subagentProvider ?? this.config.provider
const workerRun = new WorkerRun(
runCtx,
subagents,
diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts
index d82eae699d..535917fc42 100644
--- a/packages/workflow/workflow-workerthread/src/runtime.ts
+++ b/packages/workflow/workflow-workerthread/src/runtime.ts
@@ -255,7 +255,7 @@ export class WorkflowExecution {
const opts = this.readAgentOptions(rawOpts)
if (this.started >= this.limits.maxTotalAgents) {
throw new WorkflowError(
- `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise maxTotalAgents in the engine config if the scale is intentional`,
+ `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`,
'AGENT_CAP',
)
}
diff --git a/packages/workflow/workflow-workerthread/tests/session.spec.ts b/packages/workflow/workflow-workerthread/tests/session.spec.ts
index 102bcbd9a0..b856a48785 100644
--- a/packages/workflow/workflow-workerthread/tests/session.spec.ts
+++ b/packages/workflow/workflow-workerthread/tests/session.spec.ts
@@ -392,6 +392,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => {
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain('total agent cap (2)')
+ expect(result.error).toContain('applicable maxTotalAgents limit')
expect(result.agentsStarted).toBe(2)
host.close()
})
diff --git a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts
index 673a43ee0a..3fb08bb5ba 100644
--- a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts
+++ b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts
@@ -8,6 +8,7 @@ import { expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
+import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
import WorkerWorkflowEngine from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -18,6 +19,13 @@ vi.setConfig({ testTimeout: 30_000 })
it('runs the default config through the source worker', async () => {
const ctx = new Context()
const subagents = await ctx.plugin(SubagentService)
+ const provider: SubagentProvider = {
+ name: 'spawn',
+ capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
+ inheritsParentContext: false,
+ start: () => Promise.reject(new Error('source-worker compat script must not start a child')),
+ }
+ ctx.subagents.registerProvider(provider)
const engine = await ctx.plugin(WorkerWorkflowEngine, {})
const parent = { id: SessionId('workflow-compat-parent'), options: {} } as unknown as Agent
try {
diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts
index e2cc227704..d8d6f1e09d 100644
--- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts
+++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts
@@ -6,7 +6,7 @@ import Loader from '@cordisjs/plugin-loader'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
-import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
+import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
import * as workerEngineModule from '../src/index.ts'
import WorkerWorkflowEngine, { type Config } from '../src/index.ts'
import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts'
@@ -252,6 +252,77 @@ describe('dsh-workflow-workerthread', () => {
expect(provider.runs).toHaveLength(1)
})
+ it('rejects invalid start-request provider routes before publishing a run', async () => {
+ const { ctx, parent } = await setup()
+ let starts = 0
+ ctx.on('workflow/start', () => { starts += 1 })
+ const messages: string[] = []
+ for (const subagentProvider of ['', 'missing']) {
+ let run: WorkflowRun | undefined
+ let thrown: unknown
+ try {
+ run = ctx.workflows.start({
+ ...scripted("return 'must not start'"),
+ parent,
+ subagentProvider,
+ })
+ } catch (error: unknown) {
+ thrown = error
+ }
+ await run?.dispose()
+ messages.push(thrown instanceof Error ? thrown.message : '')
+ }
+
+ expect(messages).toEqual([
+ 'workflow subagentProvider must be a non-empty normalized string',
+ 'no subagent provider registered for "missing"',
+ ])
+ expect(starts).toBe(0)
+ })
+
+ it('rejects invalid per-run total-agent caps before publishing a run', async () => {
+ const { ctx, parent } = await setup({ config: { maxTotalAgents: 2 } })
+ let starts = 0
+ ctx.on('workflow/start', () => { starts += 1 })
+ const errors: unknown[] = []
+ for (const maxTotalAgents of [0, 1.5, Number.NaN, 3]) {
+ try {
+ const handle = ctx.workflows.start({
+ ...scripted("return 'must not start'"),
+ parent,
+ maxTotalAgents,
+ })
+ await handle.dispose()
+ } catch (error: unknown) {
+ errors.push(error)
+ }
+ }
+
+ expect(errors.slice(0, 3)).toEqual(Array(3).fill(expect.objectContaining({
+ code: 'INVALID_ARGUMENT',
+ message: 'workflow maxTotalAgents must be a positive safe integer',
+ })))
+ expect(errors[3]).toMatchObject({
+ code: 'INVALID_ARGUMENT',
+ message: 'workflow maxTotalAgents 3 exceeds the engine ceiling 2',
+ })
+ expect(starts).toBe(0)
+ })
+
+ it('enforces a per-run total-agent cap below the engine ceiling', async () => {
+ const { ctx, parent } = await setup({ config: { maxTotalAgents: 2 } })
+ const handle = ctx.workflows.start({
+ ...scripted("await agent('first'); await agent('second'); return 'unreachable'"),
+ parent,
+ maxTotalAgents: 1,
+ })
+ const result = await handle.result
+ expect(result.stopReason).toBe('error')
+ expect(result.agentsStarted).toBe(1)
+ expect(result.error).toContain('total agent cap (1)')
+ await handle.dispose()
+ })
+
it('a fatal hook error inside the worker kills the script and reports the error', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, scripted("return await parallel([() => agent('x', { isolation: 'worktree' })])"))
@@ -259,11 +330,18 @@ describe('dsh-workflow-workerthread', () => {
expect(result.error).toContain('"isolation" is deferred')
})
- it('a provider start failure crosses back as a fatal AGENT_START error', async () => {
+ it('rejects an unregistered configured provider before publishing a run', async () => {
const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } })
- const result = await run(ctx, parent, scripted("return await pipeline([1], () => agent('p'))"))
- expect(result.stopReason).toBe('error')
- expect(result.error).toContain('agent() could not start a child')
+ let thrown: unknown
+ try {
+ ctx.workflows.start({ ...scripted("return 'must not start'"), parent })
+ } catch (error: unknown) {
+ thrown = error
+ }
+ expect(thrown).toMatchObject({
+ code: 'AGENT_START',
+ message: 'no subagent provider registered for "nonexistent"',
+ })
})
it('waits for async provider start before announcing a result that settled early', async () => {
diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md
index 431080a555..9283f22cc7 100644
--- a/packages/workflow/workflow/README.md
+++ b/packages/workflow/workflow/README.md
@@ -6,11 +6,11 @@ The workflow seam (`ctx.workflows`) executes a model-written orchestration scrip
## Service and run contract
-`WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block or unparseable script before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace.
+`WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block, unparseable script, unavailable provider route, or unsupported per-run limit before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace.
A run is holder-owned. Engine-plugin unload prevents new starts but does not revoke accepted runs. The holder must call `dispose()` on every path; disposal cancels remaining work and reaches or abandons quiescence within the documented bound.
-`WorkflowStartRequest` contains `{ meta, script, args?, subagentProvider?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `subagentProvider` optionally routes every child in that run without exposing provider choice to the script; omission uses the engine's configured provider. `meta` and `args` are plain data, not script fragments.
+`WorkflowStartRequest` contains `{ meta, script, args?, subagentProvider?, maxTotalAgents?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `subagentProvider` optionally routes every child in that run without exposing provider choice to the script; omission uses the engine's configured provider. `maxTotalAgents` optionally lowers the engine's deployment ceiling for one run and is likewise invisible to the script. An implementation rejects invalid routes and limits synchronously. `meta` and `args` are plain data, not script fragments.
`WorkflowRun` exposes `{ id, meta, result, cancel(reason?), dispose() }`. `WorkflowResult` contains `{ value, stopReason, error?, agentsStarted }`; `value` is plain JSON data or `null`.
diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts
index de6af8f838..12386659bc 100644
--- a/packages/workflow/workflow/src/types.ts
+++ b/packages/workflow/workflow/src/types.ts
@@ -76,6 +76,11 @@ export interface WorkflowStartRequest {
* provider.
*/
subagentProvider?: string
+ /**
+ * Optional per-run total-child ceiling. Implementations reject values above
+ * their deployment ceiling before publishing the run.
+ */
+ maxTotalAgents?: number
/** The agent on whose behalf the run executes (parent of every child). */
parent: Agent
/** Cancels the run when aborted (the tool's `exec.signal`). */
From b2f08eac4f46a47755b7f69e08330b66750aef9f Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:01:07 +0800
Subject: [PATCH 05/14] test(workflow): register real-engine route
---
packages/workflow/tool-workflow/tests/tool-workflow.spec.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
index 08bc8171c3..d8c72ed567 100644
--- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
+++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
@@ -230,6 +230,12 @@ describe('dsh-tool-workflow', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
+ ctx.subagents.registerProvider({
+ name: 'spawn',
+ capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
+ inheritsParentContext: false,
+ start: () => Promise.reject(new Error('the parked-script fixture must not start a child')),
+ })
await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
await ctx.plugin(toolWorkflow, {})
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
From b8af6c4b430953d23adf5669ad5bc2cbbffb7176 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:13:11 +0800
Subject: [PATCH 06/14] docs(goal): align rollup with implemented stack
---
.../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++--
.../feature/2026-07-16-harness-level-loop.md | 13 ++++++++-----
.../feature/2026-07-16-harness-level-loop.zh.md | 13 ++++++++-----
3 files changed, 18 insertions(+), 12 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
index 9f519419e7..9501b344dd 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
+++ b/.agents/notes/implemented/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: 76b4efbe42bd0938fcaff14e96ad4e73883e602d
-2026-07-16-harness-level-loop.zh.md: 443501cfaf8a37a210786d4b251e58116c508ba0
+2026-07-16-harness-level-loop.md: 5ed9a08f3b80fe3ff8d87d90eed8c8af34179f95
+2026-07-16-harness-level-loop.zh.md: 7608f42517dad901a48e1bb1c1d1aa57f93c0374
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
index 76b4efbe42..5ed9a08f3b 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
@@ -66,7 +66,7 @@ Normal turn completion schedules another round only while the goal remains activ
### Human and model surfaces
-The human UX follows the compact current [Codex `/goal` command shape](https://learn.chatgpt.com/docs/developer-commands?surface=cli): `/goal` shows status, `/goal ` creates, and `edit`, `pause`, `resume`, or `clear` perform direct lifecycle actions. Status includes durable phase, admitted/capped rounds, and live armed/disarmed activation. Direct status and command output do not enter model history; accepted domain mutations remain reconstructable because the goal service records them.
+The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dispatcher at commit `678157a`](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805): `/goal` shows status, `/goal ` creates, and `edit`, `pause`, `resume`, or `clear` perform direct lifecycle actions. The commit permalink keeps the researched grammar verifiable as Codex evolves. Status includes durable phase, admitted/capped rounds, and live armed/disarmed activation. Direct status and command output do not enter model history; accepted domain mutations remain reconstructable because the goal service records them.
The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Direct-human provenance is enforced in code; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective.
@@ -76,11 +76,13 @@ TUI and ACP mount the shared command registry and complete goal stack by default
Ralph is a first-class model tool in its own plugin, demonstrating that a sophisticated fixed execution policy can be composed without a new loop core. The plugin owns a fixed workflow script over `ctx.workflows` and `ctx.subagents`; it does not create session-goal state or add a branch to `dsh-agent-loop`.
-Each round uses an explicit `WorkflowStartRequest.subagentProvider`, defaulting to `spawn`. The provider must exist, support structured output, and declare that it does not inherit parent context. The child inherits cwd and lineage but receives only the immutable objective, round/cap, workspace-as-authority instruction, and previous normalized report.
+Each round uses an explicit `WorkflowStartRequest.subagentProvider`, defaulting to `spawn`. The provider must exist, support structured output, and declare that it does not inherit parent context. Ralph also passes its resolved round cap as `WorkflowStartRequest.maxTotalAgents`; the worker engine validates both per-run policies before publishing work, so provider misconfiguration or an engine ceiling below the requested Ralph scale fails before a run exists. The child inherits cwd and lineage but receives only the immutable objective, round/cap, workspace-as-authority instruction, and previous normalized report.
-A report contains status, summary, evidence, next steps, and blocker text. Status-specific invariants and serialized size are validated inside the fixed script and again at the consumer boundary. `maxRounds` is configurable, defaults to `256`, and is the ceiling for a call override. `maxHandoffChars` defaults to `16384`; oversized reports fail rather than being silently truncated.
+A report contains status, summary, evidence, next steps, and blocker text. Status-specific invariants and serialized size are validated inside the fixed script and again at the consumer boundary. `maxRounds` is configurable, defaults to `256`, and is the ceiling for a call override. `maxHandoffChars` defaults to `16384`; oversized reports fail rather than being silently truncated. `maxResultChars` separately defaults to `16384` and bounds the complete successful parent-facing text, including its envelope and truncation marker.
-The tool is foreground and process-local. The parent tool call waits for the terminal result, propagates cancellation into the worker engine, and awaits `run.dispose()` so child work is quiescent before return. The model sees one call and one bounded terminal result; intermediate child conversations remain outside the parent transcript.
+An ordinary child failure ends the run without retry. The fixed script reports the failed round and last successful handoff when one exists, and the tool returns that state as an error instead of misclassifying it as a malformed report or budget exhaustion. Fatal workflow infrastructure failures can settle before the script returns that state; richer reason transport and retry policy remain deferred.
+
+The tool is foreground and process-local. The parent tool call waits for the terminal result, propagates cancellation into the worker engine, and awaits `run.dispose()` so child work is quiescent before return. The model sees one call and one bounded successful terminal result or an error; completion and blocker envelopes explicitly say that a worker reported the outcome rather than presenting it as independent certification. Intermediate child conversations remain outside the parent transcript.
### External design lineage
@@ -92,7 +94,7 @@ External products are comparators, not compatibility targets. The local source s
### Verification
-The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, command discovery in TUI/ACP, ACP transcript isolation, fixed Ralph provider routing, distinct unseeded children, bounded handoff, terminal outcomes, and cancellation quiescence. Package sources remain under the repository's per-file 100% coverage gate, and the assembled applications are pinned by keyless replay snapshots and built-binary tests.
+The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, command discovery in TUI/ACP, and ACP transcript isolation. Ralph's keyless real stack—worker-thread engine, spawn provider, structured-output runtime, and agent loop—covers distinct unseeded children, exact bounded handoff, completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and the assembled applications are pinned by keyless replay snapshots and built-binary tests.
## Alternatives considered
@@ -123,4 +125,5 @@ The six owning Agent Notes record unit, integration, process, snapshot, cancella
- **No generic loop journal or execution-world rewind** — session replay reconstructs model-visible goal history, not prior files, processes, environment, credentials, or external side effects. Ralph treats the current workspace as authority and carries no cross-run journal.
- **No goal reflector** — concern events, automatic no-progress heuristics, goal revision by an independent reflector, stuck-pattern detection, and `loop_split` are not implemented. Humans can edit, pause, clear, or resume the goal directly.
- **Ralph policy remains narrow** — one round creates one fresh child; within-round fan-out, evaluator/worker role separation, dynamic provider/model selection, and structural recursive-Ralph tool denial need separate policy surfaces. Prompt guidance is not enforcement.
+- **Ralph does not retry a failed child** — an ordinary failure preserves the failed round and last good handoff, while fatal workflow infrastructure failures can end before that state is available. Retry count, backoff, and richer failure transport need separate policy and seam design.
- **Portable UI remains modest** — TUI and ACP render plain-text goal status and generic Ralph cards. There is no continuous status widget, reconnectable command output, modal goal editor, or command plane in stdio/JSON-RPC.
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
index 443501cfaf..7608f42517 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
@@ -66,7 +66,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for
### 人类与模型表面
-人类 UX 遵循当前紧凑的 [Codex `/goal` 命令形态](https://learn.chatgpt.com/docs/developer-commands?surface=cli):`/goal` 显示状态,`/goal ` 创建目标,而 `edit`、`pause`、`resume` 或 `clear` 执行直接生命周期操作。状态包含持久阶段、已接纳/上限 Round 数以及实时已激活/未激活状态。直接状态与命令输出不会进入模型历史;已接受领域变更仍可重建,因为目标服务会记录它们。
+人类 UX 遵循 [OpenAI Codex 在提交 `678157a` 时的公开 TUI 分发器](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)中的紧凑形态:`/goal` 显示状态,`/goal ` 创建目标,而 `edit`、`pause`、`resume` 或 `clear` 执行直接生命周期操作。该提交永久链接让研究所得语法在 Codex 演进时仍可验证。状态包含持久阶段、已接纳/上限 Round 数以及实时已激活/未激活状态。直接状态与命令输出不会进入模型历史;已接受领域变更仍可重建,因为目标服务会记录它们。
模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。
@@ -76,11 +76,13 @@ TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同
Ralph 是位于自有插件中的一等模型工具,展示了复杂固定执行策略可以在没有新 loop 核心的情况下组合完成。该插件拥有构建在 `ctx.workflows` 与 `ctx.subagents` 之上的固定工作流脚本;它不会创建会话目标状态,也不会为 `dsh-agent-loop` 增加分支。
-每个 Round 都使用显式 `WorkflowStartRequest.subagentProvider`,默认为 `spawn`。该 provider 必须存在、支持结构化输出,并声明不继承父上下文。子 agent 继承 cwd 与谱系,但只接收不可变目标、当前 Round/上限、以工作区为权威的指令和上一份规范化报告。
+每个 Round 都使用显式 `WorkflowStartRequest.subagentProvider`,默认为 `spawn`。该 provider 必须存在、支持结构化输出,并声明不继承父上下文。Ralph 还会把解析后的 Round 上限作为 `WorkflowStartRequest.maxTotalAgents` 传递;工作线程引擎会在发布工作前验证两项每次运行策略,因此 provider 配置错误或低于所请求 Ralph 规模的引擎上限会在运行存在前失败。子 agent 继承 cwd 与谱系,但只接收不可变目标、当前 Round/上限、以工作区为权威的指令和上一份规范化报告。
-报告包含状态、摘要、证据、下一步与阻塞文本。固定脚本内部和消费者边界都会验证状态专用不变量与序列化大小。`maxRounds` 可配置,默认为 `256`,并作为调用覆盖值的上限。`maxHandoffChars` 默认为 `16384`;过大报告会失败,而不会被静默截断。
+报告包含状态、摘要、证据、下一步与阻塞文本。固定脚本内部和消费者边界都会验证状态专用不变量与序列化大小。`maxRounds` 可配置,默认为 `256`,并作为调用覆盖值的上限。`maxHandoffChars` 默认为 `16384`;过大报告会失败,而不会被静默截断。`maxResultChars` 单独默认为 `16384`,并限制面向父级的完整成功文本,包括外层文本与截断标记。
-该工具位于前台且只存在于进程内。父工具调用等待终止结果,把取消传播到工作线程引擎,并等待 `run.dispose()`,因此返回前子工作已达到静止。模型只看到一次调用和一份有界终止结果;中间子 agent 对话不会进入父转录。
+普通子 agent 失败会结束运行且不重试。固定脚本会报告失败 Round,并在存在时带回上一份成功交接;工具会把该状态作为错误返回,而不会误判为畸形报告或预算耗尽。致命工作流基础设施错误可能在脚本返回该状态前结算;更丰富的原因传输与重试策略均予以延期。
+
+该工具位于前台且只存在于进程内。父工具调用等待终止结果,把取消传播到工作线程引擎,并等待 `run.dispose()`,因此返回前子工作已达到静止。模型只看到一次调用,以及一份有界成功终止结果或一个错误;完成与阻塞的外层文本会明确说明结果由工作者报告,而不会呈现为独立认证。中间子 agent 对话不会进入父转录。
### 外部设计谱系
@@ -92,7 +94,7 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天
### 验证
-六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、TUI/ACP 命令发现、ACP 转录隔离、固定 Ralph provider 路由、互不相同且无种子的子 agent、有界交接、终止结果与取消静止性。包源码继续受仓库逐文件 100% 覆盖率门禁约束,组装后应用由无密钥重放快照与构建后二进制测试固定。
+六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、TUI/ACP 命令发现与 ACP 转录隔离。Ralph 的无密钥真实栈——工作线程引擎、spawn provider、结构化输出运行时与 agent loop——覆盖互不相同且无种子的子 agent、准确有界交接、完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,组装后应用由无密钥重放快照与构建后二进制测试固定。
## 考虑过的替代方案
@@ -123,4 +125,5 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天
- **没有通用 loop 日志或执行世界回退**——会话重放会重建模型可见目标历史,而不会恢复此前文件、进程、环境、凭据或外部副作用。Ralph 把当前工作区作为权威,并且没有跨运行日志。
- **没有目标反思器**——concern 事件、自动无进展启发式、由独立反思器执行的目标修订、卡住模式检测与 `loop_split` 均未实现。人类可以直接编辑、暂停、清除或恢复目标。
- **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与结构化递归 Ralph 工具禁止都需要独立策略表面。提示词指导不是强制执行。
+- **Ralph 不会重试失败的子 agent**——普通失败会保留失败 Round 与上一份有效交接,而致命工作流基础设施错误可能在该状态可用前结束。重试次数、退避与更丰富的失败传输需要独立的策略与接缝设计。
- **可移植 UI 仍较朴素**——TUI 与 ACP 渲染纯文本目标状态和通用 Ralph 卡片。系统没有持续状态组件、可重连命令输出、模态目标编辑器,也没有 stdio/JSON-RPC 命令平面。
From 9f6f87cf694c7915b58f9f353a289fe514fc2fd8 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 18:18:08 +0800
Subject: [PATCH 07/14] test(ralph): snapshot fresh-agent rounds in headless
app
---
...-fresh-agent-ralph-workflow-tool.i18n.yaml | 4 +-
...6-07-19-fresh-agent-ralph-workflow-tool.md | 2 +-
...7-19-fresh-agent-ralph-workflow-tool.zh.md | 2 +-
.../headless-agent/ralph.cordis.snapshot.yml | 12 +++
.../headless-agent/tests/headless.snapshot.ts | 75 +++++++++++++++++++
.../tests/snapshots/ralph-loop/input.json | 8 ++
.../snapshots/ralph-loop/replay.override.json | 22 ++++++
.../snapshots/ralph-loop/session.1.jsonl | 6 ++
.../snapshots/ralph-loop/session.2.jsonl | 6 ++
.../tests/snapshots/ralph-loop/session.jsonl | 1 +
.../ralph-loop/stream-json.expected.jsonl | 23 ++++++
11 files changed, 157 insertions(+), 4 deletions(-)
create mode 100644 examples/headless-agent/ralph.cordis.snapshot.yml
create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/input.json
create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json
create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl
create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl
create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl
create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl
diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml
index f0453085c5..9ef2bae475 100644
--- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.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-fresh-agent-ralph-workflow-tool.md: 159ad7e39602d8b84ffafdb396ad1083f9c73009
-2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: bb6025952ada35526459101d53b3ea1fea622163
+2026-07-19-fresh-agent-ralph-workflow-tool.md: 46816b8cc06f0acf5615ffb44035c79ea13b6794
+2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 188a0bf50a7a6a683cd6aa1004c4d78673d3631d
diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
index 159ad7e396..46816b8cc0 100644
--- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
+++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
@@ -46,7 +46,7 @@ ACP and terminal presentation use a generic `ralph` card whose raw input is the
Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing and child ceiling, all successful terminal outcomes, ordinary child-failure envelopes, malformed and oversized boundary values, exact successful-result truncation, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove synchronous provider-route validation, per-run child ceilings below the deployment ceiling, and that a provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node.
-A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface.
+A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. A shipped keyless headless snapshot additionally boots the real `examples/headless-agent` composition, invokes `ralph`, pins the parent stream transcript, and inspects persisted logs for two distinct unseeded child sessions and the round-one handoff appearing only in round two. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
index bb6025952a..188a0bf50a 100644
--- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
@@ -46,7 +46,7 @@ ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入
单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明 provider 路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。
-一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、只产生一个阶段事件、终止完成以及两个子 agent 都被处置。同一真实栈还覆盖阻塞与轮次上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后达到子 agent 静止状态。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。
+一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、只产生一个阶段事件、终止完成以及两个子 agent 都被处置。同一真实栈还覆盖阻塞与轮次上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后达到子 agent 静止状态。一项已发布的无密钥无头快照还会启动真实的 `examples/headless-agent` 组合、调用 `ralph`、固定父级流式转录,并检查持久化日志中存在两个不同且无种子的子会话,且第一轮交接只出现在第二轮。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。
## 考虑过的替代方案
diff --git a/examples/headless-agent/ralph.cordis.snapshot.yml b/examples/headless-agent/ralph.cordis.snapshot.yml
new file mode 100644
index 0000000000..e84bdfed31
--- /dev/null
+++ b/examples/headless-agent/ralph.cordis.snapshot.yml
@@ -0,0 +1,12 @@
+# Replay counterpart to cordis.yml for the shipped Ralph-loop snapshot.
+- id: base
+ name: '@cordisjs/plugin-include'
+ config:
+ path: ./cordis.yml
+ patches:
+ - id: llm-deepseek
+ name: '@deepseek-ai/dsh-llm-deepseek'
+ disabled: true
+ - insert:
+ - id: llm-replay
+ name: '@deepseek-ai/dsh-llm-replay'
diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts
index d799c6250d..d8a36aab59 100644
--- a/examples/headless-agent/tests/headless.snapshot.ts
+++ b/examples/headless-agent/tests/headless.snapshot.ts
@@ -17,6 +17,8 @@ const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.j
const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url))
const goalScenarioDir = join(snapshotsDir, 'goal-tools')
const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url))
+const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
+const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
@@ -216,4 +218,77 @@ describe('headless stream-json snapshots', () => {
if (refreshing) await writeFile(streamExpected, normalized)
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
+ it('replays two fresh Ralph rounds through the one-shot app', async () => {
+ const prompt = await scenarioPrompt(ralphScenarioDir, 'ralph-loop')
+ const streamExpected = join(ralphScenarioDir, 'stream-json.expected.jsonl')
+ let runCwd = ''
+ const result = await runLoaderSmoke({
+ label: 'Ralph loop headless stream-json snapshot',
+ tempDirPrefix: 'headless-snapshot-ralph-loop-',
+ binScript,
+ configPath: ralphConfigPath,
+ binArgs: ['--config', ralphConfigPath, '--output-format', 'stream-json', prompt],
+ tsconfigPath,
+ env: {
+ DSH_SNAPSHOT: 'replay',
+ DSH_SNAPSHOT_FILE: join(ralphScenarioDir, 'session.jsonl'),
+ DSH_SNAPSHOT_OVERRIDE: join(ralphScenarioDir, 'replay.override.json'),
+ DSH_SNAPSHOT_CHILD_FILES: [
+ join(ralphScenarioDir, 'session.1.jsonl'),
+ join(ralphScenarioDir, 'session.2.jsonl'),
+ ].join(delimiter),
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+ },
+ prepare: (cwd) => { runCwd = cwd },
+ inspect: async (cwd) => {
+ const logs = await persistedLogs(cwd)
+ expect(logs).toHaveLength(3)
+ const parent = logs.find(log => typeof log.header.parentSession !== 'string')
+ if (parent === undefined) throw new Error('Ralph snapshot did not persist its parent session')
+ const parentId = parent.header.id
+ expect(typeof parentId).toBe('string')
+ const children = logs.filter(log => typeof log.header.parentSession === 'string')
+ .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
+ expect(children).toHaveLength(2)
+ expect(children.map(child => child.header.parentSession)).toEqual([parentId, parentId])
+ expect(children.map(child => child.header.cwd)).toEqual([parent.header.cwd, parent.header.cwd])
+ expect(children.map(child => child.header.seedLength)).toEqual([undefined, undefined])
+ expect(new Set(children.map(child => child.header.id)).size).toBe(2)
+
+ const parentRecords = parseJsonl(parent.content)
+ const parentCalls = parentRecords.filter(record => record.type === 'tool/call')
+ expect(parentCalls.map(record => (record.data as JsonObject | undefined)?.name)).toEqual(['ralph'])
+ const parentResult = parentRecords.find(record => record.type === 'tool/result')
+ const parentResultData = parentResult?.data as JsonObject | undefined
+ expect(parentResultData?.isError).toBe(false)
+ expect(JSON.stringify(parentResultData?.content)).toContain('reported completion after 2 rounds')
+
+ const childRecords = children.map(child => parseJsonl(child.content))
+ const childPrompts = childRecords.map((records) => {
+ const message = records.find(record => record.type === 'user/message')
+ return JSON.stringify((message?.data as JsonObject | undefined)?.content)
+ })
+ expect(childPrompts[0]).toContain('Ralph round: 1 of 2.')
+ expect(childPrompts[0]).toContain('(none — this is the first round)')
+ expect(childPrompts[0]).not.toContain('ROUND_ONE_HANDOFF')
+ expect(childPrompts[1]).toContain('Ralph round: 2 of 2.')
+ expect(childPrompts[1]).toContain('ROUND_ONE_HANDOFF')
+ for (const childPrompt of childPrompts) {
+ expect(childPrompt).toContain('Prove two fresh Ralph rounds through the shipped headless app.')
+ expect(childPrompt).not.toContain('Run a two-round fresh-agent Ralph loop')
+ }
+ for (const records of childRecords) {
+ const calls = records.filter(record => record.type === 'tool/call')
+ expect(calls.map(record => (record.data as JsonObject | undefined)?.name))
+ .toEqual(['structured_output'])
+ }
+ },
+ })
+
+ expect(result.stderr).toBe('')
+ const normalized = normalizeHeadlessStream(result.stdout, runCwd)
+ if (refreshing) await writeFile(streamExpected, normalized)
+ expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
+ }, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/input.json b/examples/headless-agent/tests/snapshots/ralph-loop/input.json
new file mode 100644
index 0000000000..42652a4ac5
--- /dev/null
+++ b/examples/headless-agent/tests/snapshots/ralph-loop/input.json
@@ -0,0 +1,8 @@
+{
+ "steps": [
+ {
+ "op": "prompt",
+ "text": "Run a two-round fresh-agent Ralph loop to prove the shipped headless integration."
+ }
+ ]
+}
diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json b/examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json
new file mode 100644
index 0000000000..1d7846f76b
--- /dev/null
+++ b/examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json
@@ -0,0 +1,22 @@
+[
+ {
+ "kind": "chunks",
+ "chunks": [
+ { "type": "block-start", "index": 0, "blockType": "tool-call" },
+ { "type": "tool-call-delta", "index": 0, "id": "call_ralph", "name": "ralph", "argumentsDelta": "{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}" },
+ { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_ralph", "name": "ralph", "arguments": "{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}" } },
+ { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } },
+ { "type": "finish", "reason": { "kind": "tool-calls" } }
+ ]
+ },
+ {
+ "kind": "chunks",
+ "chunks": [
+ { "type": "block-start", "index": 0, "blockType": "text" },
+ { "type": "text-delta", "index": 0, "text": "RALPH SNAPSHOT COMPLETE" },
+ { "type": "block-end", "index": 0, "block": { "type": "text", "text": "RALPH SNAPSHOT COMPLETE" } },
+ { "type": "usage", "usage": { "inputTokens": 30, "outputTokens": 4 } },
+ { "type": "finish", "reason": { "kind": "stop" } }
+ ]
+ }
+]
diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl
new file mode 100644
index 0000000000..ca36330c36
--- /dev/null
+++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl
@@ -0,0 +1,6 @@
+{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"}
+{"type":"assistant/chunk","seq":0,"time":1783951001001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":1,"time":1783951001002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-one-report","name":"structured_output","argumentsDelta":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}}
+{"type":"assistant/chunk","seq":2,"time":1783951001003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-one-report","name":"structured_output","arguments":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}}}
+{"type":"assistant/chunk","seq":3,"time":1783951001004,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":12}}}}
+{"type":"assistant/chunk","seq":4,"time":1783951001005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl
new file mode 100644
index 0000000000..c722158098
--- /dev/null
+++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl
@@ -0,0 +1,6 @@
+{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"}
+{"type":"assistant/chunk","seq":0,"time":1783951002001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":1,"time":1783951002002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-two-report","name":"structured_output","argumentsDelta":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}}
+{"type":"assistant/chunk","seq":2,"time":1783951002003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-two-report","name":"structured_output","arguments":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}}}
+{"type":"assistant/chunk","seq":3,"time":1783951002004,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":12}}}}
+{"type":"assistant/chunk","seq":4,"time":1783951002005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl
new file mode 100644
index 0000000000..c452fb5fa8
--- /dev/null
+++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl
@@ -0,0 +1 @@
+{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"/tmp/ralph-headless"}
diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl
new file mode 100644
index 0000000000..727f84fd90
--- /dev/null
+++ b/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl
@@ -0,0 +1,23 @@
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run a two-round fresh-agent Ralph loop to prove the shipped headless integration."}],"source":{"kind":"user"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_ralph","name":"ralph","argumentsDelta":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_ralph","content":[{"type":"text","text":"Ralph worker reported completion after 2 rounds.\nFinal report:\n{\n \"status\": \"complete\",\n \"summary\": \"The Ralph snapshot objective is complete.\",\n \"evidence\": [\n \"Two fresh rounds completed through the shipped app.\"\n ],\n \"nextSteps\": [],\n \"blocker\": \"\"\n}"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"RALPH SNAPSHOT COMPLETE"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
+{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"RALPH SNAPSHOT COMPLETE","reason":{"kind":"completed"},"usage":{"inputTokens":50,"outputTokens":12}}
From 07058e527cc87b81de4c2359bce67ff3244cf6a7 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 18:32:21 +0800
Subject: [PATCH 08/14] fix(snapshot): isolate concurrent spill roots
---
.../testing/2026-06-19-acp-snapshot-tests.md | 2 +-
packages/support/acp-snapshot/src/harness.ts | 14 ++++++++++--
.../support/acp-snapshot/src/normalize.ts | 2 +-
.../tests/fixtures/fake-acp-agent.ts | 1 +
.../acp-snapshot/tests/harness.spec.ts | 22 +++++++++++++++++++
.../acp-snapshot/tests/normalize.spec.ts | 15 +++++++++++++
6 files changed, 52 insertions(+), 4 deletions(-)
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
index 5fc7479eaf..56a102f456 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
@@ -57,7 +57,7 @@ Normalization replaces session, cwd, protocol-id, timestamp, path, and process v
### Isolation: normalization now, sandbox later
-Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. It does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed.
+Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. Concurrent replay runs own separate cwd, persistence, and fixed-length scenario-keyed spill roots, so one scenario's teardown cannot delete another's in-flight full-output recovery while real-path preview budgets remain stable. This tier does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed.
### The replay plugin is its own package
diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts
index a6252d9aa3..9700f24702 100644
--- a/packages/support/acp-snapshot/src/harness.ts
+++ b/packages/support/acp-snapshot/src/harness.ts
@@ -18,8 +18,9 @@
import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
+import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
-import { join, delimiter } from 'node:path'
+import { basename, dirname, join, delimiter } from 'node:path'
import {
ClientSideConnection,
PROTOCOL_VERSION,
@@ -152,6 +153,13 @@ export interface RunOptions {
configPath?: string
}
+/** Derive one stable, fixed-length spill root owned by this scenario. */
+function scenarioSpillRoot(fixtureFile: string): string {
+ const scenario = basename(dirname(fixtureFile))
+ const key = createHash('sha256').update(scenario).digest('hex').slice(0, 9)
+ return `/tmp/dsh-acp-snap-${key}`
+}
+
/**
* Run a scenario end-to-end against a freshly-spawned subprocess. Owns the
* child and its temp dirs; always tears them down. Returns the captured stdout
@@ -166,7 +174,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
// Fixed path length: spill-policy budgets the preview against the REAL path
// before stdout normalization, so tmpdir() length differences churn expected outputs.
- const spillRoot = '/tmp/dsh-acp-snapshot-spill'
+ // Scenario ownership also matters: replay runs concurrently, and one teardown
+ // must never delete another scenario's in-flight full-output recovery file.
+ const spillRoot = scenarioSpillRoot(opts.fixtureFile)
// Everything past the temp-dir creation is followed by failure-safe cleanup,
// so a failure in workspace seeding, spawn, or any step never leaks resources.
let launched: LaunchedAcpTestAgent | undefined
diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts
index 0c21046eb2..6671959b29 100644
--- a/packages/support/acp-snapshot/src/normalize.ts
+++ b/packages/support/acp-snapshot/src/normalize.ts
@@ -20,7 +20,7 @@ const LOCAL_SPILL_PATH_RE = new RegExp(
'g',
)
const SNAPSHOT_SPILL_PATH_RE = new RegExp(
- String.raw`/tmp/dsh-acp-snapshot-spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
+ String.raw`/tmp/(?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
+ String.raw`(?=\. Use read with offset/limit|[\s)]|$)`,
'g',
)
diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts
index 43b5ee3fb3..277dc7565c 100644
--- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts
+++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts
@@ -152,6 +152,7 @@ async function handlePrompt(id: number | string): Promise {
mode: process.env.DSH_SNAPSHOT,
override: process.env.DSH_SNAPSHOT_OVERRIDE ?? null,
childFiles: process.env.DSH_SNAPSHOT_CHILD_FILES ?? null,
+ spillRoot: process.env.DSH_SNAPSHOT_SPILL_ROOT ?? null,
})}`)
}
if (behavior.echoWorkspace === true) {
diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts
index 748b9e1606..2f54b7781d 100644
--- a/packages/support/acp-snapshot/tests/harness.spec.ts
+++ b/packages/support/acp-snapshot/tests/harness.spec.ts
@@ -60,6 +60,15 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s
const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
+function environmentEcho(rawStdout: string): Record {
+ const frames = rawStdout.trim().split('\n')
+ .map(line => JSON.parse(line) as { params?: { update?: { content?: { text?: unknown } } } })
+ const text = frames.map(frame => frame.params?.update?.content?.text)
+ .find(value => typeof value === 'string' && value.startsWith('env:'))
+ if (typeof text !== 'string') throw new Error('fake ACP agent did not echo its environment')
+ return JSON.parse(text.slice('env:'.length)) as Record
+}
+
describe('runScenario', () => {
it('surfaces an asynchronous child spawn failure through startup and close', async () => {
const { dir } = await scenario({})
@@ -309,6 +318,19 @@ describe('runScenario', () => {
expect(result.rawStdout).toContain(JSON.stringify(childFiles.join(delimiter)).slice(1, -1))
})
+ it('gives concurrent scenarios distinct equal-length spill roots', { timeout: 20_000 }, async () => {
+ const [first, second] = await Promise.all([scenario({ echoEnv: true }), scenario({ echoEnv: true })])
+ const results = await Promise.all([first, second].map(({ fixtureFile }) => runScenario(
+ { steps: [...boot, { op: 'prompt', text: 'env?' }] },
+ { agent: AGENT, mode: 'replay', fixtureFile },
+ )))
+ const roots = results.map(result => environmentEcho(result.rawStdout).spillRoot)
+ expect(roots.every(root => typeof root === 'string')).toBe(true)
+ expect(new Set(roots).size).toBe(2)
+ expect((roots[0] as string).length).toBe((roots[1] as string).length)
+ expect((roots[0] as string).length).toBe('/tmp/dsh-acp-snapshot-spill'.length)
+ })
+
it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => {
const { dir, fixtureFile } = await scenario({ echoWorkspace: true })
const workspaceDir = join(dir, 'workspace')
diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts
index 2beaba5114..0ebfbe3255 100644
--- a/packages/support/acp-snapshot/tests/normalize.spec.ts
+++ b/packages/support/acp-snapshot/tests/normalize.spec.ts
@@ -139,6 +139,21 @@ describe('normalizeSessionLog', () => {
expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
})
+ it('scrubs scenario-owned snapshot spill paths', () => {
+ const ev = JSON.stringify({
+ type: 'tool/result', seq: 2, time: 5,
+ data: {
+ content: [{
+ type: 'text',
+ text: 'Full formatted result stored at: /tmp/dsh-acp-snap-012345678/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
+ }],
+ },
+ })
+ const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
+ expect(out).toContain('{{spillLocator:bash.txt}}')
+ expect(out).not.toContain('/tmp/dsh-acp-snap-012345678')
+ })
+
it('scrubs the session id in the header', () => {
const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
expect(out).toContain('{{sessionId}}')
From bdd0a05c4aa7397760750c36dafdc1b0975c07e1 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 18:41:54 +0800
Subject: [PATCH 09/14] docs(agent-note): align goal rollup with implementation
---
.../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++--
.../feature/2026-07-16-harness-level-loop.md | 12 ++++++------
.../feature/2026-07-16-harness-level-loop.zh.md | 12 ++++++------
3 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
index 9501b344dd..19c946a6f6 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
+++ b/.agents/notes/implemented/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: 5ed9a08f3b80fe3ff8d87d90eed8c8af34179f95
-2026-07-16-harness-level-loop.zh.md: 7608f42517dad901a48e1bb1c1d1aa57f93c0374
+2026-07-16-harness-level-loop.md: f9f501d365d368cffdbc0f909db48f9d6ce926b5
+2026-07-16-harness-level-loop.zh.md: 1d09841e74530fbe4c9d86b7a481a95494890afe
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
index 5ed9a08f3b..f9f501d365 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
@@ -35,8 +35,8 @@ Time-based `/loop` or scheduled execution is a third policy and is not implement
| Package | Repository category | Owned structures and verbs |
|---|---|---|
-| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`, domain service | Owns `GoalId`, compare-and-set `GoalRef`, `GoalSnapshot`, `GoalPhase`, process-local `GoalActivation`, replay folding, and `get`, `create`, `edit`, `pause`, `resume`, `complete`, `block`, `markUsageLimited`, `markBudgetLimited`, `clear`, and `disarm` verbs. |
-| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`, model-facing consumer | Registers exclusive `get_goal`, `create_goal`, and `update_goal`; authenticates live turn provenance and narrows autonomous-round authority to completion or blocking reports. |
+| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`, domain service | Owns `GoalId`, compare-and-set `GoalRef`, `GoalSnapshot`, four-state `GoalPhase`, structured `GoalBlockReason`, process-local `GoalActivation`, replay folding, and `get`, `create`, `edit`, `pause`, `resume`, `complete`, `block`, `clear`, and `disarm` verbs. |
+| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`, model-facing consumer | Registers exclusive `get_goal`, `create_goal`, and `update_goal`; authenticates live turn provenance and narrows autonomous-round authority to completion or blocking reports with machine-routable reason codes. |
| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`, continuation policy | Reserves, fences, admits, attributes, settles, cancels, and quiescently drains same-session goal rounds without importing the concrete loop. |
| `@deepseek-ai/dsh-commands` | `packages/ui/commands/`, UI registry | Owns `CommandDefinition`, discovery, scoped registration, direct dispatch, `CommandResult`, and request cancellation for human-only commands. |
| `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`, human-command producer | Registers `/goal` status, creation, edit, pause, resume, and clear over the goal domain for TUI and ACP. |
@@ -48,7 +48,7 @@ The detailed contracts live in the [goal-domain](2026-07-19-persisted-same-sessi
One session has at most one current goal. Every non-clear mutation appends a full, versioned, model-visible goal snapshot through `Agent.inject()`; clear appends a revisioned tombstone. The session log is the only durable source of truth, so normal persistence, resume, compaction semantics, and `SessionStore.fork()` carry the goal without a second database or an artificial cancellation record.
-Durable phases are `active`, `paused`, `blocked`, `usage-limited`, `budget-limited`, and `complete`. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed.
+Durable phases are only `active`, `paused`, `blocked`, and `complete`. A blocked goal carries a required `GoalBlockReason` with a stable lower-kebab-case `code` and a non-empty human-readable `message`; usage limits, round exhaustion, model failures, and policy rejection are reason codes rather than extra lifecycle phases. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed.
This separation makes session restoration observable and unsurprising. Reopening a session never starts goal work by itself. A later human prompt such as “continue”, “resume the goal”, or an equivalent request in any language gives the runtime-root model a new turn in which it may read the goal and call `update_goal(..., action: 'resume')`. `/goal resume` is the direct human-command path. The runtime authenticates that the request came from a live direct-human turn; prompt policy lets the model interpret whether the wording semantically authorizes creation or resumption.
@@ -62,7 +62,7 @@ The goal-round driver owns at most one pending reservation per exact live agent.
Only the durable goal-sourced `user/message` charges a round. Stale reservations become rejected zero-step turns without consuming the cap. A concurrent goal revision wins over settlement from an older round.
-Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses; rate limiting records `usage-limited`; cap exhaustion records `budget-limited`; other errors, max-token stops, policy rejections, and unknown terminal results block for inspection. The driver never invents an automatic retry after an abnormal outcome. A human can later authorize resume through ordinary language or `/goal resume`.
+Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. The driver never invents an automatic retry after an abnormal outcome. A human can later authorize resume through ordinary language or `/goal resume`.
### Human and model surfaces
@@ -70,7 +70,7 @@ The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dis
The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Direct-human provenance is enforced in code; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective.
-TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. Line-oriented stdio does not consume the command plane; its ordinary human text can still authorize model goal tools when that stack is mounted.
+TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. Line-oriented stdio does not consume the command plane; its ordinary human text can still authorize model goal tools when that stack is mounted.
### Fresh-agent Ralph execution
@@ -94,7 +94,7 @@ External products are comparators, not compatibility targets. The local source s
### Verification
-The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, command discovery in TUI/ACP, and ACP transcript isolation. Ralph's keyless real stack—worker-thread engine, spawn provider, structured-output runtime, and agent loop—covers distinct unseeded children, exact bounded handoff, completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and the assembled applications are pinned by keyless replay snapshots and built-binary tests.
+The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, adapter-wide command discovery, and transcript isolation. Shipped keyless snapshots cover model goal creation/inspection through the headless app, multi-round same-session lifecycle and cancellation through ACP, direct `/goal` status without a model turn, and two real Ralph rounds through the headless app. The Ralph snapshot boots the worker-thread engine, spawn provider, structured-output runtime, and agent loop, then inspects distinct unseeded child logs and exact one-way bounded handoff while pinning the parent stream. Focused real-stack tests additionally cover completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and built-binary tests cover installed-artifact resolution. The implementation experience is recorded in the root testing policy: every non-trivial model- or human-visible change must carry a real-example keyless snapshot in the same PR rather than relying on package or echo-agent coverage.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
index 7608f42517..1d09841e74 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
@@ -35,8 +35,8 @@ Status: implemented
| 包 | 仓库类别 | 所属结构与动词 |
|---|---|---|
-| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`,领域服务 | 拥有 `GoalId`、比较并交换 `GoalRef`、`GoalSnapshot`、`GoalPhase`、进程本地 `GoalActivation`、重放折叠,以及 `get`、`create`、`edit`、`pause`、`resume`、`complete`、`block`、`markUsageLimited`、`markBudgetLimited`、`clear` 与 `disarm` 动词。 |
-| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`,面向模型消费者 | 注册互斥的 `get_goal`、`create_goal` 与 `update_goal`;认证实时 Turn 来源,并把自治 Round 权限收窄到完成或阻塞报告。 |
+| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`,领域服务 | 拥有 `GoalId`、比较并交换 `GoalRef`、`GoalSnapshot`、四状态 `GoalPhase`、结构化 `GoalBlockReason`、进程本地 `GoalActivation`、重放折叠,以及 `get`、`create`、`edit`、`pause`、`resume`、`complete`、`block`、`clear` 与 `disarm` 动词。 |
+| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`,面向模型消费者 | 注册互斥的 `get_goal`、`create_goal` 与 `update_goal`;认证实时 Turn 来源,并把自治 Round 权限收窄到带机器可路由原因代码的完成或阻塞报告。 |
| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`,续行策略 | 在不导入具体 loop 的情况下,预留、设围栏、接纳、归属、结算、取消并静止排空同会话目标回合。 |
| `@deepseek-ai/dsh-commands` | `packages/ui/commands/`,UI 注册表 | 拥有面向人类专用命令的 `CommandDefinition`、发现、作用域注册、直接分发、`CommandResult` 与请求取消。 |
| `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`,人类命令生产方 | 为 TUI 和 ACP 注册构建在目标领域之上的 `/goal` 状态、创建、编辑、暂停、恢复与清除。 |
@@ -48,7 +48,7 @@ Status: implemented
一个会话至多有一个当前目标。每次非清除变更都通过 `Agent.inject()` 追加一份完整、带版本且模型可见的目标快照;清除会追加带修订号的墓碑。会话日志是唯一持久事实来源,因此普通持久化、恢复、压缩语义与 `SessionStore.fork()` 会携带目标,无需第二个数据库或人为取消记录。
-持久阶段为 `active`、`paused`、`blocked`、`usage-limited`、`budget-limited` 与 `complete`。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 重放、驱动器替换和驱动器拆卸都会让目标保持未激活。
+持久阶段只有 `active`、`paused`、`blocked` 与 `complete`。阻塞目标必须携带 `GoalBlockReason`,其中包含稳定的小写 kebab-case `code` 与非空的人类可读 `message`;用量限制、Round 耗尽、模型失败与策略拒绝都是原因代码,而不是额外生命周期阶段。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 重放、驱动器替换和驱动器拆卸都会让目标保持未激活。
这种分离让会话恢复可观察且符合直觉。重新打开会话绝不会自行开始目标工作。随后的人类提示词,例如“继续”、“恢复目标”或任何语言中的等价请求,会给运行时根 agent 的模型一个新 Turn;模型可在其中读取目标并调用 `update_goal(..., action: 'resume')`。`/goal resume` 是直接人类命令路径。运行时认证请求来自实时直接人类 Turn;提示策略让模型解释措辞在语义上是否授权创建或恢复。
@@ -62,7 +62,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for
只有持久的目标来源 `user/message` 会计入一个 Round。过时预留会成为未消耗上限的零 Step 拒绝 Turn。并发目标修订会胜过旧 Round 的结算。
-普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停;速率限制记录 `usage-limited`;上限耗尽记录 `budget-limited`;其他错误、max-token 停止、策略拒绝和未知终止结果会进入阻塞状态以供检查。驱动器绝不会在异常结果后凭空发起自动重试。人类随后可以通过普通语言或 `/goal resume` 授权恢复。
+普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;Turn 错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。驱动器绝不会在异常结果后凭空发起自动重试。人类随后可以通过普通语言或 `/goal resume` 授权恢复。
### 人类与模型表面
@@ -70,7 +70,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for
模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。
-TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。行式 stdio 不消费命令平面;挂载目标栈后,它的普通人类文本仍可授权模型目标工具。
+TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。行式 stdio 不消费命令平面;挂载目标栈后,它的普通人类文本仍可授权模型目标工具。
### 全新 agent Ralph 执行
@@ -94,7 +94,7 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天
### 验证
-六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、TUI/ACP 命令发现与 ACP 转录隔离。Ralph 的无密钥真实栈——工作线程引擎、spawn provider、结构化输出运行时与 agent loop——覆盖互不相同且无种子的子 agent、准确有界交接、完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,组装后应用由无密钥重放快照与构建后二进制测试固定。
+六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、适配器范围的命令发现与转录隔离。已发布的无密钥快照覆盖通过无头应用创建/检查模型目标、通过 ACP 执行多 Round 同会话生命周期与取消、无需模型 Turn 的直接 `/goal` 状态,以及通过无头应用执行两个真实 Ralph Round。Ralph 快照会启动工作线程引擎、spawn provider、结构化输出运行时与 agent loop,随后检查互不相同且无种子的子日志和准确单向有界交接,同时固定父级事件流。聚焦的真实栈测试还覆盖完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,构建后二进制测试覆盖已安装产物解析。实现经验已记录进根测试策略:每项非平凡的模型或人类可见变更都必须在同一 PR 中携带真实示例无密钥快照,而不能依赖包级或 echo-agent 覆盖。
## 考虑过的替代方案
From a93e9b86e7d51cac88ea10fa15f9378e49ecf7dc Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 18:42:06 +0800
Subject: [PATCH 10/14] docs(testing): require real-example snapshots
---
AGENTS.md | 4 ++--
docs/testing.md | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 87f46c88a0..803ac5888f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -119,9 +119,9 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
- **Every non-trivial change MUST include at least one Agent Note in the same PR.** Update the owning note or add one, validate its premises against code, and exempt only mechanical/local edits ([scope](.agents/notes/README.md#when-to-write-one)).
-- **Testing policy** — [docs/testing.md](docs/testing.md). Transcript changes need snapshots or a PR note. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
+- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and echo-agent fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
-- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces, and schedule any missing harness support before implementation.
+- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
- Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it.
diff --git a/docs/testing.md b/docs/testing.md
index 84629aae45..6672bfc6f3 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -35,4 +35,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword
## When a snapshot test is required
-Any change affecting an editor-facing transcript, headless event stream, or end-to-end agent UX adds or updates a scenario in the owning snapshot suite, or states in the PR why none applies. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise.
+Every non-trivial model- or human-visible change adds or updates a keyless scenario through a real runnable example in the owning snapshot suite in the same PR. Package tests, e2e-only assertions, test-only or echo-agent compositions, and a PR rationale do not substitute for the assembled application transcript; when the current harness cannot express the behavior, extending that harness is part of the change. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it before implementation.
From 60926e32577d1fc0fe9357cdde04f2fef03379d6 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 19:46:17 +0800
Subject: [PATCH 11/14] test(ralph): await child readiness event
---
.../workflow/tool-ralph/tests/integration.spec.ts | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts
index 836f023b41..e2c704eaa3 100644
--- a/packages/workflow/tool-ralph/tests/integration.spec.ts
+++ b/packages/workflow/tool-ralph/tests/integration.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -234,13 +234,18 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
await parentHandle.dispose()
})
- it('cancels the real worker and fresh child to quiescence', async () => {
+ it('cancels the real worker and fresh child to quiescence', { timeout: 20_000 }, async () => {
const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 })
const children: Agent[] = []
const outcomes: string[] = []
+ let resolveChildStarted!: (child: Agent) => void
+ const childStarted = new Promise((resolve) => { resolveChildStarted = resolve })
ctx.on('workflow/agent-start', (_run, child) => {
const agent = ctx.agents.get(child.childId)
- if (agent !== undefined) children.push(agent)
+ if (agent !== undefined) {
+ children.push(agent)
+ resolveChildStarted(agent)
+ }
})
ctx.on('workflow/agent-end', (_run, child) => { outcomes.push(child.outcome) })
const controller = new AbortController()
@@ -251,7 +256,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
agent: parent,
signal: controller.signal,
})
- await vi.waitFor(() => { expect(children).toHaveLength(1) })
+ await childStarted
controller.abort()
const result = await pending
From 7094608c5049ac3f1c96b67e3afd1dec04b738d1 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 22:30:52 +0800
Subject: [PATCH 12/14] Update goal RFC rollup for current app surfaces
---
.../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++--
.../implemented/feature/2026-07-16-harness-level-loop.md | 6 +++---
.../implemented/feature/2026-07-16-harness-level-loop.zh.md | 6 +++---
AGENTS.md | 2 +-
docs/testing.md | 2 +-
5 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
index 19c946a6f6..99565bc83c 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
+++ b/.agents/notes/implemented/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: f9f501d365d368cffdbc0f909db48f9d6ce926b5
-2026-07-16-harness-level-loop.zh.md: 1d09841e74530fbe4c9d86b7a481a95494890afe
+2026-07-16-harness-level-loop.md: 1b99dc8720239dea5cfaf5711595ac0f9962ab46
+2026-07-16-harness-level-loop.zh.md: 33db26f63b733046b29eb36ccfbe068b1cf01124
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
index f9f501d365..1b99dc8720 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
@@ -70,7 +70,7 @@ The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dis
The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Direct-human provenance is enforced in code; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective.
-TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. Line-oriented stdio does not consume the command plane; its ordinary human text can still authorize model goal tools when that stack is mounted.
+TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC front doors do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed.
### Fresh-agent Ralph execution
@@ -94,7 +94,7 @@ External products are comparators, not compatibility targets. The local source s
### Verification
-The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, adapter-wide command discovery, and transcript isolation. Shipped keyless snapshots cover model goal creation/inspection through the headless app, multi-round same-session lifecycle and cancellation through ACP, direct `/goal` status without a model turn, and two real Ralph rounds through the headless app. The Ralph snapshot boots the worker-thread engine, spawn provider, structured-output runtime, and agent loop, then inspects distinct unseeded child logs and exact one-way bounded handoff while pinning the parent stream. Focused real-stack tests additionally cover completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and built-binary tests cover installed-artifact resolution. The implementation experience is recorded in the root testing policy: every non-trivial model- or human-visible change must carry a real-example keyless snapshot in the same PR rather than relying on package or echo-agent coverage.
+The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, adapter-wide command discovery, and transcript isolation. Shipped keyless snapshots cover model goal creation/inspection through the headless app, multi-round same-session lifecycle and cancellation through ACP, direct `/goal` status without a model turn, and two real Ralph rounds through the headless app. The Ralph snapshot boots the worker-thread engine, spawn provider, structured-output runtime, and agent loop, then inspects distinct unseeded child logs and exact one-way bounded handoff while pinning the parent stream. Focused real-stack tests additionally cover completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and built-binary tests cover installed-artifact resolution. The implementation experience is recorded in the root testing policy: every non-trivial model- or human-visible change must carry a real-example keyless snapshot in the same PR rather than relying on package-only or mock-only fixture coverage.
## Alternatives considered
@@ -126,4 +126,4 @@ The six owning Agent Notes record unit, integration, process, snapshot, cancella
- **No goal reflector** — concern events, automatic no-progress heuristics, goal revision by an independent reflector, stuck-pattern detection, and `loop_split` are not implemented. Humans can edit, pause, clear, or resume the goal directly.
- **Ralph policy remains narrow** — one round creates one fresh child; within-round fan-out, evaluator/worker role separation, dynamic provider/model selection, and structural recursive-Ralph tool denial need separate policy surfaces. Prompt guidance is not enforcement.
- **Ralph does not retry a failed child** — an ordinary failure preserves the failed round and last good handoff, while fatal workflow infrastructure failures can end before that state is available. Retry count, backoff, and richer failure transport need separate policy and seam design.
-- **Portable UI remains modest** — TUI and ACP render plain-text goal status and generic Ralph cards. There is no continuous status widget, reconnectable command output, modal goal editor, or command plane in stdio/JSON-RPC.
+- **Portable UI remains modest** — TUI and ACP render plain-text goal status and generic Ralph cards. There is no continuous status widget, reconnectable command output, modal goal editor, or command plane in the headless CLI or JSON-RPC front doors.
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
index 1d09841e74..33db26f63b 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
@@ -70,7 +70,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for
模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。
-TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。行式 stdio 不消费命令平面;挂载目标栈后,它的普通人类文本仍可授权模型目标工具。
+TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI 与 JSON-RPC 前端不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。
### 全新 agent Ralph 执行
@@ -94,7 +94,7 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天
### 验证
-六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、适配器范围的命令发现与转录隔离。已发布的无密钥快照覆盖通过无头应用创建/检查模型目标、通过 ACP 执行多 Round 同会话生命周期与取消、无需模型 Turn 的直接 `/goal` 状态,以及通过无头应用执行两个真实 Ralph Round。Ralph 快照会启动工作线程引擎、spawn provider、结构化输出运行时与 agent loop,随后检查互不相同且无种子的子日志和准确单向有界交接,同时固定父级事件流。聚焦的真实栈测试还覆盖完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,构建后二进制测试覆盖已安装产物解析。实现经验已记录进根测试策略:每项非平凡的模型或人类可见变更都必须在同一 PR 中携带真实示例无密钥快照,而不能依赖包级或 echo-agent 覆盖。
+六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、适配器范围的命令发现与转录隔离。已发布的无密钥快照覆盖通过无头应用创建/检查模型目标、通过 ACP 执行多 Round 同会话生命周期与取消、无需模型 Turn 的直接 `/goal` 状态,以及通过无头应用执行两个真实 Ralph Round。Ralph 快照会启动工作线程引擎、spawn provider、结构化输出运行时与 agent loop,随后检查互不相同且无种子的子日志和准确单向有界交接,同时固定父级事件流。聚焦的真实栈测试还覆盖完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,构建后二进制测试覆盖已安装产物解析。实现经验已记录进根测试策略:每项非平凡的模型或人类可见变更都必须在同一 PR 中携带真实示例无密钥快照,而不能依赖仅包级或仅模拟夹具的覆盖。
## 考虑过的替代方案
@@ -126,4 +126,4 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天
- **没有目标反思器**——concern 事件、自动无进展启发式、由独立反思器执行的目标修订、卡住模式检测与 `loop_split` 均未实现。人类可以直接编辑、暂停、清除或恢复目标。
- **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与结构化递归 Ralph 工具禁止都需要独立策略表面。提示词指导不是强制执行。
- **Ralph 不会重试失败的子 agent**——普通失败会保留失败 Round 与上一份有效交接,而致命工作流基础设施错误可能在该状态可用前结束。重试次数、退避与更丰富的失败传输需要独立的策略与接缝设计。
-- **可移植 UI 仍较朴素**——TUI 与 ACP 渲染纯文本目标状态和通用 Ralph 卡片。系统没有持续状态组件、可重连命令输出、模态目标编辑器,也没有 stdio/JSON-RPC 命令平面。
+- **可移植 UI 仍较朴素**——TUI 与 ACP 渲染纯文本目标状态和通用 Ralph 卡片。系统没有持续状态组件、可重连命令输出、模态目标编辑器,无头 CLI 与 JSON-RPC 前端也没有命令平面。
diff --git a/AGENTS.md b/AGENTS.md
index 5f1ee04096..6a0e4ca13b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -112,7 +112,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
- **Every non-trivial change MUST include at least one Agent Note in the same PR.** Update the owning note or add one, validate its premises against code, and exempt only mechanical/local edits ([scope](.agents/notes/README.md#when-to-write-one)).
-- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and echo-agent fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
+- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).
diff --git a/docs/testing.md b/docs/testing.md
index 4a112c2d6e..9ac79922c8 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -35,4 +35,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword
## When a snapshot test is required
-Every non-trivial model- or human-visible change adds or updates a keyless scenario through a real runnable example in the owning snapshot suite in the same PR. Package tests, e2e-only assertions, test-only or echo-agent compositions, and a PR rationale do not substitute for the assembled application transcript; when the current harness cannot express the behavior, extending that harness is part of the change. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it before implementation.
+Every non-trivial model- or human-visible change adds or updates a keyless scenario through a real runnable example in the owning snapshot suite in the same PR. Package tests, e2e-only assertions, test-only or mock-only compositions, and a PR rationale do not substitute for the assembled application transcript; when the current harness cannot express the behavior, extending that harness is part of the change. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it before implementation.
From 578df3f986f6af1a91c74d264419e2391509654f Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 23:20:44 +0800
Subject: [PATCH 13/14] Clarify goal rounds around request recovery
---
.../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++--
.../implemented/feature/2026-07-16-harness-level-loop.md | 2 +-
.../implemented/feature/2026-07-16-harness-level-loop.zh.md | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
index 99565bc83c..1a8c03cb52 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
+++ b/.agents/notes/implemented/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: 1b99dc8720239dea5cfaf5711595ac0f9962ab46
-2026-07-16-harness-level-loop.zh.md: 33db26f63b733046b29eb36ccfbe068b1cf01124
+2026-07-16-harness-level-loop.md: 9a9511b9dcea1b5fdc90f4fc716c4399f2346967
+2026-07-16-harness-level-loop.zh.md: 284e73051eaaa4633b9f56367de9096dadc8184e
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
index 1b99dc8720..9a9511b9dc 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
@@ -62,7 +62,7 @@ The goal-round driver owns at most one pending reservation per exact live agent.
Only the durable goal-sourced `user/message` charges a round. Stale reservations become rejected zero-step turns without consuming the cap. A concurrent goal revision wins over settlement from an older round.
-Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. The driver never invents an automatic retry after an abnormal outcome. A human can later authorize resume through ordinary language or `/goal resume`.
+Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting or quota exhaustion blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. An independently composed request-recovery plugin may retry transient provider failures within that same turn; the goal driver never invents another round after an abnormal terminal outcome. A human can later authorize resume through ordinary language or `/goal resume`.
### Human and model surfaces
diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
index 33db26f63b..284e73051e 100644
--- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
@@ -62,7 +62,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for
只有持久的目标来源 `user/message` 会计入一个 Round。过时预留会成为未消耗上限的零 Step 拒绝 Turn。并发目标修订会胜过旧 Round 的结算。
-普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;Turn 错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。驱动器绝不会在异常结果后凭空发起自动重试。人类随后可以通过普通语言或 `/goal resume` 授权恢复。
+普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制或配额耗尽以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;Turn 错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。独立组合的请求恢复插件可以在同一个 Turn 内重试暂时性 provider 失败;目标驱动器绝不会在异常终止结果后凭空发起另一个 Round。人类随后可以通过普通语言或 `/goal resume` 授权恢复。
### 人类与模型表面
From b850e69068fd14e8352d4614281c7aff7640257e Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 23:24:43 +0800
Subject: [PATCH 14/14] Condense snapshot testing policy
---
docs/testing.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/testing.md b/docs/testing.md
index 9ddf7930e3..85601ab6c9 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -37,4 +37,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword
## When a snapshot test is required
-Every non-trivial model- or human-visible change adds or updates a keyless scenario through a real runnable example in the owning snapshot suite in the same PR. Package tests, e2e-only assertions, test-only or mock-only compositions, and a PR rationale do not substitute for the assembled application transcript; when the current harness cannot express the behavior, extending that harness is part of the change. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it before implementation.
+Every non-trivial model- or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation.