diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 1813126dee..bc033f76b3 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 24803da46f12f4fd6e8d62f493ea097dcd099718 -2026-07-26-todo-parallel-in-progress.zh.md: d44492dbfdbe986985fa17cc7b9b975dc1ddcd9c +2026-07-26-todo-parallel-in-progress.md: a023338704337c5ad2a76a5eaf7ac64a3e949e52 +2026-07-26-todo-parallel-in-progress.zh.md: 163cec20811fcfe756dbf294d152597dade69af6 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 24803da46f..a023338704 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -10,9 +10,9 @@ The [original `todo_write` design](2026-06-29-todo-write-tool.md) enforced at mo ## Decision -Make the single-`in_progress` cap a deployment policy instead of a fixed rule, defaulting to allowing several: +Make the single-`in_progress` cap a deployment policy instead of a fixed rule, requiring every composition to choose: -- `packages/todo/tool-todo/src/index.ts` gains `Config.allowParallelInProgress` (default `true`). At `true`, `execute` accepts any number of active items and the description instructs the model to mark every actively-worked task — several during parallel work, one for sequential work — keeping at least one while work remains. At `false`, the description asks for exactly one and `execute` rejects a call marking more. +- `packages/todo/tool-todo/src/index.ts` gains the required `Config.allowParallelInProgress` field. At `true`, `execute` accepts any number of active items and the description instructs the model to mark every actively-worked task — several during parallel work, one for sequential work — keeping at least one while work remains. At `false`, the description asks for exactly one and `execute` rejects a call marking more. - The durable-log invariant in `packages/todo/tool-todo/src/invariant.ts` no longer rejects snapshots with several active items, and does not follow the config, so previously-persisted logs are unaffected and parallel snapshots replay cleanly under either policy. The remaining coded invariants are unchanged: non-empty trimmed unique `content`, valid status enum. This supersedes the "at most one active" clause of the [original design's validation decision](2026-06-29-todo-write-tool.md); the rest of that Agent Note (whole-list replace, log-backed state, single owner) stands. @@ -23,7 +23,7 @@ A coded invariant can only see the list, not the runtime: whether two `in_progre ## The policy is a deployment choice -Whether concurrent active tasks are legitimate depends on runtime concurrency the tool cannot observe — but whether a deployment's agents ever run work concurrently is knowable at composition time. That makes the policy a `Config` field rather than a constant: `allowParallelInProgress` (default `true`) is set from cordis.yml, and a deployment whose agents never fan out can restore the single-active discipline. +Whether concurrent active tasks are legitimate depends on runtime concurrency the tool cannot observe — but whether a deployment's agents ever run work concurrently is knowable at composition time. That makes the policy a required `Config` field rather than a constant or default: every cordis.yml composition sets `allowParallelInProgress` deliberately, choosing `true` for agents that may fan out work or `false` for the single-active discipline. The flag moves the model-facing instruction and the accepted input together. Splitting them would be the bug: a description asking for one active task while `execute` accepts several teaches the model a rule the tool does not hold, and the reverse rejects calls the description invited. Only the active-status clause of the description varies, because that is the only instruction the policy changes. @@ -47,4 +47,4 @@ Splitting the count into its own span puts it outside the `.summary` rule, so it ## Consequences -A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. Under the default policy the tool no longer rejects a formerly-invalid snapshot shape, so the change is compatible with every previously valid call; a deployment that sets `allowParallelInProgress: false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every `tool-schemas.expected.json` sidecar carrying the todo schema (seven of the eight in the tree). Scenarios composing an identical header share one sidecar through `toolSchemasSource` rather than each keeping a copy, so the count tracks distinct header compositions, not scenarios; a branch changing the tool description still has to refresh whichever sidecars landed after it branched — `pnpm run test:snapshot:refresh` does it keylessly. The web fixture's todo sample now runs two items `in_progress`, so the assembled web transcript replays a parallel plan and would fail again if either surface returned to single-active derivation. +A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every `tool-schemas.expected.json` sidecar carrying the todo schema (seven of the eight in the tree). Scenarios composing an identical header share one sidecar through `toolSchemasSource` rather than each keeping a copy, so the count tracks distinct header compositions, not scenarios; a branch changing the tool description still has to refresh whichever sidecars landed after it branched — `pnpm run test:snapshot:refresh` does it keylessly. The web fixture's todo sample now runs two items `in_progress`, so the assembled web transcript replays a parallel plan and would fail again if either surface returned to single-active derivation. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index d44492dbfd..163cec2081 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -把单一 `in_progress` 上限从固定规则改为部署策略,默认允许多个: +把单一 `in_progress` 上限从固定规则改为部署策略,并要求每个组合都作出选择: -- `packages/todo/tool-todo/src/index.ts` 新增 `Config.allowParallelInProgress`(默认 `true`)。为 `true` 时,`execute` 接受任意数量的活跃条目,描述指示模型把每个正在处理的任务标记为 `in_progress`(并行工作时可以有多个,顺序工作时只有一个),并在仍有工作未完成时至少保留一个;为 `false` 时,描述要求恰好一个,`execute` 拒绝标记更多的调用。 +- `packages/todo/tool-todo/src/index.ts` 新增必填的 `Config.allowParallelInProgress` 字段。为 `true` 时,`execute` 接受任意数量的活跃条目,描述指示模型把每个正在处理的任务标记为 `in_progress`(并行工作时可以有多个,顺序工作时只有一个),并在仍有工作未完成时至少保留一个;为 `false` 时,描述要求恰好一个,`execute` 拒绝标记更多的调用。 - `packages/todo/tool-todo/src/invariant.ts` 中的持久日志不变式不再拒绝含多个活跃条目的快照,且不跟随该配置,因此此前持久化的日志不受影响,并行快照在任一策略下都能干净回放。 其余编码的不变式保持不变:`content` 去除首尾空白后非空且唯一、status 为合法枚举值。本决定取代[原始设计的校验决策](2026-06-29-todo-write-tool.md)中「至多一个活跃」的条款;该 Agent Note 的其余部分(整列表替换、日志支撑的状态、单一所有者)依然成立。 @@ -23,7 +23,7 @@ Status: implemented ## 该策略是部署层的选择 -并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况——但一个部署的 agent 是否会并发展开工作,在组装期就是可知的。因此该策略是 `Config` 字段而非常量:`allowParallelInProgress`(默认 `true`)从 cordis.yml 设置,agent 从不并行展开的部署可以恢复单活跃项纪律。 +并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况——但一个部署的 agent 是否会并发展开工作,在组装期就是可知的。因此该策略是必填的 `Config` 字段,而非常量或默认值:每个 cordis.yml 组合都会有意设置 `allowParallelInProgress`,为可能并行展开工作的 agent 选择 `true`,或为单活跃项纪律选择 `false`。 该开关会同时改变面向模型的指令与接受的输入。把两者拆开才是 bug:描述要求只保留一个活跃任务、而 `execute` 却接受多个,等于教给模型一条工具并不遵守的规则;反过来则会拒绝描述所邀请的调用。描述中只有活跃状态那一句会变化,因为这是该策略唯一改变的指令。 @@ -47,4 +47,4 @@ Status: implemented ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。在默认策略下,工具不再拒绝一种此前无效的快照形状,因此该改动兼容此前所有合法的调用;设置了 `allowParallelInProgress: false` 的部署仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的 `tool-schemas.expected.json` sidecar(树中八个里有七个)。组合出相同 header 的场景通过 `toolSchemasSource` 共用同一份 sidecar,而非各自保留副本,因此这个数量对应的是不同的 header 组合,而不是场景数;改动工具描述的分支仍须刷新它分叉之后落地的那些 sidecar —— `pnpm run test:snapshot:refresh` 可以无 key 完成。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此组装后的 web transcript 回放的是一个并行计划;若任一展示面退回单活跃项推导,它会再次失败。 +现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的 `tool-schemas.expected.json` sidecar(树中八个里有七个)。组合出相同 header 的场景通过 `toolSchemasSource` 共用同一份 sidecar,而非各自保留副本,因此这个数量对应的是不同的 header 组合,而不是场景数;改动工具描述的分支仍须刷新它分叉之后落地的那些 sidecar —— `pnpm run test:snapshot:refresh` 可以无 key 完成。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此组装后的 web transcript 回放的是一个并行计划;若任一展示面退回单活跃项推导,它会再次失败。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index fed929a466..e39cd5a2db 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -138,6 +138,8 @@ - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true - id: tool-tasks name: '@deepseek-ai/dsh-tool-tasks' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c125fc0c6c..29a4871b12 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1804,13 +1804,13 @@ Requires: `tools` /** Model-facing todo tool configuration. */ export interface Config { /** - * Whether several todos may be `in_progress` at once (default true). True suits a deployment - * whose agents run work concurrently — subagents, background commands, workflow fan-out — and - * the description then instructs the model to mark every actively worked task. False restores - * the single-active discipline: the description asks for exactly one, and a call marking more - * is rejected. + * Required deployment choice for whether several todos may be `in_progress` at once. True suits + * agents that run work concurrently — subagents, background commands, workflow fan-out — and the + * description then instructs the model to mark every actively worked task. False restores the + * single-active discipline: the description asks for exactly one, and a call marking more is + * rejected. */ - allowParallelInProgress?: boolean + allowParallelInProgress: boolean } ``` diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index f784972429..1dc331005d 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -127,6 +127,8 @@ # `todo_write` replaces the logged whole list for later model requests. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true # Identical repeat calls trigger advisory context, never a block, at the default # thresholds [3, 5, 8]. Only the repeat-tool-guard snapshot scenario reaches them. diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 896c73469b..faf3be2869 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -106,6 +106,8 @@ # `todo_write` replaces the logged whole list. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true # Policy loads before the model-facing filesystem tools so writes and edits # require an observed file. Relative paths resolve from the process cwd. diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index 63174eac2c..833def3973 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -64,7 +64,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) - await ctx.plugin(ToolTodo) + await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) // Compaction is opt-in: only the compaction e2e loads the reusable meter and backend. if (options.compact !== undefined) { await ctx.plugin(TokenMeterService) diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index b23dd30b4a..4934cfcd41 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -65,6 +65,8 @@ - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true - id: fs-local name: '@deepseek-ai/dsh-fs-local' diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml index 3d29530411..5727b4ae06 100644 --- a/examples/tui-agent/README.i18n.yaml +++ b/examples/tui-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/tui-agent/README.md -README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 -README.zh.md: b3f6dc18536b159379eac7433367ccf2cd8fcc53 +README.md: 8399a78d86167f13e2e211f28749d282c5b6ec99 +README.zh.md: e409ad2c36d67a9c965017b917797050125e78de diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index ea8695d37e..8399a78d86 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -17,7 +17,7 @@ Both the demo script and the installable `dsh` CLI ([`apps/cli`](../../apps/cli/ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork`. -The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. +The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) and set its required `allowParallelInProgress` policy to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md index b3f6dc1853..e409ad2c36 100644 --- a/examples/tui-agent/README.zh.md +++ b/examples/tui-agent/README.zh.md @@ -17,7 +17,7 @@ pnpm run demo:tui 输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次操作都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。fs 工具和 bash 都会根据会话 workspace 解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。 -`todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 +`todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖),并设置其必填的 `allowParallelInProgress` 策略,以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index af7c027d6d..a592907813 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -230,7 +230,7 @@ async function mountScenarioContext( await ctx.plugin(TuiPromptService) // todo_write is opt-in: only the todo-plan scenario mounts it, matching the shipped // config that omits it. The other scenarios prove the default todo-free composition. - if (scenario.enableTodo === true) await ctx.plugin(ToolTodo) + if (scenario.enableTodo === true) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false }) diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 27ba0aa581..c850812b5b 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -125,7 +125,12 @@ config: id: 'default', label: 'todo_write tool', default: true, - resources: [{ kind: 'npm-cordis-config-entry', id: 'tool-todo', package: '@deepseek-ai/dsh-tool-todo' }], + resources: [{ + kind: 'npm-cordis-config-entry', + id: 'tool-todo', + package: '@deepseek-ai/dsh-tool-todo', + config: { allowParallelInProgress: true }, + }], }], }, { diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index d5e73ebb17..c3a8545993 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md -README.md: a9191fe4f5654bcfe64090625d5e6cc22b0934bc -README.zh.md: 2d4309c643deedda5fb02d4380c4ad548d0d605d +README.md: 76ba6a089e92635618f70747e6ee54260d4c8fbc +README.zh.md: 5c5b84f5730d5dd815d429b7ee6c12fc11554115 diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index a9191fe4f5..76ba6a089e 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -16,13 +16,13 @@ The list belongs to the ONE agent session that called the tool. There is no suba ## Configuration -`allowParallelInProgress` (default `true`) decides whether several todos may be `in_progress` at once. It is a deployment choice, not a fixed rule: whether concurrent active tasks are legitimate depends on runtime concurrency the tool cannot observe, so a deployment whose agents never fan out can restore the single-active discipline from cordis.yml. +`allowParallelInProgress` is required: every composition must choose whether several todos may be `in_progress` at once. It is a deployment choice, not a fixed rule: whether concurrent active tasks are legitimate depends on runtime concurrency the tool cannot observe. Use `true` for agents that may fan out work and `false` to enforce the single-active discipline. The flag moves the model-facing instruction and the accepted input together — `true` asks the model to mark every actively worked task and accepts any number, `false` asks for exactly one and rejects a call marking more with `Error: invalid todos: at most one task may be in_progress (got )`. The durable-log invariant does NOT follow it: a log written while parallel work was allowed must still replay after a deployment tightens the policy, so the invariant stays silent on the active count. ## Validation -Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`, and any item key beyond `content`/`status` — an extended item shape (ids, nesting) fails loud instead of silently flattening, keeping the logged snapshot equal to what the model believes it wrote. How many tasks may be `in_progress` at once is the deployment's call (§ Configuration): the default allows several, because parallel work (concurrent subagents, background commands) legitimately runs several tasks simultaneously. Ordering and the discipline of keeping the list current are left to the model via the tool description. +Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`, and any item key beyond `content`/`status` — an extended item shape (ids, nesting) fails loud instead of silently flattening, keeping the logged snapshot equal to what the model believes it wrote. How many tasks may be `in_progress` at once is the deployment's call (§ Configuration): a composition that chooses `true` permits parallel work (concurrent subagents, background commands) to mark several tasks simultaneously. Ordering and the discipline of keeping the list current are left to the model via the tool description. ## Rendering diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 2d4309c643..5c5b84f573 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -16,13 +16,13 @@ ## 配置 -`allowParallelInProgress`(默认 `true`)决定是否允许多个 todo 同时处于 `in_progress`。这是部署层的选择而非固定规则:并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况,因此 agent 从不并行展开的部署可以从 cordis.yml 恢复单活跃项纪律。 +`allowParallelInProgress` 是必填项:每个组合都必须选择是否允许多个 todo 同时处于 `in_progress`。这是部署层的选择而非固定规则:并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况。可能并行展开工作的 agent 使用 `true`,`false` 则强制执行单活跃项纪律。 该开关会同时改变面向模型的指令与接受的输入——`true` 要求模型标记每个正在推进的任务并接受任意数量;`false` 要求恰好一个,并以 `Error: invalid todos: at most one task may be in_progress (got )` 拒绝标记更多的调用。持久日志不变式**不**跟随它:在允许并行时写下的日志,在部署收紧策略之后仍必须可回放,因此不变式对活跃数量保持沉默。 ## 验证 -除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`,以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会响亮失败而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。同时可以有多少任务处于 `in_progress` 由部署决定(见 § 配置):默认允许多个,因为并行工作(并发 subagent、后台命令)确实会同时推进多个任务。顺序与保持列表最新的纪律由模型根据工具描述负责。 +除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`,以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会响亮失败而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。同时可以有多少任务处于 `in_progress` 由部署决定(见 § 配置):选择 `true` 的组合允许并行工作(并发 subagent、后台命令)同时将多个任务标记为 `in_progress`。顺序与保持列表最新的纪律由模型根据工具描述负责。 ## 渲染 diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 34ce7848ab..0e403c5510 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -28,18 +28,18 @@ const STATUSES = ['pending', 'in_progress', 'completed'] as const /** Model-facing todo tool configuration. */ export interface Config { /** - * Whether several todos may be `in_progress` at once (default true). True suits a deployment - * whose agents run work concurrently — subagents, background commands, workflow fan-out — and - * the description then instructs the model to mark every actively worked task. False restores - * the single-active discipline: the description asks for exactly one, and a call marking more - * is rejected. + * Required deployment choice for whether several todos may be `in_progress` at once. True suits + * agents that run work concurrently — subagents, background commands, workflow fan-out — and the + * description then instructs the model to mark every actively worked task. False restores the + * single-active discipline: the description asks for exactly one, and a call marking more is + * rejected. */ - allowParallelInProgress?: boolean + allowParallelInProgress: boolean } /** Schemastery configuration for the todo tool consumer. */ export const Config: z = z.object({ - allowParallelInProgress: z.boolean().default(true), + allowParallelInProgress: z.boolean().required(), }) const DESCRIPTION_HEAD = @@ -123,10 +123,10 @@ const todosProjectionSchema: ZodType = zod.union([ * Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, * the `todos` unit. * @param ctx - registrant context carrying the tool registry. - * @param config - deployment's todo policy; defaults to allowing parallel active items. + * @param config - deployment's explicit todo policy. */ -export function apply(ctx: Context, config: Config = {}): void { - const allowParallel = config.allowParallelInProgress ?? true +export function apply(ctx: Context, config: Config): void { + const allowParallel = config.allowParallelInProgress // The unit child activates only when a projection registry is composed // (headless assemblies without the seam stay unaffected). Standing-plan fold: // latest whole todo/write list, cleared by the next turn/start (turn/end keeps diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index aff2958de3..b2a76fb562 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -18,7 +18,7 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(ToolTodo) + await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index a41c2bc3e2..7a3e329cb4 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context, FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' @@ -60,6 +60,7 @@ async function boot(configLines: readonly string[]): Promise { ].join('\n')) const ctx = new Context() + context = ctx ctx.baseUrl = pathToFileURL(root).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include @@ -78,7 +79,6 @@ async function boot(configLines: readonly string[]): Promise { } as unknown as NonNullable await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await ctx.loader.await() - context = ctx return ctx } @@ -107,15 +107,15 @@ describe('tool-todo real Loader composition through cordis.yml', () => { expect(owner.session.events.some(e => e.type === 'todo/write')).toBe(false) }, 30_000) - it('the omitted default keeps the parallel policy end to end', async () => { - const ctx = await boot([]) + it('allowParallelInProgress: true permits a parallel write end to end', async () => { + const ctx = await boot([' allowParallelInProgress: true']) const description = ctx.tools.schemas().find(s => s.name === 'todo_write')?.description ?? '' expect(description).toContain('several at once when work genuinely runs in parallel') const owner = agent(ctx) const result = await ctx.tools.execute({ signal: new AbortController().signal, - callId: CallId('parallel-default'), + callId: CallId('parallel-enabled'), name: 'todo_write', arguments: { todos: PARALLEL_TODOS }, agent: owner, @@ -123,4 +123,23 @@ describe('tool-todo real Loader composition through cordis.yml', () => { expect(result.isError).toBe(false) expect(owner.session.events.findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) }, 30_000) + + it('fails loading when allowParallelInProgress is omitted', async () => { + // loader.await() is all-settled; configuration failure leaves a FAILED + // entry and escapes as a late rejection for the host boot to report. + const rejections: unknown[] = [] + const onUnhandled = (err: unknown): void => { rejections.push(err) } + process.on('unhandledRejection', onUnhandled) + try { + const ctx = await boot([]) + const entry = [...ctx.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-tool-todo') + expect(entry?.fiber?.state).toBe(FiberState.FAILED) + for (let i = 0; i < 100 && rejections.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(rejections.map(String).join('\n')).toContain('$.allowParallelInProgress missing required value') + } finally { + process.off('unhandledRejection', onUnhandled) + } + }, 30_000) }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index ede937801d..2f160aa430 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -42,7 +42,7 @@ async function harness(withTodoTool: boolean): Promise { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - if (withTodoTool) await ctx.plugin(ToolTodo) + if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) @@ -116,7 +116,7 @@ describe('todos projection provider', () => { it('drops the key when the tool-todo fiber unloads (HMR safety)', async () => { const bench = await harness(false) seedMessage(bench.session) - const fiber = await bench.ctx.plugin(ToolTodo) + const fiber = await bench.ctx.plugin(ToolTodo, { allowParallelInProgress: true }) expect((await bench.tailProjections())?.values).toEqual({ todos: null }) await fiber.dispose() expect('todos' in ((await bench.tailProjections())?.values ?? {})).toBe(false) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index ec592ea8a2..99a5821c02 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -26,11 +26,11 @@ function agentWithSession(id = 'parent-1'): Agent & { session: Session } { return { id: SessionId(id), session } as unknown as Agent & { session: Session } } -async function setup(config: tool.Config = {}): Promise { +async function setup(allowParallelInProgress: boolean): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(tool, config) + await ctx.plugin(tool, { allowParallelInProgress }) return ctx } @@ -52,7 +52,7 @@ function text(result: { content: { type: string; text?: string }[] }): string { describe('dsh-tool-todo', () => { it('registers a `todo_write` tool whose schema is an array of {content,status}', async () => { - const ctx = await setup() + const ctx = await setup(true) const schema = ctx.tools.schemas().find(s => s.name === 'todo_write') expect(schema).toBeDefined() const props = (schema!.parameters as { properties?: Record }).properties ?? {} @@ -65,7 +65,7 @@ describe('dsh-tool-todo', () => { }) it('appends a todo/write event carrying the whole list to the calling session', async () => { - const ctx = await setup() + const ctx = await setup(true) const agent = agentWithSession('writer') const todos: TodoItem[] = [ { content: 'plan', status: 'in_progress' }, @@ -85,7 +85,7 @@ describe('dsh-tool-todo', () => { }) it('stores the trimmed content (the dedupe/length key), not the raw input', async () => { - const ctx = await setup() + const ctx = await setup(true) const agent = agentWithSession('trim') const result = await callTodo(ctx, { todos: [{ content: ' plan the work ', status: 'pending' }] }, { agent }) expect(result.isError).toBe(false) @@ -95,7 +95,7 @@ describe('dsh-tool-todo', () => { }) it('replaces the list on a second call (last-write-wins on the log)', async () => { - const ctx = await setup() + const ctx = await setup(true) const agent = agentWithSession('writer-2') await callTodo(ctx, { todos: [{ content: 'a', status: 'pending' }] }, { agent }) await callTodo(ctx, { todos: [ @@ -111,19 +111,19 @@ describe('dsh-tool-todo', () => { }) it('rejects a malformed status before execute runs (registry arg-validation)', async () => { - const ctx = await setup() + const ctx = await setup(true) const result = await callTodo(ctx, { todos: [{ content: 'x', status: 'doing' }] }) expect(result.isError).toBe(true) }) it('rejects a non-array todos argument', async () => { - const ctx = await setup() + const ctx = await setup(true) const result = await callTodo(ctx, { todos: 'nope' }) expect(result.isError).toBe(true) }) it('accepts several in_progress items at once (parallel work)', async () => { - const ctx = await setup() + const ctx = await setup(true) const agent = agentWithSession('parallel') const todos: TodoItem[] = [ { content: 'run subagent a', status: 'in_progress' }, @@ -147,7 +147,7 @@ describe('dsh-tool-todo', () => { ] it('rejects a call marking several items in_progress', async () => { - const ctx = await setup({ allowParallelInProgress: false }) + const ctx = await setup(false) const agent = agentWithSession('single-active') const result = await callTodo(ctx, { todos: parallel }, { agent }) expect(result.isError).toBe(true) @@ -157,7 +157,7 @@ describe('dsh-tool-todo', () => { }) it('still accepts one active item', async () => { - const ctx = await setup({ allowParallelInProgress: false }) + const ctx = await setup(false) const todos: TodoItem[] = [ { content: 'run subagent a', status: 'in_progress' }, { content: 'run subagent b', status: 'pending' }, @@ -166,31 +166,19 @@ describe('dsh-tool-todo', () => { expect(result.isError).toBe(false) }) - it('an explicit true accepts a parallel write, like the omitted default', async () => { - const ctx = await setup({ allowParallelInProgress: true }) + it('an explicit true accepts a parallel write', async () => { + const ctx = await setup(true) const result = await callTodo(ctx, { todos: parallel }) expect(result.isError).toBe(false) }) - it('defaults to parallel for a direct apply, which bypasses the schema default', async () => { - // Composing through ctx.plugin lets schemastery fill the field; a caller - // invoking apply() itself hands over a config object with it absent, so - // the policy default has to hold on that path too. - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - tool.apply(ctx, {}) - const result = await callTodo(ctx, { todos: parallel }) - expect(result.isError).toBe(false) - }) - - it('instructs the model to keep at most one active, and the default instructs parallel', async () => { - const single = await setup({ allowParallelInProgress: false }) + it('instructs the model to keep at most one active, while true instructs parallel', async () => { + const single = await setup(false) const singleDesc = single.tools.schemas().find(s => s.name === 'todo_write')!.description expect(singleDesc).toContain('Keep AT MOST ONE todo `in_progress`') expect(singleDesc).not.toContain('several at once') - const parallelDesc = (await setup()).tools.schemas().find(s => s.name === 'todo_write')!.description + const parallelDesc = (await setup(true)).tools.schemas().find(s => s.name === 'todo_write')!.description expect(parallelDesc).toContain('several at once when work genuinely runs in parallel') expect(parallelDesc).not.toContain('AT MOST ONE') }) @@ -201,21 +189,21 @@ describe('dsh-tool-todo', () => { { label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' }, { label: 'unknown item keys', todos: [{ content: 'a', status: 'pending', children: [] }], fragment: 'not a declared property' }, ])('rejects $label as an isError result', async ({ todos, fragment }) => { - const ctx = await setup() + const ctx = await setup(true) const result = await callTodo(ctx, { todos }) expect(result.isError).toBe(true) expect(text(result)).toContain(fragment) }) it('rejects a non-agent caller (the list has no owning session)', async () => { - const ctx = await setup() + const ctx = await setup(true) const result = await callTodo(ctx, { todos: [{ content: 'a', status: 'pending' }] }, { agent: undefined }) expect(result.isError).toBe(true) expect(text(result)).toContain('owning agent session') }) it('presents the call with a stable title and the list as raw input', async () => { - const ctx = await setup() + const ctx = await setup(true) const def = ctx.tools.get('todo_write')! const todos = [{ content: 'a', status: 'pending' }] expect(def.presentCall?.({ todos })).toEqual({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: todos }) @@ -225,7 +213,7 @@ describe('dsh-tool-todo', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - const fiber = await ctx.plugin(tool) + const fiber = await ctx.plugin(tool, { allowParallelInProgress: true }) expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(true) await fiber.dispose() expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(false) diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 07f9b170ce..498f0336cc 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -38,6 +38,8 @@ _CORDIS_YML = """\ cwd: '.' - id: todo name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true """ diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 7bdc8b68a8..11a5972cf1 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -370,7 +370,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'owning Agent session'], writes: ['tool/call', 'todo/write', 'tool/result'], async mount(ctx) { - await ctx.plugin(ToolTodo) + await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) }, note: 'todo_write is session-owned state; UIs render the latest todo/write event as a checklist.',