Merge remote-tracking branch 'origin/master' into codex/pr239-parent-retarget-publish
# Conflicts: # packages/core/tools/tests/gen-tool-catalog.spec.ts
This commit is contained in:
@@ -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: c2db4d7dd30c27a25adecdfc425db261cc3dfeb5
|
||||
2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: e33e9848d71c98c8f83494ebe8bf171ef10b9305
|
||||
@@ -0,0 +1,74 @@
|
||||
# Agent Note: 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, 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` 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. 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, 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 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. 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
|
||||
|
||||
- **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 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Agent Note: 全新 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 改变。
|
||||
|
||||
### 每次运行的工作流子 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` 和 `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 消息。发布的无头、TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明 provider 路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。
|
||||
|
||||
一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、只产生一个阶段事件、终止完成以及两个子 agent 都被处置。同一真实栈还覆盖阻塞与轮次上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后达到子 agent 静止状态。一项已发布的无密钥无头快照还会启动真实的 `examples/headless-agent` 组合、调用 `ralph`、固定父级流式转录,并检查持久化日志中存在两个不同且无种子的子会话,且第一轮交接只出现在第二轮。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **把 Ralph 放进同会话目标驱动器** — 拒绝,因为目标轮次有意保留同一段对话,而 Ralph 的定义性属性是每轮使用全新上下文;合并两者会让目标生命周期与子 agent 编排无法分离。
|
||||
- **在通用工作流工具上暴露 `fresh` 或循环标志** — 拒绝,因为模型编写的脚本表面应保持通用且与 provider 无关;Ralph 的固定报告协议和停止策略值得拥有一个可评审消费者。
|
||||
- **为了方便重放而使用 `subagent_fork`** — 拒绝,因为继承的已完成回合是隐式、不断增长的交接状态,并违反全新上下文契约。工作区加一份结构化报告即可重放,无需插入人为取消记录。
|
||||
- **让工具直接调用 subagent 接缝** — 拒绝,因为现有工作流引擎已经拥有前台编排、结构化子 agent、取消传播、工作线程终止、事件和静止处置。复用它可以展示插件组合,而不是构建第二个循环运行时。
|
||||
- **静默截断大型报告** — 拒绝,因为截断可能删除状态证据或下一步,却仍看似权威交接。生产者必须在配置边界内发出有效报告。
|
||||
|
||||
## 后果
|
||||
|
||||
- 全新 agent 迭代成为一项一等模型工具,并完全以现有接缝之上的可移除插件实现。
|
||||
- 目标轮次与 Ralph 轮次保持不同概念:前者是一次同会话续行回合,后者是前台工作流中的一个全新子 agent。
|
||||
- 工作区成为权威跨轮记忆,因此工作者必须检查和验证工作区,而不能信任叙事性交接。
|
||||
- 宽裕的轮次上限允许大量自治工作,而部署配置仍会限制子 agent 数量,并且每次交接始终受大小约束。
|
||||
- provider 路由与可降低的每次运行子 agent 上限成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。
|
||||
|
||||
## 已知限制与推迟工作
|
||||
|
||||
- 完成与阻塞状态由工作者自行声明。独立 evaluator、evaluator 驱动的反馈轮次、完成证书或对抗式 verifier 被有意推迟。
|
||||
- 运行位于前台且只存在于进程内。后台收集、持久化/恢复、调度和重启恢复均不存在。
|
||||
- 轮次数是唯一聚合预算。token、货币、耗时和 provider 用量预算仍属于未来的独立策略。
|
||||
- 每轮创建一个子 agent。轮内扇出、evaluator/工作者角色分离、动态 provider 或模型选择,以及跨运行日志均被推迟。
|
||||
- 普通子 agent 失败会结束运行且不重试,同时保留失败轮次与上一份成功交接。致命工作流基础设施错误可能在固定脚本返回该状态前结束;增加重试或更丰富的失败传输需要独立的策略与接缝设计。
|
||||
- 提示指导模型不要递归调用 Ralph;结构化的子 agent 工具限制需要另行设计工作流子策略表面。
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -91,6 +91,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<br/>Background task registry"]
|
||||
pkg_tool_tasks["tool-tasks"]
|
||||
@@ -202,6 +203,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
|
||||
@@ -226,6 +228,7 @@ flowchart LR
|
||||
svc_userInteraction --> pkg_tool_ask_user
|
||||
svc_userInteraction --> pkg_tui
|
||||
svc_web --> pkg_tool_web
|
||||
svc_workflows --> pkg_tool_ralph
|
||||
svc_workflows --> pkg_tool_workflow
|
||||
svc_fs -. event gate .-> pkg_fs_policy
|
||||
```
|
||||
@@ -256,10 +259,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), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; 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.
|
||||
@@ -1168,6 +1168,26 @@ 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
|
||||
/** Maximum characters in a successful parent-facing terminal text (default 16384). */
|
||||
maxResultChars?: 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`
|
||||
|
||||
@@ -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` 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
|
||||
/**
|
||||
@@ -26,6 +26,17 @@ 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
|
||||
/**
|
||||
* 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`). */
|
||||
|
||||
+7
-1
@@ -32,4 +32,10 @@ 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. <a id="turn"></a>
|
||||
- **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. <a id="step"></a>
|
||||
- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round). Round counters belong to that policy and do not count every turn in a session. <a id="round"></a>
|
||||
- **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. <a id="round"></a>
|
||||
|
||||
## 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. <a id="ralph-loop"></a>
|
||||
- **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. <a id="ralph-round"></a>
|
||||
- **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. <a id="ralph-handoff"></a>
|
||||
@@ -160,6 +160,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"]
|
||||
@@ -465,6 +466,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
|
||||
@@ -606,6 +613,7 @@ flowchart TD
|
||||
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`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), [`llm-retry`](../packages/llm/llm-retry), [`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) |
|
||||
|
||||
@@ -23,6 +23,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 conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then 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/tui-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()`. |
|
||||
@@ -512,6 +513,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 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
|
||||
{
|
||||
"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`
|
||||
|
||||
@@ -51,6 +51,8 @@ flowchart LR
|
||||
cfg --> plugin_acp_workflow_workerthread
|
||||
plugin_acp_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
|
||||
cfg --> plugin_acp_tool_workflow
|
||||
plugin_acp_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
|
||||
cfg --> plugin_acp_tool_ralph
|
||||
plugin_acp_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
|
||||
cfg --> plugin_acp_tool_todo
|
||||
plugin_acp_repeat_tool_guard["repeat-tool-guard<br/>@deepseek-ai/dsh-repeat-tool-guard"]
|
||||
@@ -87,6 +89,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` |
|
||||
| `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` |
|
||||
|
||||
@@ -126,6 +126,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'
|
||||
|
||||
@@ -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. 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
|
||||
|
||||
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:
|
||||
@@ -114,6 +116,13 @@ declare const tools: {
|
||||
}): Promise<string>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal(args: Record<string, unknown>): Promise<string>;
|
||||
/** 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;
|
||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||
maxRounds?: number;
|
||||
}): Promise<string>;
|
||||
/** Read a UTF-8 text file and return line-numbered content. */
|
||||
read(args: {
|
||||
/** Path to read, resolved by the filesystem backend. */
|
||||
|
||||
@@ -233,6 +233,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 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": {
|
||||
"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.",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}
|
||||
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-b68f88be045a/c7f3c42f90e6-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-23878e7780c4/a791f05ad303-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
|
||||
@@ -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. 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
|
||||
|
||||
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:
|
||||
@@ -97,6 +99,13 @@ declare const tools: {
|
||||
}): Promise<string>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal(args: Record<string, unknown>): Promise<string>;
|
||||
/** 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;
|
||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||
maxRounds?: number;
|
||||
}): Promise<string>;
|
||||
/** Read a UTF-8 text file and return line-numbered content. */
|
||||
read(args: {
|
||||
/** Path to read, resolved by the filesystem backend. */
|
||||
|
||||
@@ -176,6 +176,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 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": {
|
||||
"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.",
|
||||
|
||||
@@ -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. 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
|
||||
|
||||
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:
|
||||
@@ -97,6 +99,13 @@ declare const tools: {
|
||||
}): Promise<string>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal(args: Record<string, unknown>): Promise<string>;
|
||||
/** 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;
|
||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||
maxRounds?: number;
|
||||
}): Promise<string>;
|
||||
/** Read a UTF-8 text file and return line-numbered content. */
|
||||
read(args: {
|
||||
/** Path to read, resolved by the filesystem backend. */
|
||||
|
||||
+9
@@ -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. 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
|
||||
|
||||
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:
|
||||
@@ -97,6 +99,13 @@ declare const tools: {
|
||||
}): Promise<string>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal(args: Record<string, unknown>): Promise<string>;
|
||||
/** 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;
|
||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||
maxRounds?: number;
|
||||
}): Promise<string>;
|
||||
/** Read a UTF-8 text file and return line-numbered content. */
|
||||
read(args: {
|
||||
/** Path to read, resolved by the filesystem backend. */
|
||||
|
||||
@@ -131,8 +131,8 @@
|
||||
{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
|
||||
{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"e521d703-0e11-4f89-affc-f16094c5a682","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
|
||||
{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"e521d703-0e11-4f89-affc-f16094c5a682","outcome":"allowed-once"}}
|
||||
{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"baab7028-28c7-4cd5-9db3-601246dc0a04","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
|
||||
{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"baab7028-28c7-4cd5-9db3-601246dc0a04","outcome":"allowed-once"}}
|
||||
{"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}}
|
||||
|
||||
@@ -155,8 +155,8 @@
|
||||
{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
|
||||
{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"6cd28331-7453-4060-8d11-c160b5705d7c","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
|
||||
{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"6cd28331-7453-4060-8d11-c160b5705d7c","outcome":"rejected"}}
|
||||
{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"e38e859f-0848-4b11-ad6e-b817d536555e","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
|
||||
{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"e38e859f-0848-4b11-ad6e-b817d536555e","outcome":"rejected"}}
|
||||
{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}}
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
{"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":88,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}
|
||||
{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"6b5b9d09-4ce6-449b-a7a2-0a32d9cf35e3","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}}
|
||||
{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"6b5b9d09-4ce6-449b-a7a2-0a32d9cf35e3","outcome":"allowed-once"}}
|
||||
{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"84dc148b-9b51-4db8-815a-40a27e6f2209","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}}
|
||||
{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"84dc148b-9b51-4db8-815a-40a27e6f2209","outcome":"allowed-once"}}
|
||||
{"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"<path>/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}}
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
{"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}
|
||||
{"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}}
|
||||
{"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}}
|
||||
{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"26d9c129-90fb-4ea9-92b1-c5b75e53bf77","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
|
||||
{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"26d9c129-90fb-4ea9-92b1-c5b75e53bf77","outcome":"rejected"}}
|
||||
{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"5898f287-ec3e-403e-bc88-b6d183b58d16","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
|
||||
{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"5898f287-ec3e-403e-bc88-b6d183b58d16","outcome":"rejected"}}
|
||||
{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}}
|
||||
|
||||
@@ -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. 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.
|
||||
|
||||
<!-- request/header change 1 -->
|
||||
|
||||
You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
@@ -47,3 +49,5 @@ Approval prompts are disabled in this session: actions that require approval are
|
||||
<!-- dsh-user-approval-policy:never -->
|
||||
|
||||
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. 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.
|
||||
@@ -176,6 +176,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 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": {
|
||||
"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.",
|
||||
@@ -684,6 +704,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 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": {
|
||||
"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.",
|
||||
|
||||
@@ -21,6 +21,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. 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.
|
||||
|
||||
<!-- request/header change 1 -->
|
||||
|
||||
You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
@@ -46,3 +48,5 @@ Approval prompts are disabled in this session: actions that require approval are
|
||||
<!-- dsh-user-approval-policy:never -->
|
||||
|
||||
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. 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.
|
||||
@@ -176,6 +176,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 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": {
|
||||
"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.",
|
||||
@@ -684,6 +704,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 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": {
|
||||
"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.",
|
||||
|
||||
@@ -24,6 +24,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. 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.
|
||||
|
||||
<!-- request/header change 1 -->
|
||||
|
||||
You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
@@ -49,3 +51,5 @@ Approval prompts are disabled in this session: actions that require approval are
|
||||
<!-- dsh-user-approval-policy:never -->
|
||||
|
||||
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. 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.
|
||||
@@ -192,6 +192,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 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": {
|
||||
"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.",
|
||||
@@ -700,6 +720,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 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": {
|
||||
"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.",
|
||||
|
||||
@@ -21,3 +21,5 @@ Approval prompts are disabled in this session: actions that require approval are
|
||||
<!-- dsh-user-approval-policy:never -->
|
||||
|
||||
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. 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.
|
||||
@@ -176,6 +176,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 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": {
|
||||
"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.",
|
||||
|
||||
@@ -21,3 +21,5 @@ Approval prompts are disabled in this session: actions that require approval are
|
||||
<!-- dsh-user-approval-policy:never -->
|
||||
|
||||
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. 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.
|
||||
@@ -176,6 +176,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 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": {
|
||||
"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.",
|
||||
|
||||
@@ -21,3 +21,5 @@ Approval prompts are disabled in this session: actions that require approval are
|
||||
<!-- dsh-user-approval-policy:never -->
|
||||
|
||||
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. 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.
|
||||
@@ -176,6 +176,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 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": {
|
||||
"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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ flowchart LR
|
||||
cfg --> plugin_headless_workflow_workerthread
|
||||
plugin_headless_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
|
||||
cfg --> plugin_headless_tool_workflow
|
||||
plugin_headless_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
|
||||
cfg --> plugin_headless_tool_ralph
|
||||
plugin_headless_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
|
||||
cfg --> plugin_headless_tool_todo
|
||||
plugin_headless_fs_local["fs-local<br/>@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` |
|
||||
|
||||
@@ -84,6 +84,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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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,79 @@ 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(parent.header.delegationDepth).toBe(0)
|
||||
expect(children.map(child => child.header.delegationDepth)).toEqual([1, 1])
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"op": "prompt",
|
||||
"text": "Run a two-round fresh-agent Ralph loop to prove the shipped headless integration."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"}}}}
|
||||
@@ -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"}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"/tmp/ralph-headless"}
|
||||
@@ -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}}
|
||||
@@ -47,6 +47,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:*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# tui-agent
|
||||
|
||||
The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows, `todo_write`, timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo).
|
||||
The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, `todo_write`, timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo).
|
||||
|
||||
## Run it
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ flowchart LR
|
||||
cfg --> plugin_tui_workflow_workerthread
|
||||
plugin_tui_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
|
||||
cfg --> plugin_tui_tool_workflow
|
||||
plugin_tui_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
|
||||
cfg --> plugin_tui_tool_ralph
|
||||
plugin_tui_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
|
||||
cfg --> plugin_tui_tool_todo
|
||||
plugin_tui_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
|
||||
@@ -77,6 +79,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` |
|
||||
|
||||
@@ -80,6 +80,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'
|
||||
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ Packages live at `packages/<group>/<pkg>/`; 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 |
|
||||
|
||||
@@ -1828,7 +1828,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 maxTotalAgents?: number;\n parent: Agent;\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WorkflowStopReason',
|
||||
|
||||
@@ -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', 'exit_plan_mode', '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', 'exit_plan_mode', '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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -176,6 +177,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
|
||||
@@ -190,7 +198,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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -168,6 +168,7 @@ async function handlePrompt(id: number | string): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
describe('runScenario', () => {
|
||||
it('surfaces an asynchronous child spawn failure through startup and close', async () => {
|
||||
const { dir } = await scenario({})
|
||||
@@ -310,6 +319,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')
|
||||
|
||||
@@ -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}}')
|
||||
|
||||
@@ -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: [.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md).
|
||||
The general script engine's decisions and deferred work live in the [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). The separate [Ralph consumer](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) fixes the script and fresh-provider policy rather than adding another engine or an agent-loop mode.
|
||||
@@ -0,0 +1,91 @@
|
||||
# @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. The [Ralph Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) owns the policy and deferred work.
|
||||
|
||||
## 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. 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 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
|
||||
|
||||
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. |
|
||||
| `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.
|
||||
|
||||
## 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. 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
|
||||
|
||||
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 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. `maxHandoffChars` bounds cross-round state and `maxResultChars` independently bounds the complete successful parent text; 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.
|
||||
- **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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* 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
|
||||
/** Maximum characters in a successful parent-facing terminal text (default 16384). */
|
||||
maxResultChars?: number
|
||||
}
|
||||
|
||||
/** Schemastery configuration for the Ralph tool. */
|
||||
export const Config: z<Config> = 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'
|
||||
|
||||
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 RalphRoundFailure {
|
||||
readonly status: 'round-failed'
|
||||
readonly roundsStarted: number
|
||||
readonly lastReport?: RalphRoundReport
|
||||
}
|
||||
|
||||
type RalphTerminalResult = RalphRunResult | RalphRoundFailure
|
||||
|
||||
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
|
||||
phase('Fresh-agent rounds')
|
||||
for (let round = 1; round <= args.maxRounds; round += 1) {
|
||||
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 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
|
||||
}
|
||||
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 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. */
|
||||
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')
|
||||
}
|
||||
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')
|
||||
}
|
||||
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. */
|
||||
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<string, unknown> {
|
||||
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): RalphTerminalResult {
|
||||
if (!isRecord(value)
|
||||
|| 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':
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
}
|
||||
}
|
||||
|
||||
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':
|
||||
text = `Ralph worker reported completion after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
|
||||
break
|
||||
case 'blocked':
|
||||
text = `Ralph worker reported a blocker after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
|
||||
break
|
||||
case 'budget-limited':
|
||||
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 {
|
||||
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. 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',
|
||||
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<ContentBlock[]> {
|
||||
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,
|
||||
maxTotalAgents: maxRounds,
|
||||
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)
|
||||
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()
|
||||
}
|
||||
},
|
||||
presentCall,
|
||||
presentResult,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as toolRalph from '../src/index.ts'
|
||||
|
||||
type MockScript = ConstructorParameters<typeof MockAdapter>[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 = {
|
||||
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[] = []
|
||||
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()
|
||||
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 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) {
|
||||
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()
|
||||
})
|
||||
|
||||
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', { 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<Agent>((resolve) => { resolveChildStarted = resolve })
|
||||
ctx.on('workflow/agent-start', (_run, child) => {
|
||||
const agent = ctx.agents.get(child.childId)
|
||||
if (agent !== undefined) {
|
||||
children.push(agent)
|
||||
resolveChildStarted(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 childStarted
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,380 @@
|
||||
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<WorkflowResult>((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<SubagentRun> {
|
||||
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
|
||||
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 }
|
||||
}
|
||||
|
||||
function execute(
|
||||
ctx: Context,
|
||||
args: unknown,
|
||||
extra?: { agent?: Agent; signal?: AbortSignal },
|
||||
): Promise<ToolExecutionResult> {
|
||||
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<ToolExecutionResult>,
|
||||
value: unknown,
|
||||
agentsStarted = 1,
|
||||
): Promise<ToolExecutionResult> {
|
||||
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',
|
||||
maxTotalAgents: 4,
|
||||
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 worker reported completion 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 worker reported a blocker 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; 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 () => {
|
||||
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')
|
||||
expect(() => { toolRalph.apply(new Context(), { maxResultChars: 0 }) }).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, 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(
|
||||
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')
|
||||
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',
|
||||
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<string, unknown>
|
||||
expect(unwrapped).toBe(toolRalph)
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -34,12 +34,12 @@ 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:
|
||||
|
||||
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` 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
|
||||
|
||||
### Child-agent requests
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -144,7 +176,7 @@ class WorkerWorkflowEngine extends WorkflowService {
|
||||
meta,
|
||||
request.parent,
|
||||
init,
|
||||
this.config.provider,
|
||||
subagentProvider,
|
||||
this.config.disposeGraceMs,
|
||||
{
|
||||
phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) },
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
@@ -232,6 +232,97 @@ 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('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' })])"))
|
||||
@@ -239,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 () => {
|
||||
|
||||
@@ -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?, 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?, 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`.
|
||||
|
||||
|
||||
@@ -70,6 +70,17 @@ 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
|
||||
/**
|
||||
* 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`). */
|
||||
|
||||
Generated
+55
@@ -215,6 +215,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
|
||||
@@ -2666,6 +2669,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:
|
||||
|
||||
@@ -288,8 +288,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',
|
||||
@@ -323,8 +323,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.',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -40,6 +40,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, '..')
|
||||
@@ -254,6 +255,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',
|
||||
|
||||
@@ -97,6 +97,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/mode/mode" },
|
||||
{ "path": "./packages/guard/repeat-tool-guard" },
|
||||
|
||||
@@ -111,6 +111,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/mode/mode" },
|
||||
{ "path": "./packages/guard/repeat-tool-guard" },
|
||||
|
||||
Reference in New Issue
Block a user