From 3a61c4c568ee9fca0f65ec5a6868b13a9ab43783 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 29 Jul 2026 14:49:45 +0800 Subject: [PATCH] feat(todo): make the parallel in_progress policy configurable Whether concurrent active tasks are legitimate depends on runtime concurrency the tool cannot observe, but whether a deployment's agents ever fan out is knowable at composition time. `allowParallelInProgress` (default true) therefore replaces the hardcoded policy: the flag moves the model-facing instruction and the accepted input together, so a deployment running strictly sequential agents can restore the single-active discipline from cordis.yml. The durable-log invariant does not follow the flag. 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. --- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 20 ++- ...2026-07-26-todo-parallel-in-progress.zh.md | 20 ++- docs/config-catalog.md | 21 ++- packages/todo/tool-todo/README.i18n.yaml | 4 +- packages/todo/tool-todo/README.md | 10 +- packages/todo/tool-todo/README.zh.md | 10 +- packages/todo/tool-todo/package.json | 1 + packages/todo/tool-todo/src/index.ts | 95 ++++++++++--- packages/todo/tool-todo/src/invariant.ts | 10 +- .../tests/loader-composition.spec.ts | 126 ++++++++++++++++++ .../todo/tool-todo/tests/tool-todo.spec.ts | 60 ++++++++- pnpm-lock.yaml | 3 + 13 files changed, 339 insertions(+), 45 deletions(-) create mode 100644 packages/todo/tool-todo/tests/loader-composition.spec.ts 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 5148b112be..1813126dee 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: 61bfe4bdf03441fa2369683060efcbc35493a95f -2026-07-26-todo-parallel-in-progress.zh.md: e4098cd0c3151867c788b15b3226cff866f6c23d +2026-07-26-todo-parallel-in-progress.md: 24803da46f12f4fd6e8d62f493ea097dcd099718 +2026-07-26-todo-parallel-in-progress.zh.md: d44492dbfdbe986985fa17cc7b9b975dc1ddcd9c 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 61bfe4bdf0..24803da46f 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,11 +10,10 @@ The [original `todo_write` design](2026-06-29-todo-write-tool.md) enforced at mo ## Decision -Remove the single-`in_progress` cap everywhere it was enforced and let any number of tasks be `in_progress`: +Make the single-`in_progress` cap a deployment policy instead of a fixed rule, defaulting to allowing several: -- `execute` in `packages/todo/tool-todo/src/index.ts` no longer counts `in_progress` items; the `at most one task may be in_progress` error is gone from the tool's stable failure set. -- The durable-log invariant in `packages/todo/tool-todo/src/invariant.ts` no longer rejects snapshots with several active items, so previously-persisted logs are unaffected and parallel snapshots replay cleanly. -- The tool description now instructs the model to mark every actively-worked task `in_progress` — several during parallel work, one for sequential work — and to keep at least one while work remains. +- `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. +- 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. @@ -22,10 +21,19 @@ The remaining coded invariants are unchanged: non-empty trimmed unique `content` A coded invariant can only see the list, not the runtime: whether two `in_progress` items are legitimate depends on whether work is actually running concurrently, which the tool cannot observe. Enforcing a cap was therefore wrong in exactly the cases parallelism made it matter, and any replacement (for example, capping active items at the live subagent count) would couple the tool to runtimes it deliberately knows nothing about. The discipline of matching `in_progress` marks to genuinely concurrent work moves to the tool description, the same place ordering and list freshness already live. +## 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. + +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. + +The durable-log invariant deliberately does NOT follow the flag. A log written while parallel work was allowed must still replay after a deployment tightens the policy, so tying `invariant.ts` to the current config would reject history that was valid when it was written. The invariant stays silent on the active count; the tool is where the policy applies, at the moment of the write. + ## Alternatives considered - **Keep the cap and add an explicit parallel opt-in flag** — an extra argument on every call to serve the common case; the flag would be noise for sequential work and still unverifiable. -- **Cap active items at a configured maximum** — any fixed number is arbitrary, and a deployment-varying tunable for list coherence has no principled value. +- **Cap active items at a configured maximum** — any fixed number is arbitrary. This is why the config field is a boolean policy switch and not a count: "may several tasks be active" is a property of the deployment, while "at most N" invents a threshold nothing can justify. +- **Hardcode the parallel policy** — the first revision of this branch did, which is what made `allowParallelInProgress` necessary: a deployment running strictly sequential agents had no way back to the discipline it wanted. ## The display surfaces are part of the change @@ -39,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. The tool no longer rejects a formerly-invalid snapshot shape, so the change is compatible with every previously valid call; only the error path was removed. 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. 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. 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 e4098cd0c3..d44492dbfd 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,11 +10,10 @@ Status: implemented ## 决策 -在所有强制它的位置移除单一 `in_progress` 上限,允许任意数量的任务处于 `in_progress`: +把单一 `in_progress` 上限从固定规则改为部署策略,默认允许多个: -- `packages/todo/tool-todo/src/index.ts` 中的 `execute` 不再统计 `in_progress` 条目;`at most one task may be in_progress` 错误已从工具稳定的失败集合中移除。 -- `packages/todo/tool-todo/src/invariant.ts` 中的持久日志不变式不再拒绝含多个活跃条目的快照,因此此前持久化的日志不受影响,并行快照也能干净回放。 -- 工具描述现在指示模型把每个正在处理的任务标记为 `in_progress`(并行工作时可以有多个,顺序工作时只有一个),并在仍有工作未完成时至少保留一个。 +- `packages/todo/tool-todo/src/index.ts` 新增 `Config.allowParallelInProgress`(默认 `true`)。为 `true` 时,`execute` 接受任意数量的活跃条目,描述指示模型把每个正在处理的任务标记为 `in_progress`(并行工作时可以有多个,顺序工作时只有一个),并在仍有工作未完成时至少保留一个;为 `false` 时,描述要求恰好一个,`execute` 拒绝标记更多的调用。 +- `packages/todo/tool-todo/src/invariant.ts` 中的持久日志不变式不再拒绝含多个活跃条目的快照,且不跟随该配置,因此此前持久化的日志不受影响,并行快照在任一策略下都能干净回放。 其余编码的不变式保持不变:`content` 去除首尾空白后非空且唯一、status 为合法枚举值。本决定取代[原始设计的校验决策](2026-06-29-todo-write-tool.md)中「至多一个活跃」的条款;该 Agent Note 的其余部分(整列表替换、日志支撑的状态、单一所有者)依然成立。 @@ -22,10 +21,19 @@ Status: implemented 编码的不变式只能看到列表,看不到运行时:两个 `in_progress` 条目是否合理,取决于工作是否真的在并发运行,而这一点工具无法观测。因此,恰恰在并行让上限变得重要的场景里,强制上限反而是错的;任何替代方案(例如把活跃条目数限制为在线 subagent 的数量)都会把工具耦合到它有意一无所知的运行时上。把 `in_progress` 标记与真正并发的工作对应起来这一纪律,转移到工具描述中,也就是排序与列表新鲜度已经所在的地方。 +## 该策略是部署层的选择 + +并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况——但一个部署的 agent 是否会并发展开工作,在组装期就是可知的。因此该策略是 `Config` 字段而非常量:`allowParallelInProgress`(默认 `true`)从 cordis.yml 设置,agent 从不并行展开的部署可以恢复单活跃项纪律。 + +该开关会同时改变面向模型的指令与接受的输入。把两者拆开才是 bug:描述要求只保留一个活跃任务、而 `execute` 却接受多个,等于教给模型一条工具并不遵守的规则;反过来则会拒绝描述所邀请的调用。描述中只有活跃状态那一句会变化,因为这是该策略唯一改变的指令。 + +持久日志不变式刻意**不**跟随该开关。在允许并行时写下的日志,在部署收紧策略之后仍必须可回放,因此把 `invariant.ts` 绑定到当前配置会拒绝在写入当时合法的历史。不变式对活跃数量保持沉默;策略生效之处是工具,时机是写入的那一刻。 + ## 曾考虑的替代方案 - **保留上限并增加一个显式的并行 opt-in 标志**——为服务常见场景而给每次调用增加一个额外参数;这个标志对顺序工作而言只是噪声,而且仍然无法验证。 -- **把活跃条目限制在一个可配置的上限内**——任何固定数字都是任意的,而为列表连贯性设一个随部署变化的可调参数没有原则性价值。 +- **把活跃条目限制在一个可配置的上限内**——任何固定数字都是任意的。这正是该配置字段是布尔策略开关而非数量的原因:「是否允许多个任务同时活跃」是部署的属性,而「最多 N 个」凭空发明了一个无从论证的阈值。 +- **把并行策略硬编码**——本分支的第一版就是如此,这也是 `allowParallelInProgress` 之所以必要的原因:运行严格顺序 agent 的部署没有任何办法回到它想要的纪律。 ## 展示面是本次改动的一部分 @@ -39,4 +47,4 @@ Status: implemented ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。工具不再拒绝一种此前无效的快照形状,因此该改动兼容此前所有合法的调用;被移除的只是错误路径。面向模型的描述发生了变化,这重新记录了 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: 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/docs/config-catalog.md b/docs/config-catalog.md index 68343a6a19..c125fc0c6c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1796,6 +1796,26 @@ export interface Config { Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +## `@deepseek-ai/dsh-tool-todo` + +Requires: `tools` + +```ts config-catalog +/** 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. + */ + allowParallelInProgress?: boolean +} +``` + +Source: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` Requires: `tools` · `web` · `systemPrompt` @@ -2236,7 +2256,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) -- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 0ea298d49b..d5e73ebb17 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: 48d221b696dc692eeee072ce4a47b845b5fac9c7 -README.zh.md: c64727f66dffaf670b4f997718e76b769c3de299 +README.md: a9191fe4f5654bcfe64090625d5e6cc22b0934bc +README.zh.md: 2d4309c643deedda5fb02d4380c4ad548d0d605d diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 48d221b696..a9191fe4f5 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -14,9 +14,15 @@ Registers one tool, `todo_write(todos: [{ content, status }])`, on `ctx.tools`. The list belongs to the ONE agent session that called the tool. There is no subagent/shared/swarm scope: a non-agent caller (no `exec.agent`) has nowhere to write the list and is rejected. This is a deliberate scope limit — see the Agent Note. +## 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. + +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. Any number of tasks may be `in_progress` at once — 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): 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. ## Rendering @@ -50,7 +56,7 @@ Prefix-stable while the definition and visibility are unchanged. Plugin lifecycl #### What the model sees -Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: pending, in progress, completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content ""`, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message. +Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: pending, in progress, completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content ""`, `Error: todo_write requires an owning agent session`, and — only where the deployment set `allowParallelInProgress: false` — `Error: invalid todos: at most one task may be in_progress (got )`. The full `todo/write` session event is UI and replay state, not a second model message. #### Token effect diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index c64727f66d..2d4309c643 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -14,9 +14,15 @@ 该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note。 +## 配置 + +`allowParallelInProgress`(默认 `true`)决定是否允许多个 todo 同时处于 `in_progress`。这是部署层的选择而非固定规则:并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况,因此 agent 从不并行展开的部署可以从 cordis.yml 恢复单活跃项纪律。 + +该开关会同时改变面向模型的指令与接受的输入——`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` 由部署决定(见 § 配置):默认允许多个,因为并行工作(并发 subagent、后台命令)确实会同时推进多个任务。顺序与保持列表最新的纪律由模型根据工具描述负责。 ## 渲染 @@ -50,7 +56,7 @@ #### 模型所见内容 -每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 +每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""`、`Error: todo_write requires an owning agent session`,以及——仅在部署设置了 `allowParallelInProgress: false` 时——`Error: invalid todos: at most one task may be in_progress (got )`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 #### Token 影响 diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 85bd18ea8b..6248de95fc 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -32,6 +32,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "schemastery": "^3.18.0", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index a5dbc1ea38..34ce7848ab 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -6,7 +6,8 @@ */ import type { Context } from 'cordis' -import { z } from 'zod' +import z from 'schemastery' +import { z as zod } from 'zod' import type { ZodType } from 'zod' import { defineTool } from '@deepseek-ai/dsh-tools' import type { TodoItem } from '@deepseek-ai/dsh-session' @@ -24,31 +25,73 @@ export const inject = ['tools'] /** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */ const STATUSES = ['pending', 'in_progress', 'completed'] as const -const DESCRIPTION = +/** 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. + */ + allowParallelInProgress?: boolean +} + +/** Schemastery configuration for the todo tool consumer. */ +export const Config: z = z.object({ + allowParallelInProgress: z.boolean().default(true), +}) + +const DESCRIPTION_HEAD = 'Record and update a structured task list for the current work. Send the ENTIRE ' + 'list every call — it REPLACES the previous list (there are no partial updates, ' + 'no per-item edits). Use it to plan multi-step work and show progress: add one ' - + 'todo per concrete step before you start. Mark every todo being actively worked ' + + 'todo per concrete step before you start. ' + +const DESCRIPTION_PARALLEL = + 'Mark every todo being actively worked ' + 'on `in_progress` — several at once when work genuinely runs in parallel (e.g. ' + 'concurrent subagents or background commands), one for sequential work; while ' - + 'work remains, at least one task should be `in_progress`. Mark a todo ' + + 'work remains, at least one task should be `in_progress`. ' + +const DESCRIPTION_SINGLE = + 'Keep AT MOST ONE todo `in_progress` at a ' + + 'time; while work remains, exactly one active task should be `in_progress`. ' + +const DESCRIPTION_TAIL = + 'Mark a todo ' + '`completed` the moment it is done (do not batch completions), and allow no ' + '`in_progress` item only once all work is complete. Skip the list for trivial ' + 'single-step tasks. Statuses: `pending` (not started), `in_progress` (being ' + 'worked on now), `completed` (finished).' /** - * Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link - * TodoItem}[]: trimmed non-empty unique content. Any number of items may be in_progress — - * parallel work (subagents, background commands) legitimately runs several tasks at once. The - * registry has already enforced the status enum and rejected unknown item keys - * (`additionalProperties: false` — the logged snapshot must equal what the model believes it - * wrote, so a nested/extended item shape fails loud at the schema boundary instead of silently - * flattening); the cast below records that guarantee. + * The model-facing description for one activation. The active-status clause is the only part that + * varies, because it is the only instruction the parallel policy changes. + * @param allowParallel - whether several todos may be `in_progress` at once. + * @returns the composed tool description. */ -function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { +function describe(allowParallel: boolean): string { + return DESCRIPTION_HEAD + + (allowParallel ? DESCRIPTION_PARALLEL : DESCRIPTION_SINGLE) + + DESCRIPTION_TAIL +} + +/** + * Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link + * TodoItem}[]: trimmed non-empty unique content, and at most one `in_progress` item unless the + * deployment allows parallel work. The registry has already enforced the status enum and rejected + * unknown item keys (`additionalProperties: false` — the logged snapshot must equal what the model + * believes it wrote, so a nested/extended item shape fails loud at the schema boundary instead of + * silently flattening); the cast below records that guarantee. + * @param raw - the model-supplied list, already schema-checked. + * @param allowParallel - whether several items may be `in_progress` at once. + * @returns the canonical list. + */ +function toTodoList(raw: { content: string; status: string }[], allowParallel: boolean): TodoItem[] { const todos: TodoItem[] = [] const seen = new Set() + let active = 0 for (const item of raw) { const content = item.content.trim() if (content.length === 0) { @@ -58,22 +101,32 @@ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { throw new Error(`invalid todos: duplicate content ${JSON.stringify(content)}`) } seen.add(content) + if (item.status === 'in_progress') active++ todos.push({ content, status: item.status as TodoItem['status'] }) } + if (!allowParallel && active > 1) { + throw new Error(`invalid todos: at most one task may be in_progress (got ${active})`) + } return todos } /** Wire payload schema of the `todos` projection (whole list or pre-first-write null). */ -const todosProjectionSchema: ZodType = z.union([ - z.array(z.object({ - content: z.string(), - status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]), +const todosProjectionSchema: ZodType = zod.union([ + zod.array(zod.object({ + content: zod.string(), + status: zod.union([zod.literal('pending'), zod.literal('in_progress'), zod.literal('completed')]), })), - z.null(), + zod.null(), ]) -/** Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, the `todos` unit. */ -export function apply(ctx: Context): void { +/** + * 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. + */ +export function apply(ctx: Context, config: Config = {}): void { + const allowParallel = config.allowParallelInProgress ?? true // 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 @@ -96,7 +149,7 @@ export function apply(ctx: Context): void { }) ctx.tools.register(defineTool({ name: 'todo_write', - description: DESCRIPTION, + description: describe(allowParallel), parameters: { todos: { type: 'array', @@ -152,7 +205,7 @@ export function apply(ctx: Context): void { }], }, execute(args, exec) { - const todos = toTodoList(args.todos) + const todos = toTodoList(args.todos, allowParallel) if (!exec.agent) { // The list is per-agent-session state; a non-agent caller (no owning // session) has nowhere to write it. Reject rather than silently no-op. diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 0fef0b1cce..0cfe48a6f2 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -12,7 +12,15 @@ export const name = 'tool-todo-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate one whole-list todo snapshot before it reaches the durable log. */ +/** + * Validate one whole-list todo snapshot before it reaches the durable log. + * + * Deliberately silent on how many items are `in_progress`. That is the tool's + * per-deployment policy (`Config.allowParallelInProgress`), not a durable-shape + * rule: a log written while parallel work was allowed must still replay after a + * deployment tightens the policy, so tying the invariant to the current config + * would reject history that was valid when it was written. + */ function validateTodos(value: unknown, fail: InvariantFailure): void { if (!Array.isArray(value)) fail('todo/write todos must be an array') const seen = new Set() diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..a41c2bc3e2 --- /dev/null +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -0,0 +1,126 @@ +// Proves `allowParallelInProgress` is real configurability and not a constant: +// the flag is set in a cordis.yml booted through the real Loader, and both faces +// it controls — the model-facing description and the accepted input — follow it. +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +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 Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +function agent(ctx: Context): Agent { + const scope = ctx.plugin(() => {}) + const id = SessionId('todo-loader-agent') + const value: Agent = { + id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function resultText(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +/** + * Boot a cordis.yml carrying the given tool-todo config block. + * @param configLines - YAML lines nested under the tool's `config:` key. + * @returns the booted context. + */ +async function boot(configLines: readonly string[]): Promise { + root = await mkdtemp(join(tmpdir(), 'dsh-todo-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-tool-todo'", + ...configLines.length > 0 ? [' config:', ...configLines] : [], + '', + ].join('\n')) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-tool-todo', ToolTodo], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await ctx.loader.await() + context = ctx + return ctx +} + +const PARALLEL_TODOS = [ + { content: 'run subagent a', status: 'in_progress' }, + { content: 'run subagent b', status: 'in_progress' }, +] + +describe('tool-todo real Loader composition through cordis.yml', () => { + it('allowParallelInProgress: false narrows the description and rejects a parallel write', async () => { + const ctx = await boot([' allowParallelInProgress: false']) + const description = ctx.tools.schemas().find(s => s.name === 'todo_write')?.description ?? '' + expect(description).toContain('Keep AT MOST ONE todo `in_progress`') + expect(description).not.toContain('several at once') + + const owner = agent(ctx) + const result = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('parallel'), + name: 'todo_write', + arguments: { todos: PARALLEL_TODOS }, + agent: owner, + }) + expect(result.isError).toBe(true) + expect(resultText(result)).toContain('at most one task may be in_progress') + 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([]) + 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'), + name: 'todo_write', + arguments: { todos: PARALLEL_TODOS }, + agent: owner, + }) + expect(result.isError).toBe(false) + expect(owner.session.events.findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) + }, 30_000) +}) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 4a5f4ce913..ec592ea8a2 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(): Promise { +async function setup(config: tool.Config = {}): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(tool) + await ctx.plugin(tool, config) return ctx } @@ -140,6 +140,62 @@ describe('dsh-tool-todo', () => { expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) }) + describe('allowParallelInProgress: false', () => { + const parallel = [ + { content: 'run subagent a', status: 'in_progress' }, + { content: 'run subagent b', status: 'in_progress' }, + ] + + it('rejects a call marking several items in_progress', async () => { + const ctx = await setup({ allowParallelInProgress: false }) + const agent = agentWithSession('single-active') + const result = await callTodo(ctx, { todos: parallel }, { agent }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('at most one task may be in_progress') + // A rejected call must not reach the durable log. + expect(agent.session.events.some(e => e.type === 'todo/write')).toBe(false) + }) + + it('still accepts one active item', async () => { + const ctx = await setup({ allowParallelInProgress: false }) + const todos: TodoItem[] = [ + { content: 'run subagent a', status: 'in_progress' }, + { content: 'run subagent b', status: 'pending' }, + ] + const result = await callTodo(ctx, { todos }) + expect(result.isError).toBe(false) + }) + + it('an explicit true accepts a parallel write, like the omitted default', async () => { + const ctx = await setup({ allowParallelInProgress: 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 }) + 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 + expect(parallelDesc).toContain('several at once when work genuinely runs in parallel') + expect(parallelDesc).not.toContain('AT MOST ONE') + }) + }) + it.each([ { label: 'empty content', todos: [{ content: ' ', status: 'pending' }], fragment: 'non-empty' }, { label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18cb1ec0d1..a5d7b1c9ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4727,6 +4727,9 @@ importers: packages/todo/tool-todo: dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 zod: specifier: ^4.4.3 version: 4.4.3