From 64e0fbfd6d08415200cc3ea0947ee418c5b7bd0e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 12:16:19 +0800 Subject: [PATCH 001/105] fix(subagent): inherit parent policy overrides in continuable children A continuable background child (the default backgroundMode for both delegation tools) never received the parent session's explicit sandbox/approval overrides: materialization applied only child composition, so a danger-full-access parent produced workspace-write children whose every out-of-workspace operation raised an approval prompt. Move the one-shot driver's capture/append pair into the shared child-agent module (captureDelegatedPolicyOverrides / appendDelegatedPolicyOverrides) and call it from both paths: startContinuable captures before its first await, only fresh materialization appends the source-tagged events (after any fork seed), and a cold resume replays the persisted delegation events instead of re-capturing the parent. Adds the continuable inheritance unit suite, the ACP snapshot scenario subagent-continuable-inheritance (fails without the fix), the continuable policy-inheritance Agent Note, and the seam-level README contract, with bilingual counterparts. Fixes #1692 --- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +- .../2026-07-25-subagent-policy-inheritance.md | 4 +- ...26-07-25-subagent-policy-inheritance.zh.md | 4 +- ...able-subagent-policy-inheritance.i18n.yaml | 6 + ...continuable-subagent-policy-inheritance.md | 29 + ...tinuable-subagent-policy-inheritance.zh.md | 29 + docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 8 +- docs/event-producer-consumer.zh.md | 8 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 10 +- docs/subsystems/subagent.zh.md | 10 +- ...ontinuable-inheritance.cordis.snapshot.yml | 46 ++ ...ubagent-continuable-inheritance.cordis.yml | 11 + examples/acp-agent/tests/acp.snapshot.ts | 15 + .../tests/fixtures/parent-sandbox-override.ts | 19 + .../input.json | 19 + .../session.1.jsonl | 21 + .../session.jsonl | 29 + .../stdout.expected.jsonl | 4 + .../tool-schemas.1.expected.json | 543 ++++++++++++++++++ knip.json | 1 + .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 20 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 + packages/subagent/subagent/README.zh.md | 4 + packages/subagent/subagent/package.json | 15 + packages/subagent/subagent/src/child-agent.ts | 61 +- .../subagent/subagent/src/continuation.ts | 30 +- packages/subagent/subagent/src/index.ts | 4 +- .../tests/continuation-inheritance.spec.ts | 171 ++++++ packages/subagent/subagent/tsconfig.json | 9 + pnpm-lock.yaml | 9 + 36 files changed, 1106 insertions(+), 61 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md create mode 100644 examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml create mode 100644 examples/acp-agent/subagent-continuable-inheritance.cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/parent-sandbox-override.ts create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json create mode 100644 packages/subagent/subagent/tests/continuation-inheritance.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 0308005515..616a45e1d1 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: aeff83795eedead9c75de6bbb74c1da1945092ca -2026-07-25-subagent-policy-inheritance.zh.md: c26e6bf8b79c86855022c384673957fe04ff761d +2026-07-25-subagent-policy-inheritance.md: a2f4d578de857ee63df5e7741c433210d5f1ef86 +2026-07-25-subagent-policy-inheritance.zh.md: f069bf290586447afc0b7d46a41ad4de9bfcbe8f diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index aeff83795e..a2f4d578de 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -10,7 +10,7 @@ Sandbox and approval overrides are per-session log folds. An in-process subagent ## Decision -The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. +The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. @@ -33,4 +33,4 @@ A confined child gets the ordinary denial marker. No answerer currently owns an - Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. - The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. -- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. +- Each delegation adds at most two log-only events. `dsh-subagent` and `dsh-subagent-inprocess` have optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index c26e6bf8b7..f069bf2905 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -共享的进程内驱动器在第一次 await 之前对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 +委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 @@ -33,4 +33,4 @@ Status: implemented - spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 - 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 -- 每次委派最多增加两条仅日志事件。`dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 +- 每次委派最多增加两条仅日志事件。`dsh-subagent` 和 `dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml new file mode 100644 index 0000000000..dc23421912 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md +2026-08-10-continuable-subagent-policy-inheritance.md: 39df910a920e6995ba6048fdd2613d2c216f5ec2 +2026-08-10-continuable-subagent-policy-inheritance.zh.md: 2a977eaa9aade189213fdd20a7d888e8e62efb48 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md new file mode 100644 index 0000000000..39df910a92 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md @@ -0,0 +1,29 @@ +# Agent Note: Continuable subagent policy inheritance — the durable child log owns the delegation-time snapshot + +Status: implemented + +English | [中文](2026-08-10-continuable-subagent-policy-inheritance.zh.md) + +## Problem + +The one-shot in-process driver has seeded parent sandbox/approval overrides into its children since the [in-process policy-inheritance decision](2026-07-25-subagent-policy-inheritance.md), but the continuable path never did: `SubagentContinuationManager` materialization applied only child composition and the activation setup registry. The default bundle wires both delegation tools as `backgroundMode: continuable`, so in a default deployment every background child silently fell back to deployment defaults — a parent switched to `danger-full-access` produced children stuck at `workspace-write` whose every out-of-workspace operation raised an approval prompt, and a parent's unattended `'never'` approval stance reverted to prompting ([dsh-external/issues#334](https://github.com/dsh-external/issues/issues/334)). + +## Decision + +The capture/append pair moved from the one-shot driver into the seam's shared child-agent module (`dsh-subagent/src/child-agent.ts`), the declared one home for shared child composition: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` through optional `ctx.get`, and `appendDelegatedPolicyOverrides(childSession, overrides)` appends the `source: 'delegation'` events. The one-shot driver and the continuation manager both call them, so the two paths cannot drift. + +`startContinuable` captures before its first await (`prepareContinuable`), the same "a later parent switch belongs to the parent's future" boundary as one-shot. The snapshot travels in `MaterializeInputs.create`, so only fresh materialization appends the events during unpublished setup, after any fork seed. A cold resume passes no `create` inputs and appends nothing: the persisted child log already carries the delegation events, and replaying the log IS the state. The durable child log — not the current Activation, not the resuming parent — owns the child's effective policy, so a parent switch between residency epochs never retroactively changes a durable child. + +## Alternatives considered + +- **An activation-setup-registry contribution** (`registerContinuableSetup`) — rejected: a contribution receives only the child context, so it cannot capture the parent's overrides at the delegation boundary; the registry applies on cold resume as well as fresh creation, which would re-append or re-capture; and nothing ties a contribution's capture to the start call's synchronous prefix, so the pre-await capture guarantee would be lost. +- **Re-capturing the parent's overrides at cold resume** — rejected: a resumed child would silently change policy with the parent's later switches, breaking the snapshot-at-delegation semantic and making effective policy depend on resume timing instead of the child's own log. A parent that wants a resumed child under new policy re-delegates. +- **Importing the one-shot driver's inline logic from the continuation manager** — rejected: the Service Definition package cannot depend on its own provider package, and duplicating the capture/append pair in `continuation.ts` invites drift; `child-agent.ts` already holds every other shared composition step. +- **Seeding the events into the descriptor seed turn** — rejected: the capture value is not known when the seed is assembled for every caller, and the one-shot precedent already establishes unpublished-setup appends as the ordering that places inherited facts after fork history with `firstLiveSeq` intact. + +## Consequences + +- Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox and approval overrides; compositions without either policy service behave unchanged. +- `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` keeps its optional peers but delegates to the shared helpers. +- The continuable suite (`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`) pins fresh-start seeding, pre-await capture, default omission, cold-resume snapshot stability, and fork-seed precedence; the ACP snapshot scenario `subagent-continuable-inheritance` pins the child's delegation event and read-only runtime context through the assembled app and fails when the capture is removed. +- Out-of-process providers (`acp`, `dsh-sdk`, `claude-code`, `codex`) support no continuable children (`prepareContinuable` absent), and their one-shot children keep their own deployment policy (`inheritsParentContext = false`); cross-process policy propagation remains out of scope. diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md new file mode 100644 index 0000000000..2a977eaa9a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 可继续 subagent 策略继承——持久化子日志拥有委派时快照 + +Status: implemented + +[English](2026-08-10-continuable-subagent-policy-inheritance.md) | 中文 + +## 问题 + +自[进程内策略继承决策](2026-07-25-subagent-policy-inheritance.md)以来,一次性进程内驱动器一直会把父级的沙箱/审批覆盖项注入其子级,但可继续路径从未这样做:`SubagentContinuationManager` 的物化只应用子级组合与 Activation(激活)设置注册表。默认组合包把两个委派工具都配置为 `backgroundMode: continuable`,因此在默认部署中,每个后台子 agent(智能体)都静默回退到部署默认值:切换到 `danger-full-access` 的父级产出的子 agent 卡在 `workspace-write`,每次工作区外操作都会触发审批提示;父级无人值守的 `'never'` 审批立场也退回为发起提示的行为([dsh-external/issues#334](https://github.com/dsh-external/issues/issues/334))。 + +## 决策 + +捕获/追加这对函数从一次性驱动器移入该 seam 的共享子 agent 模块(`dsh-subagent/src/child-agent.ts`),即声明的共享子级组合唯一归属之处:`captureDelegatedPolicyOverrides(parent)` 通过可选的 `ctx.get` 对 `sandboxPolicy.overrideOf(parent.session)` 与 `approval.overrideOf(parent.session)` 建立快照,`appendDelegatedPolicyOverrides(childSession, overrides)` 则追加 `source: 'delegation'` 事件。一次性驱动器与继续执行管理器都调用它们,因此两条路径不会出现偏差。 + +`startContinuable` 在其第一次 await(`prepareContinuable`)之前完成捕获,沿用与一次性路径相同的「父级后续切换属于父级的未来」边界。快照放在 `MaterializeInputs.create` 中传递,因此只有全新物化会在未发布的设置阶段、排在任何 fork 种子之后追加这些事件。冷恢复(cold resume)不传入 `create` 输入,也不追加任何内容:持久化的子日志已经携带委派事件,而回放该日志本身就是状态。子 agent 的生效策略由持久化子日志拥有,而不是当前 Activation,也不是发起恢复的父级,因此父级在驻留纪元(residency epoch)之间的切换绝不会追溯性地改变一个持久化子 agent。 + +## 考虑过的替代方案 + +- **一项 Activation 设置注册表贡献**(`registerContinuableSetup`):不予采纳。贡献只接收子级上下文,因此无法在委派边界捕获父级的覆盖项;该注册表在冷恢复与全新创建时都会应用,会导致重复追加或重复捕获;而且没有任何机制把贡献的捕获绑定到 start 调用的同步前缀,await 前捕获的保证会因此丢失。 +- **在冷恢复时重新捕获父级覆盖项**:不予采纳。恢复的子 agent 会随父级后续切换静默改变策略,这会破坏委派时快照的语义,并让生效策略取决于恢复时机而非子级自身的日志。希望恢复的子 agent 采用新策略的父级应重新委派。 +- **让继续执行管理器导入一次性驱动器的内联逻辑**:不予采纳。Service Definition 包不能依赖自己的提供方包,而在 `continuation.ts` 中复制捕获/追加这对函数会招致偏差;`child-agent.ts` 已经承载其余每个共享组合步骤。 +- **把这些事件写入描述符种子轮次**:不予采纳。种子为每个调用方组装时,捕获值尚不可知;而且一次性路径的先例已经确立:在未发布的设置阶段追加,才是把继承事实排在 fork 历史之后、同时保持 `firstLiveSeq` 不变的顺序。 + +## 后果 + +- 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱与审批覆盖项;未组合任一策略服务的组合保持原有行为。 +- `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 保留自己的可选 peer,但委托给共享辅助函数。 +- 可继续测试套件(`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`)锁定全新启动的种子写入、await 前捕获、默认值省略、冷恢复快照稳定性与 fork 种子优先级;ACP 快照场景 `subagent-continuable-inheritance` 经组装后的应用锁定子级的委派事件与只读运行时上下文,移除捕获时即失败。 +- 进程外提供方(`acp`、`dsh-sdk`、`claude-code`、`codex`)不支持可继续子 agent(没有 `prepareContinuable`),其一次性子 agent 保留自身的部署策略(`inheritsParentContext = false`);跨进程策略传播仍不在范围内。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0a3d6007f1..1f2e85c3d6 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 11eecf81a4eccadf2b97026154a78e4ed8a72164 -event-producer-consumer.zh.md: 2db5e596465b4adaf98c1b05692b61de3ced47b9 +event-producer-consumer.md: e238734189d6553f9008d958bafbf9556ee23bff +event-producer-consumer.zh.md: b74a1ed334919fd6db1187d7f0e07e2b6ddc5221 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 11eecf81a4..e238734189 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,10 +37,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2db5e59646..b74a1ed334 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -39,10 +39,10 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index e98438900d..06b2b87fad 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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 docs/subsystems/subagent.md -subagent.md: 99c54e0696c9c3585c50285ecb69b14c90d83c11 -subagent.zh.md: c5a79247a81f5174662f2b5d1ed2d3c20cf6c672 +subagent.md: b554bb20180b8784e38ac14fb83769eb354b53ae +subagent.zh.md: 14e2b9b3e571c97384ccf560ce23edbeda62a305 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 99c54e0696..b554bb2018 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -613,7 +613,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) -Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts) @@ -639,7 +639,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts) @@ -656,7 +656,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts) @@ -673,7 +673,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts) @@ -697,5 +697,5 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index c5a79247a8..14e2b9b3e5 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -615,7 +615,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) -Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts) @@ -641,7 +641,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts) @@ -658,7 +658,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts) @@ -675,7 +675,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts) @@ -699,5 +699,5 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts) diff --git a/examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml b/examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml new file mode 100644 index 0000000000..089f1e3ae3 --- /dev/null +++ b/examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml @@ -0,0 +1,46 @@ +# Keyless counterpart to subagent-continuable-inheritance.cordis.yml: replace +# the live adapter with replay and switch the root session to read-only at +# creation. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: parent-sandbox-override + name: './tests/fixtures/parent-sandbox-override.ts' diff --git a/examples/acp-agent/subagent-continuable-inheritance.cordis.yml b/examples/acp-agent/subagent-continuable-inheritance.cordis.yml new file mode 100644 index 0000000000..5ad395650d --- /dev/null +++ b/examples/acp-agent/subagent-continuable-inheritance.cordis.yml @@ -0,0 +1,11 @@ +# Policy-inheritance overlay: the root session is switched to read-only at +# creation (the UI Access switch equivalent), so a continuable background +# child must inherit that override instead of the deployment default. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: parent-sandbox-override + name: './tests/fixtures/parent-sandbox-override.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..4add3f82e8 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -47,6 +47,9 @@ const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml' const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( new URL('../subagent-durability-failure.cordis.yml', import.meta.url), ) +const SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG = fileURLToPath( + new URL('../subagent-continuable-inheritance.cordis.yml', import.meta.url), +) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) @@ -315,6 +318,18 @@ const SCENARIOS: Scenario[] = [ pinsChildToolSchemas: [1], configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, }, + // Authored policy-inheritance transcript: the root session is switched to + // read-only at creation (the UI Access switch equivalent), and the + // continuable background child's log carries that override as a + // `sandbox/mode` `source: 'delegation'` event, so the child's runtime + // context states the inherited policy instead of the deployment default. + { + name: 'subagent-continuable-inheritance', + hasModelTurn: true, + recorded: false, + pinsChildToolSchemas: [1], + configPath: SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG, + }, // The in-process child is published before its first follow-up fails. The // foreground tool retains both that run-result failure and an independent // published-handle disposal failure. diff --git a/examples/acp-agent/tests/fixtures/parent-sandbox-override.ts b/examples/acp-agent/tests/fixtures/parent-sandbox-override.ts new file mode 100644 index 0000000000..02698d58c6 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/parent-sandbox-override.ts @@ -0,0 +1,19 @@ +import type { Context } from 'cordis' +import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-agent' + +export const name = 'parent-sandbox-override' + +/** + * Snapshot-only overlay: switch each ROOT session to `read-only` at creation — + * the UI "Access" switch equivalent (one runtime `sandbox/mode` event on the + * session log) — so the scenario proves a continuable background child + * inherits the parent's explicit override as a `source: 'delegation'` event + * instead of falling back to the deployment default. + */ +export function apply(ctx: Context): void { + ctx.on('agent/created', ({ agent }) => { + if (agent.session.header.parentSession !== undefined) return + setSandboxMode(agent.session, 'read-only') + }) +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json new file mode 100644 index 0000000000..183e21b557 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json @@ -0,0 +1,19 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool." + }, + { + "op": "waitForSubagentTurnEnd", + "child": 1, + "minimumTurn": 1 + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl new file mode 100644 index 0000000000..478198cf0c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl @@ -0,0 +1,21 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} +{"type":"subagent/descriptor","seq":0,"time":1786333735890,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"session/end-seed","seq":1,"time":1786333735890,"data":{}} +{"type":"sandbox/mode","seq":2,"time":1786333735890,"data":{"mode":"read-only","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786333735891,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"}]}} +{"type":"turn/start","seq":4,"time":1786333735891,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1786333735891,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":1786333735916,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":1786333735916,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1786333735916,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"5f181e93-3fd7-40f3-b5f7-21674b53d7c7"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1786333735916,"data":{"title":"Reply with exactly the word","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1786333735916,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1786333735916,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1786333735920,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":14,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":15,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":16,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":1786333735921,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e2b94008-b067-4ca7-a576-6b4a9060cd83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1786333735921,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":19,"time":1786333735921,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl new file mode 100644 index 0000000000..0968357f90 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"sandbox/mode","seq":0,"time":1786333735842,"data":{"mode":"read-only"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786333735845,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"}]}} +{"type":"turn/start","seq":2,"time":1786333735845,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786333735845,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":4,"time":1786333735878,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1786333735878,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786333735878,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9a793bfc-c155-44f8-ba56-c6843338d6be"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786333735878,"data":{"title":"Follow these steps exactly, then","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1786333735879,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1786333735879,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1789000000000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":12,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":13,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":1786333735884,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8ab58a42-e74c-4121-a6ca-63696e592287"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":1786333735885,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":17,"time":1786333735892,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"3478555e-f0d0-4ec1-a7e4-a15ab24b9ecf"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1786333735892,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":1786333735897,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":22,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":23,"time":1786333735904,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":24,"time":1786333735904,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":1786333735904,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4057a08e-b50e-45e7-beb0-c74485f2b7d6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1786333735904,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":27,"time":1786333735904,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json new file mode 100644 index 0000000000..7dd791cf27 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -0,0 +1,543 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "report", + "description": "Report selected content to the agent that started you. Call this zero or more times for progress, findings, or a final answer. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "parameters": { + "type": "object", + "properties": { + "output": { + "type": "string", + "description": "Self-contained content for your parent; it does not see your private work." + } + }, + "required": [ + "output" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "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 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 `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).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/knip.json b/knip.json index 812f7446bc..9a091a068e 100644 --- a/knip.json +++ b/knip.json @@ -48,6 +48,7 @@ "headless-agent/tests/fixtures/e2b/e2b/bin.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "acp-agent/tests/fixtures/child-question-tripwire.ts", + "acp-agent/tests/fixtures/parent-sandbox-override.ts", "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index a6a82fb47f..7598b6dc3e 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d -README.zh.md: 648a160be5f1c3dcbe66a867a273a3df610dbc0a +README.md: 4189979806ccd4e7dcfee231ab3e9e2331550b0d +README.zh.md: c6a9005cbfdb9d40a54383f921671fa22a31dc32 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 67f0cf5dd1..4189979806 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -20,7 +20,7 @@ The child gets the parent's working-directory/session lineage and inherits the p This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output. -When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). +The driver applies the seam's [delegated policy inheritance](../subagent/README.md#delegated-policy-inheritance) through the shared child-agent helpers: it captures the parent's explicit sandbox/approval overrides before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). ## Cancellation and ownership diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 648a160be5..c6a9005cbf 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -20,7 +20,7 @@ 该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 -当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 +驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略继承](../subagent/README.md#delegated-policy-inheritance):它会在创建子 agent 前捕获父级的显式沙箱/审批覆盖项,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 ## 取消与所有权 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index acb4e4d36e..f3e4bb04d2 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -17,8 +17,10 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { + appendDelegatedPolicyOverrides, applyChildComposition, assertSubagentMaxDepth, + captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, @@ -30,11 +32,6 @@ import type { SubagentRun, SubagentStopReason, } from '@deepseek-ai/dsh-subagent' -// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve -// to the policy services when composed — the driver consumes both -// opportunistically (the documented `ctx.get` pattern), never as a hard dep. -import type {} from '@deepseek-ai/dsh-sandbox-policy' -import type {} from '@deepseek-ai/dsh-user-approval' import { attachStructuredRuntime, type StructuredAttachment, @@ -111,20 +108,11 @@ export async function startInProcessRun( // Capture before the first await: a later parent switch belongs to the // parent's future. - const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session) - const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session) + const inherited = captureDelegatedPolicyOverrides(parent) let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { - // Inherited overrides land on the child's own log, so its effective policy - // is reconstructable from that log alone. - const childSession = (childCtx.agent as Agent).session - if (inheritedMode !== undefined) { - childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' }) - } - if (inheritedPolicy !== undefined) { - childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' }) - } + appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited) applyChildComposition(childCtx, { persona: request.persona, toolFilter: request.toolFilter, diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 1241aa8042..7c64fb7228 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 762030629c09305c48adebc71244655a5faa6585 -README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff +README.md: 6cea175de3d07290b293ad55ccbe60d7d918d37c +README.zh.md: c5fecd554357146d317b5f75824f40a1cb976f2c diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 762030629c..6cea175de3 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -52,6 +52,10 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority. +## Delegated policy inheritance + +Both in-process delegation paths seed the parent's explicit policy overrides into the child through the shared child-agent helpers: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf()` and `approval.overrideOf()` synchronously at the delegation boundary (both services are optional `ctx.get` consumers), and `appendDelegatedPolicyOverrides()` writes each captured value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state, a later child switch wins the snapshot, and the child's effective policy stays reconstructable from its log alone. Deployment defaults are never copied: an unswitched parent stamps nothing and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) policy-inheritance Agent Notes. + ## One-shot ownership and lifecycle `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 535cc25895..c5fecd5543 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -52,6 +52,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。 +## 委派策略继承 + +两条进程内委派路径都会通过共享的子 agent 辅助函数,把父级的显式策略覆盖项作为种子注入子 agent:`captureDelegatedPolicyOverrides(parent)` 在委派边界同步对 `sandboxPolicy.overrideOf()` 与 `approval.overrideOf()` 获取快照(这两个服务都是可选的 `ctx.get` 消费方),`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个捕获值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,子 agent 后续的切换压过该快照,而子 agent 的生效策略始终可以仅凭其日志重建。部署默认值绝不复制:未切换的父级不会记录任何值,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇策略继承 Agent Note。 + ## 一次性所有权与生命周期 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index ba1dd0fbc4..fc03f20b1a 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -37,6 +37,8 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -44,9 +46,16 @@ "@deepseek-ai/dsh-session-projection-cache": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { + "@deepseek-ai/dsh-sandbox": { + "optional": true + }, + "@deepseek-ai/dsh-sandbox-policy": { + "optional": true + }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -58,6 +67,9 @@ }, "@deepseek-ai/dsh-tasks": { "optional": true + }, + "@deepseek-ai/dsh-user-approval": { + "optional": true } }, "devDependencies": { @@ -65,6 +77,8 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", @@ -74,6 +88,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index c477954468..cb4e8fbd97 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -1,17 +1,23 @@ /** * Shared in-process child composition: the delegation-depth budget, the - * durable session metadata, the resolved child `AgentOptions`, and the scoped - * setup a child agent needs. Both the one-shot provider driver and the - * continuation manager compose children this way, so depth accounting and - * lineage stamping have one home. + * durable session metadata, the resolved child `AgentOptions`, the delegated + * policy snapshot, and the scoped setup a child agent needs. Both the one-shot + * provider driver and the continuation manager compose children this way, so + * depth accounting, lineage stamping, and policy inheritance have one home. * * @module @deepseek-ai/dsh-subagent/child-agent */ import type { Context } from 'cordis' import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve +// to the policy services when composed — delegation consumes both +// opportunistically (the documented `ctx.get` pattern), never as a hard dep. +import type {} from '@deepseek-ai/dsh-sandbox-policy' import { delegationDepthOf } from './depth.ts' /** Thrown when starting a child would exceed the requested depth cap. */ @@ -119,6 +125,51 @@ export function applyChildComposition(childCtx: Context, composition: ChildCompo if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter) } +/** Parent-session policy overrides captured at the delegation boundary. */ +export interface DelegatedPolicyOverrides { + /** The parent session's explicit sandbox-mode override, or `undefined` without one. */ + readonly sandboxMode: SandboxMode | undefined + /** The parent session's explicit approval-policy override, or `undefined` without one. */ + readonly approvalPolicy: ApprovalPolicy | undefined +} + +/** + * Capture the parent session's explicit policy overrides for one delegation. + * Call synchronously before the child start's first await: a later parent + * switch belongs to the parent's future, not to this child. Deployment + * defaults and one-shot grants are never captured, so an unswitched parent + * leaves the child following the deployment default dynamically. + * @param parent - the delegating parent agent. + * @returns the overrides to seed into the child, each `undefined` without one. + */ +export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides { + return { + sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session), + approvalPolicy: parent.ctx.get('approval')?.overrideOf(parent.session), + } +} + +/** + * Append captured parent overrides onto the child's own log as + * `source: 'delegation'` events inside the unpublished creation window, so the + * child's effective policy is reconstructable from its log alone. Appends land + * after any fork seed, so fresh policy wins stale seed state; later child + * switches still win over these events. + * @param childSession - the unpublished child's session. + * @param overrides - the overrides captured at delegation. + */ +export function appendDelegatedPolicyOverrides( + childSession: Session, + overrides: DelegatedPolicyOverrides, +): void { + if (overrides.sandboxMode !== undefined) { + childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' }) + } + if (overrides.approvalPolicy !== undefined) { + childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' }) + } +} + /** Identity and lineage inputs shared by every in-process child creation. */ export interface ChildCreateInputs { /** The child's reserved session id. */ diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 3425a12851..743d6d63de 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -32,11 +32,14 @@ import type { ToolRestriction } from '@deepseek-ai/dsh-tools' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' import type { SubagentDescriptorData } from './descriptor.ts' import { + appendDelegatedPolicyOverrides, applyChildComposition, + captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, } from './child-agent.ts' +import type { DelegatedPolicyOverrides } from './child-agent.ts' import { assertSubagentMaxDepth } from './depth.ts' import { seedDescriptorTurn } from './descriptor-seed.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts' @@ -203,8 +206,17 @@ interface MaterializeInputs { childId: SessionId provider: string parent: Agent - /** Creation inputs; absent for a cold resume, which loads the persisted session. */ - create?: { seed: readonly SessionEvent[]; meta: NonNullable } + /** + * Creation inputs; absent for a cold resume, which loads the persisted + * session — including the delegation policy events a fresh creation seeded, + * so a resume never re-captures the parent's policy. + */ + create?: { + seed: readonly SessionEvent[] + meta: NonNullable + /** Parent policy overrides captured at the delegation boundary. */ + inheritedPolicies: DelegatedPolicyOverrides + } agentOptions: AgentOptions composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } signal: AbortSignal @@ -341,6 +353,9 @@ export class SubagentContinuationManager { ...request.persona !== undefined ? { persona: request.persona } : {}, ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) + // Capture before the first await: a later parent switch belongs to the + // parent's future, not to this child. + const inheritedPolicies = captureDelegatedPolicyOverrides(parent) const prepared = await this.host.prepareContinuable(spec.provider, { sessionId: childId, @@ -357,7 +372,7 @@ export class SubagentContinuationManager { childId, provider: spec.provider, parent, - create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength) }, + create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), inheritedPolicies }, agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, @@ -878,18 +893,23 @@ export class SubagentContinuationManager { inputs: MaterializeInputs, parentLineage: readonly Agent[], ): Promise { - const { childId, provider, parent } = inputs + const { childId, provider, parent, create } = inputs // No id pre-check here: the child lock serializes each durable child, both // callers reach this only after confirming no Activation exists, and // `AgentRegistry.enter()` is the authoritative collision boundary for an id // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): AgentSetupCommit => { + // Only fresh creation seeds captured parent policy onto the child's own + // log (after any fork seed, so fresh policy wins stale seed state); a + // cold resume replays those persisted events instead. + if (create !== undefined) { + appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.inheritedPolicies) + } applyChildComposition(childCtx, inputs.composition) return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) - const { create } = inputs // Agent creation owns rollback before handle transfer. A rejection leaves // no resident Activation and therefore publishes no lifecycle edge. const handle: AgentHandle = create === undefined diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index eddcf63c3c..75bcf1f12c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -100,13 +100,15 @@ export { SubagentError } from './error.ts' export { settleRun } from './run-settlement.ts' export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts' export { + appendDelegatedPolicyOverrides, applyChildComposition, + captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, SubagentDepthError, } from './child-agent.ts' -export type { ChildComposition } from './child-agent.ts' +export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts' export type { ContinuableStart, ContinuableStartSpec, diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts new file mode 100644 index 0000000000..246bafa1ed --- /dev/null +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -0,0 +1,171 @@ +/** + * Continuable-child policy inheritance: a fresh continuable start seeds the + * parent's explicit sandbox/approval overrides onto the child's own log as + * `source: 'delegation'` events, and a cold resume replays that persisted + * snapshot instead of re-capturing the parent (the one-shot + * `subagent-inprocess/tests/inheritance.spec.ts` counterpart). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +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 { createUserMessage } from '@deepseek-ai/dsh-llm' +import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import ApprovalService, { effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import SubagentService from '../src/index.ts' + +type Script = ConstructorParameters[0] + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +/** Boot the continuable stack plus both policy services the manager consumes opportunistically. */ +async function setup(script: Script) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root }) + await ctx.plugin(ApprovalService) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(SubagentFork, { providerName: 'fork' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } +} + +function startSpec(parent: Agent, provider = 'spawn') { + return { + provider, + label: 'child task', + request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent }, + signal: new AbortController().signal, + } +} + +/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */ +async function waitNoActivation(ctx: Context, childId: SessionId): Promise { + await vi.waitFor(() => { + expect(ctx.agents.get(childId)).toBeUndefined() + }, { timeout: 5_000 }) +} + +function policyEvents(events: readonly SessionEvent[]) { + return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy') +} + +describe('continuable policy inheritance', () => { + it('seeds parent overrides into a fresh continuable child', async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + setSandboxMode(parent.session, 'danger-full-access') + setApprovalPolicy(parent.session, 'never') + let child: Agent | undefined + ctx.on('agent/created', ({ agent }) => { + if (agent !== parent) child = agent + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + // The delegation events are appended in the creation window, so they are + // already the child's effective policy at inbox acceptance. + if (child === undefined) throw new Error('expected the continuable child to be created') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access') + expect(ctx.approval.overrideOf(child.session)).toBe('never') + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(policyEvents(loaded.events)).toMatchObject([ + { type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } }, + { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, + ]) + // Durable: a reload folds the same effective policy. + expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access') + expect(effectiveApprovalPolicy(loaded.events)).toBe('never') + }) + + it('captures policy at delegation before asynchronous child creation', async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + setSandboxMode(parent.session, 'read-only') + + const starting = ctx.subagents.startContinuable(startSpec(parent)) + // A parent switch after the synchronous capture belongs to the parent's + // future, not to this child. + setSandboxMode(parent.session, 'danger-full-access') + const started = await starting + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access') + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) + + it('does not freeze deployment defaults into an unswitched child', async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(policyEvents(loaded.events)).toEqual([]) + }) + + it('cold-resumes on the persisted snapshot without re-capturing the parent', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) + setSandboxMode(parent.session, 'read-only') + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + // The parent widens AFTER the child was created; the resumed child keeps + // the delegation-time snapshot from its own log. + setSandboxMode(parent.session, 'danger-full-access') + await ctx.subagents.followup(parent, started.childId, [{ type: 'text', text: 'continue please' }], { + source: { kind: 'user' }, + signal: new AbortController().signal, + }) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + { data: { mode: 'read-only', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) + + it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => { + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')]) + // The stale mode lands inside the completed turn the fork seed replays. + setSandboxMode(parent.session, 'workspace-write') + parent.followup(createUserMessage({ + content: [{ type: 'text', text: 'parent work' }], + source: { kind: 'user' }, + })) + await parent.whenIdle() + setSandboxMode(parent.session, 'read-only') + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.seedLength).toBeGreaterThan(0) + expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + { data: { mode: 'workspace-write' } }, + { data: { mode: 'read-only', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index c72f2ef68d..46e4e4592d 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,6 +26,15 @@ { "path": "../../core/scope" }, + { + "path": "../../interaction/user-approval" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../session/session-persistence" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b08ae2da5d..d73737cfe4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5887,6 +5887,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -5914,6 +5920,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From b1d67a693537bb24f0e95460bbdafd98e6c507e6 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 12:45:05 +0800 Subject: [PATCH 002/105] feat(agent-presets): add Codex and Claude Code subagent tools --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 8 +- ...2026-08-03-per-session-agent-presets.zh.md | 8 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 12 +- ...ude-code-and-codex-subagent-backends.zh.md | 12 +- apps/cli/composition.md | 6 + .../agent-presets/code/agent.cordis.yml | 21 + .../agent-presets/cordis/agent.cordis.yml | 21 + .../editing-cordis-compositions/SKILL.md | 28 + .../agent-presets/standard/agent.cordis.yml | 21 + apps/cli/tests/web-agent-presets.e2e.ts | 124 +++- .../product-subagent-both.cordis.snapshot.yml | 38 ++ .../product-subagent-both.cordis.yml | 27 + ...product-subagent-codex.cordis.snapshot.yml | 29 + .../product-subagent-codex.cordis.yml | 18 + examples/acp-agent/tests/acp.snapshot.ts | 19 + .../product-subagent-both/input.json | 7 + .../product-subagent-both/session.jsonl | 22 + .../stdout.expected.jsonl | 4 + .../tool-schemas.expected.json | 569 ++++++++++++++++++ .../product-subagent-codex/input.json | 7 + .../product-subagent-codex/session.jsonl | 22 + .../stdout.expected.jsonl | 4 + .../system-prompt.expected.md | 22 + .../tool-schemas.expected.json | 548 +++++++++++++++++ packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 9 + packages/bundle/base/package.json | 2 + packages/bundle/base/tests/base.spec.ts | 11 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 11 +- .../subagent-claude-code/README.zh.md | 11 +- .../subagent-claude-code/src/index.ts | 8 +- .../subagent-claude-code/src/process.ts | 9 +- .../subagent/subagent-claude-code/src/run.ts | 3 + .../tests/real-deepseek.e2e.ts | 3 +- .../tests/real-product.spec.ts | 21 +- .../tests/subagent-claude-code.spec.ts | 32 + .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 3 +- packages/subagent/subagent-codex/README.zh.md | 3 +- pnpm-lock.yaml | 6 + 45 files changed, 1708 insertions(+), 45 deletions(-) create mode 100644 examples/acp-agent/product-subagent-both.cordis.snapshot.yml create mode 100644 examples/acp-agent/product-subagent-both.cordis.yml create mode 100644 examples/acp-agent/product-subagent-codex.cordis.snapshot.yml create mode 100644 examples/acp-agent/product-subagent-codex.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-both/input.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/input.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index f3d763058b..1f9868917b 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 6f1643c25008c3363cb10adb7fbff7afeea31cbe -2026-08-03-per-session-agent-presets.zh.md: 7afe9ade5c98fadb96384a7e0acd47531c370e0c +2026-08-03-per-session-agent-presets.md: ca3e6967504ac62b1d79ec28ef9dbd4bf8383bac +2026-08-03-per-session-agent-presets.zh.md: 94651d465939d760554ed8637376ae77cfae6812 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 6f1643c250..ca3e696750 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -18,7 +18,7 @@ Composition splits into two planes, decided by what must be shared rather than b | Plane | Instances | Contents | |---|---|---| -| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), and the web host | +| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), the subagent providers those facilities resolve, and the web host | | Agent | one per session | What a single agent contributes to those registries: tool plugins, persona and prompt sections, compaction policy | Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. @@ -55,7 +55,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. -**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the fixed Codex and Claude Code product providers, are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. **A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. @@ -78,3 +78,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Make the agent's scope key the preset.** Sessions on one preset would share a layer for free, but per-agent registrations — `installAgentLlmTarget`, per-agent tool restrictions — would then collide across sessions. **Run each preset as a child process.** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one. + +**Give product subagents global enable settings and a separate settings page.** The process-wide value would compete with the preset as owner of model-visible tools and could not express two sessions using different compositions. Product providers stay host-side, while ordinary preset rows independently expose Codex and Claude Code tools. + +**Ship one preset for every Codex and Claude Code combination.** Four identities duplicate the full preset composition to represent two independent rows. A copied preset can enable either row directly, so combination presets add roster and maintenance cost without adding a user result. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 7afe9ade5c..94651d4659 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -18,7 +18,7 @@ Status: implemented | 平面 | 实例数 | 内容 | |---|---|---| -| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测),以及 web 宿主 | +| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测)、这些设施所解析的 subagent provider,以及 web 宿主 | | agent | 每会话一份 | 单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、压缩策略 | 模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 @@ -56,7 +56,7 @@ Status: implemented **创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 -**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与后端属于宿主平面;preset 贡献的是委派**工具**,它们解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括固定的 Codex 与 Claude Code 产品 provider,都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 **真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 @@ -79,3 +79,7 @@ Status: implemented **把 agent 的 scope 键设为 preset。** 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——`installAgentLlmTarget`、按 agent 的工具限制——会跨会话相撞。 **把每个 preset 作为子进程运行。** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。 + +**给产品 subagent 增加全局启用设置与独立设置页。** 进程级值会与 preset 争夺模型可见工具的所有权,也无法表达两个会话使用不同组装。产品 provider 留在宿主,普通 preset 行分别暴露 Codex 与 Claude Code 工具。 + +**为 Codex 与 Claude Code 的每种组合交付一份 preset。** 四个身份会复制完整 preset 组装,只为表示两条独立行。复制后的 preset 已能直接启用任一行,因此组合 preset 只增加名单与维护成本,不增加用户结果。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 7100fc33fc..1d346efd96 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: e81d1fb14f719331c503dba539d6a5ec0f1eed4f -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: aa6d80e38c12a2808a27e93bde8aec5d71e6a509 +2026-08-04-claude-code-and-codex-subagent-backends.md: cf41190c2d03965106e817b50fd2edb806e084bf +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: e0c9cfc96a68fe919a70af64b23ee7ed33ffcea3 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index e81d1fb14f..cf41190c2d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. +The harness publishes two sibling one-shot providers in the shared profile host: `codex` and `claude-code`. Loading the host providers starts no product process. An Agent Preset independently contributes ordinary `dsh-tool-subagent` rows when its agent should see `subagent_codex`, `subagent_claude_code`, both, or neither; the shipped full presets carry both rows disabled so copies have one accurate configuration template without changing the default model schema. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -47,7 +47,7 @@ Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The SDK's platform `optionalDependency` supplies the real Claude Code 2.1.220 CLI. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` command, arguments, cwd, environment, and forwarded signal unchanged to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and its platform-distributed Claude Code 2.1.220 CLI. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader e2e resolves both product packages by name while neither product command is available and records zero child starts. +The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -79,6 +79,10 @@ The project owner's distribution authorization is scoped to the official `@anthr **A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. +**Global product enable settings and a product-specific Web page.** Those controls make Codex and Claude Code exceptions to the Agent Preset composition that already owns one agent's tool set, and one process-wide choice cannot represent two sessions using different presets. The host always supplies the providers; the preset alone decides which fixed tools its agent receives. + +**One shipped preset per product combination.** Four preset variants encode a two-boolean choice in preset identities and multiply every future standard-preset change. Independent ordinary rows express the same result in the user's copied preset without adding a roster taxonomy. + **Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. **Plugin-managed login, product home, models, settings, or permissions.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed. @@ -87,7 +91,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users can install either or both product providers, bind stable foreground tools in their own Cordis configuration, and delegate one self-contained task through the existing subagent contract. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. +Users copy or author an Agent Preset and independently enable either or both stable foreground tools. Every profile host supplies the reusable providers once, while each preset owns only its agent's model-visible tool rows. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index aa6d80e38c..e0c9cfc96a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 将两个一次性兄弟提供方作为可独立安装、选择启用的包交付。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,`subagent_claude_code` 绑定 `claude-code`。随产品交付的 CLI(命令行界面)依赖闭包,以及基础、Web 与 headless 配置都不会加载任一提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 +harness 在共享 profile 宿主中交付两个一次性兄弟提供方:`codex` 与 `claude-code`。加载宿主提供方不会启动产品进程。某个 Agent Preset 是否让自己的 agent 看见 `subagent_codex`、`subagent_claude_code`、两者或两者皆无,由该 preset 独立贡献普通的 `dsh-tool-subagent` 行;随附的完整 preset 携带两条默认禁用的行,使复制品拥有一份准确配置模板,同时不改变默认模型 schema。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -47,7 +47,7 @@ Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。SDK 的平台 `optionalDependency` 提供真实的 Claude Code 2.1.220 CLI。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1.220 CLI。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader e2e 会在两个产品命令均不可用时按名称解析两个产品包,并记录零次子级启动。 +Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -79,6 +79,10 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1 **面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 +**全局产品启用设置与产品专属 Web 页面。** 这类控制会让 Codex 与 Claude Code 成为 Agent Preset 组装的例外,而后者本就拥有单个 agent 的工具集;一个进程级选择也无法表达两个会话使用不同 preset。宿主始终提供 provider,只有 preset 决定其 agent 获得哪些固定工具。 + +**为每种产品组合交付一份 preset。** 四个 preset 变体把两个布尔选择编码成 preset 身份,并让未来每次标准 preset 修改都要同步多份副本。用户复制的 preset 中两条独立普通行已经能表达同一结果,无需新增名单分类。 + **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 **由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 @@ -87,7 +91,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1 ## 后果 -用户可以安装任一或两个产品提供方,在自己的 Cordis 配置中绑定稳定的前台工具,并通过现有 subagent 约定委派一项自包含任务。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 +用户可以复制或创作一个 Agent Preset,并分别启用任一或两个稳定前台工具。每个 profile 宿主只提供一次可复用 provider,而每个 preset 只拥有自己 agent 的模型可见工具行。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index cfcd903e32..71b45bf6cf 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -110,6 +110,10 @@ flowchart LR cfg --> plugin_dsh_base_subagent_spawn plugin_dsh_base_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_dsh_base_subagent_fork + plugin_dsh_base_subagent_codex["subagent-codex
@deepseek-ai/dsh-subagent-codex"] + cfg --> plugin_dsh_base_subagent_codex + plugin_dsh_base_subagent_claude_code["subagent-claude-code
@deepseek-ai/dsh-subagent-claude-code"] + cfg --> plugin_dsh_base_subagent_claude_code plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_dsh_base_tool_subagent_control plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] @@ -215,6 +219,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` | +| `subagent-claude-code` | `@deepseek-ai/dsh-subagent-claude-code` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 65d2716458..481cad47f1 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -197,6 +197,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index f2cdeea159..6f08b777a3 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -184,6 +184,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 3810ec334b..7682cf5add 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -26,6 +26,34 @@ A preset is a directory holding one `agent.cordis.yml`, optionally beside a `pre 3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above. +### Native product subagents + +Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field. + +Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: + +```yaml +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. + The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete. ## The rule that catches people diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 66407faf1d..643d9e65b8 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -196,6 +196,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1bfaed8c67..8f54d6ddf6 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -8,7 +8,7 @@ import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek- import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@cordisjs/plugin-include' -import { beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' import { CallId } from '@deepseek-ai/dsh-llm' @@ -95,6 +95,18 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis const toolNames = (ctx: Context, agent?: Agent): string[] => ctx.tools.schemas(agent).map(schema => schema.name).sort() +function enablePresetTool(composition: string, id: string): string { + const row = ` - id: ${id}\n` + const start = composition.indexOf(row) + if (start < 0) throw new Error(`missing preset row ${id}`) + const end = composition.indexOf('\n - id:', start + row.length) + const disabled = composition.indexOf(' disabled: true\n', start) + if (disabled < 0 || (end >= 0 && disabled > end)) { + throw new Error(`preset row ${id} is not disabled`) + } + return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length) +} + let ctx: Context beforeAll(async () => { const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml') @@ -120,6 +132,24 @@ describe('the shipped Web composition', () => { expect(ctx.agentPresets.defaultId).toBe('standard') }) + it('keeps product providers on the host while shipped presets leave their tools disabled', async () => { + expect(ctx.subagents.list()).toEqual(expect.arrayContaining([ + 'spawn', 'fork', 'codex', 'claude-code', + ])) + + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-products-disabled'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + expect(toolNames(ctx, handle.agent)).not.toEqual(expect.arrayContaining([ + 'subagent_codex', 'subagent_claude_code', + ])) + } finally { + await handle.dispose() + } + }) + it('composes the full agent from `standard`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-standard'), @@ -355,6 +385,98 @@ describe('the shipped Web composition', () => { }) }) +describe('product subagent rows in user presets', () => { + let productCtx: Context + const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const + + beforeAll(async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) + const userRoot = join(root, 'presets') + const settingsFile = join(root, 'settings.yaml') + const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8') + await writeFile(settingsFile, '{}\n') + for (const id of ids) { + let composition = standard + if (id === 'products-codex' || id === 'products-both') { + composition = enablePresetTool(composition, 'tool-subagent-codex') + } + if (id === 'products-claude' || id === 'products-both') { + composition = enablePresetTool(composition, 'tool-subagent-claude-code') + } + const directory = join(userRoot, id) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'agent.cordis.yml'), composition) + } + productCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [ + { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, + { path: userRoot, trust: 'user' }, + ], + }, + }]) + }, 120_000) + + afterAll(async () => { + await productCtx.fiber.dispose() + }) + + it('composes none, either product, or both without changing the shared host registry', async () => { + const expected = new Map([ + ['products-none', []], + ['products-codex', ['subagent_codex']], + ['products-claude', ['subagent_claude_code']], + ['products-both', ['subagent_claude_code', 'subagent_codex']], + ]) + expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([ + 'spawn', 'fork', 'codex', 'claude-code', + ])) + + for (const [id, productTools] of expected) { + const handle = await productCtx.agents.create({ + sessionId: SessionId(`preset-${id}`), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), + }) + try { + const tools = toolNames(productCtx, handle.agent) + expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) + .toEqual(productTools) + } finally { + await handle.dispose() + } + } + }) + + it('applies a product-row edit only to later sessions on the preset', async () => { + const preset = await productCtx.agentPresets.resolve('products-none') + const original = await readFile(preset.path, 'utf8') + const existing = await productCtx.agents.create({ + sessionId: SessionId('preset-product-generation-existing'), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), + }) + try { + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') + await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex')) + + const later = await productCtx.agents.create({ + sessionId: SessionId('preset-product-generation-later'), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), + }) + try { + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') + expect(toolNames(productCtx, later.agent)).toContain('subagent_codex') + } finally { + await later.dispose() + } + } finally { + await existing.dispose() + await writeFile(preset.path, original) + } + }) +}) + describe('a switch survives the session', () => { it('records the choice so the log states what the agent runs', async () => { const handle = await ctx.agents.create({ diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml new file mode 100644 index 0000000000..2863c80641 --- /dev/null +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Keyless twin of product-subagent-both.cordis.yml: preserve both product +# tools while replacing only the external model adapter. +- 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' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml new file mode 100644 index 0000000000..7d6269352a --- /dev/null +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -0,0 +1,27 @@ +# Add both native product providers and the same independent foreground tool +# rows an Agent Preset may contribute. Loading the composition starts neither +# product; the scenario pins both model-visible schemas. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml new file mode 100644 index 0000000000..74823e5da5 --- /dev/null +++ b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless twin of product-subagent-codex.cordis.yml: keep the same product +# provider/tool composition and replace only the external model adapter. +- 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' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.yml b/examples/acp-agent/product-subagent-codex.cordis.yml new file mode 100644 index 0000000000..169acee9a7 --- /dev/null +++ b/examples/acp-agent/product-subagent-codex.cordis.yml @@ -0,0 +1,18 @@ +# Add the native Codex product provider and its preset-shaped foreground tool to +# the real ACP composition. The model is told not to call it; the scenario pins +# the assembled request schema without starting Codex. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..b8d67c0c67 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -52,6 +52,8 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -122,6 +124,23 @@ const SCENARIOS: Scenario[] = [ // text-turn is the default header pin and owns the prompt and tool-schema // sidecars reused by alternate classes with identical component sequences. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, + { + name: 'product-subagent-codex', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_CODEX_CONFIG, + }, + { + name: 'product-subagent-both', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'product-subagent-both', + systemPromptSource: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_BOTH_CONFIG, + }, { name: 'session-title-after-turn', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/input.json b/examples/acp-agent/tests/snapshots/product-subagent-both/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl new file mode 100644 index 0000000000..84ac27e70e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f1418376-f303-4017-acd7-92899c841c8a"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json new file mode 100644 index 0000000000..76f60e28d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -0,0 +1,569 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_claude_code", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "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 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 `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).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl new file mode 100644 index 0000000000..bd47dfa24f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c883cf16-01fe-4afc-b37c-d255bb450d21"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md new file mode 100644 index 0000000000..a6ffe7d4d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md @@ -0,0 +1,22 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +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. diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json new file mode 100644 index 0000000000..84c4671579 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json @@ -0,0 +1,548 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "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.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "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 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 `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).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 973959478e..9b4989304d 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: fb003908a262dc21edd3c9d49c972e487534f367 -README.zh.md: 13e64db6d34374fac63bf9bfd60544fc46b86f35 +README.md: aea28a7412fd1123e5bbc593fb7978b2e4595248 +README.zh.md: f11745061c340d23401729ff326d277a0eade428 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index fb003908a2..aea28a7412 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 13e64db6d3..f11745061c 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、repository 插件、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c512127199..3902a2318e 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -288,6 +288,15 @@ config: providerName: fork + # Product providers stay on the host plane because the registry is a + # process singleton. Agent presets decide whether their own model sees the + # matching delegation tools; loading either provider starts no product. + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + # Continuable background children are selected per delegation tool. The # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index c2cd151b3e..7f237a09cb 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -76,6 +76,8 @@ "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-claude-code": "workspace:^", + "@deepseek-ai/dsh-subagent-codex": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 24ee2a1ba3..4f2c355d8b 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -13,7 +13,10 @@ import { entryListSchema } from '@cordisjs/plugin-include' describe('dsh-base bundle', () => { it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { const root = fileURLToPath(new URL('..', import.meta.url)) - const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { bundle?: { patch?: string } } } + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema }) expect(Array.isArray(parsed)).toBe(true) @@ -21,5 +24,11 @@ describe('dsh-base bundle', () => { const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? []) expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) + expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(1) + expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(1) + expect(manifest.dependencies).toMatchObject({ + '@deepseek-ai/dsh-subagent-codex': 'workspace:^', + '@deepseek-ai/dsh-subagent-claude-code': 'workspace:^', + }) }) }) diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 23450e6e42..4f62110d49 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent/subagent-claude-code/README.md -README.md: 222cf796f71f8dc0dc2c06f7f32bab70ded6ab43 -README.zh.md: 9334820a591cbfcb8dc2046dc3a78201ba193aab +README.md: 5e3138b9211b01de9096fa1b8e8b68321aad0c7d +README.zh.md: cc536d13d58e08efc77f4f7b4374c8a1af8c5caa diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 222cf796f7..5e3138b921 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -29,9 +29,9 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. -Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default. +Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. ```yaml - id: subagent-claude-code @@ -42,6 +42,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code @@ -51,7 +52,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. Required evidence exercises that official distribution through a keyless loopback product path and a credentialed DeepSeek path, while Loader composition proves that both opt-in product packages coexist without starting either product. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation; the SDK's platform optional payload remains in the current installation closure and is tracked as a separate distribution follow-up. Required evidence exercises the compatible native product through a keyless loopback path and a credentialed DeepSeek path, while Loader composition proves that both product packages coexist without starting either product. The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. @@ -89,7 +90,7 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. -- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. - **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. - **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 9334820a59..cc536d13d5 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务启动 SDK 分发的 Claude Code CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 ## 启动与所有权 @@ -29,9 +29,9 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 -请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。 +随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 ```yaml - id: subagent-claude-code @@ -42,6 +42,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code @@ -51,7 +52,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。强制证据会通过无密钥回环产品路径与带密钥 DeepSeek 路径运行该官方发行版,而 Loader 组合则证明两个选择启用的产品包能够共存,且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装;SDK 的平台可选载荷仍处于当前安装闭包,并作为独立分发后续项跟踪。强制证据会通过无密钥回环路径与带密钥 DeepSeek 路径运行兼容的原生产品,而 Loader 组合则证明两个产品包能够共存且不会启动任一产品。 项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -89,7 +90,7 @@ Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 -- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 - **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index e4d6fbac5f..7552575f45 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -59,19 +59,25 @@ class ClaudeCodeProvider implements SubagentProvider { private readonly config: ResolvedConfig, ) {} - start(request: ResolvedSubagentStartRequest) { + async start(request: ResolvedSubagentStartRequest) { const parentCwd = request.parent.session.header.cwd if (parentCwd === undefined) { throw new Error( 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', ) } + const executable = await this.ctx.subprocess.resolveExecutable( + 'claude', + this.config.env, + request.signal, + ) const spec: ClaudeCodeRunSpec = { cwd: resolveChildCwd( 'subagent-claude-code', undefined, parentCwd, ), + executable, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 32a545bf08..6200ac559b 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -6,6 +6,7 @@ */ import { EventEmitter } from 'node:events' +import { extname } from 'node:path' import type { SpawnedProcess, SpawnOptions, @@ -40,17 +41,23 @@ export function sdkEnvironmentOverlay( * Translate one official SDK spawn request to the shared process owner. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. * @param graceMs - process-tree termination grace. + * @param platform - host platform selecting the Windows batch-shim boundary. * @returns the fully explicit shared subprocess request. */ export function claudeSpawnSpec( options: SpawnOptions, graceMs: number, + platform: NodeJS.Platform = process.platform, ): SubprocessSpawnSpec { if (options.cwd === undefined || options.cwd.length === 0) { throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') } + const extension = extname(options.command).toLowerCase() + const argv = platform === 'win32' && (extension === '.cmd' || extension === '.bat') + ? ['cmd.exe', '/d', '/s', '/c', options.command, ...options.args] + : [options.command, ...options.args] return { - argv: [options.command, ...options.args], + argv, cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 9dc4d740ac..6c1e0a8dbf 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -44,6 +44,8 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ readonly cwd: string + /** Exact native Claude Code executable resolved from the host PATH. */ + readonly executable: string /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -180,6 +182,7 @@ export function claudeQueryOptions( return { abortController: controller, cwd: spec.cwd, + pathToClaudeCodeExecutable: spec.executable, env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, disallowedTools: ['AskUserQuestion'], diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index 806181ad13..7a9a0c61d1 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -7,7 +7,7 @@ import { rmSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { Context } from 'cordis' @@ -87,6 +87,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( ]) mkdirSync(directory) const env = { + PATH: `${dirname(claudeBin)}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`, ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index f76b4038f6..3c73db7a32 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -1,13 +1,15 @@ import { execFile } from 'node:child_process' import { + copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import type { @@ -19,7 +21,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' import { @@ -98,9 +100,11 @@ afterEach(async () => { interface RealHarness { readonly ctx: Context readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] readonly parent: Agent readonly workspace: string readonly env: Record + readonly executable: string } async function realHarness(behavior: MessagesBehavior): Promise<{ @@ -112,9 +116,14 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') + const nativeBin = join(root, 'native-bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) + mkdirSync(nativeBin) + const executable = join(nativeBin, process.platform === 'win32' ? 'claude.exe' : 'claude') + if (process.platform === 'win32') copyFileSync(claudeBin, executable) + else symlinkSync(claudeBin, executable) writeFileSync( join(claudeConfig, 'settings.json'), `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, @@ -122,6 +131,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const fixture = await startMessagesFixture(behavior) fixtures.push(fixture) const env = { + PATH: `${nativeBin}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_API_KEY: fakeKey, ANTHROPIC_BASE_URL: fixture.baseUrl, CLAUDE_CONFIG_DIR: claudeConfig, @@ -141,8 +151,10 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ await ctx.plugin(SubagentService) await ctx.plugin(LocalSubprocessService) const handles: SubprocessHandle[] = [] + const spawnSpecs: SubprocessSpawnSpec[] = [] const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) const handle = spawn(spec) handles.push(handle) return handle @@ -153,7 +165,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ session: { header: { cwd: workspace } }, } as unknown as Agent return { - harness: { ctx, handles, parent, workspace, env }, + harness: { ctx, handles, spawnSpecs, parent, workspace, env, executable }, fixture, } } @@ -195,7 +207,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { expect(sdkPackage.version).toBe('0.3.220') expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') - const version = await execFileAsync(claudeBin, ['--version'], { + const version = await execFileAsync(harness.executable, ['--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') @@ -212,6 +224,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') + expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 8c4ac1708d..423841a9d4 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -248,6 +248,7 @@ function fakeRun( const options: FakeRun['options'] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: { ANTHROPIC_API_KEY: 'fake-key' }, disposeGraceMs: 5, spawn: (spawnSpec) => { @@ -331,6 +332,8 @@ describe('task admission and package contracts', () => { const child = fakeChild() const spawn = vi.spyOn(ctx.subprocess, 'spawn') .mockImplementation(() => child.handle) + const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable') + .mockResolvedValue('/native/claude') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) await ctx.plugin(claudeCode, { env: { @@ -352,6 +355,11 @@ describe('task admission and package contracts', () => { ) expect(queryMock).not.toHaveBeenCalled() + resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) + await expect(ctx.subagents.start('claude-code', request())) + .rejects.toThrow('claude missing from PATH') + expect(queryMock).not.toHaveBeenCalled() + const run = await ctx.subagents.start('claude-code', request()) child.settle({ exitCode: 9, signal: null }) child.stdout.end() @@ -362,6 +370,13 @@ describe('task admission and package contracts', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining( 'subagent-claude-code: child run failed (error):', )) + expect(resolveExecutable).toHaveBeenCalledWith( + 'claude', + expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }), + expect.any(AbortSignal), + ) + expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable) + .toBe('/native/claude') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: process.cwd(), graceMs: 29, @@ -441,6 +456,19 @@ describe('official spawn projection', () => { )).toThrow('SDK spawn request omitted its workspace') }) + it.each(['cmd', 'bat'])('routes a Windows .%s shim through cmd.exe', (extension) => { + const command = String.raw`C:\Program Files\Claude\claude.${extension}` + const spec = claudeSpawnSpec(sdkSpawnOptions({ + command, + args: ['--output-format', 'stream-json'], + }), 7, 'win32') + + expect(spec.argv).toEqual([ + 'cmd.exe', '/d', '/s', '/c', command, + '--output-format', 'stream-json', + ]) + }) + it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { const child = fakeChild({ exitOnTerminate: false }) const process = new ManagedClaudeCodeProcess(child.handle) @@ -508,6 +536,7 @@ describe('query options and result mapping', () => { const captured: SubprocessHandle[] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: { HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', @@ -523,6 +552,7 @@ describe('query options and result mapping', () => { expect(options).toMatchObject({ abortController: controller, cwd: '/workspace', + pathToClaudeCodeExecutable: '/native/claude', persistSession: false, disallowedTools: ['AskUserQuestion'], }) @@ -670,6 +700,7 @@ describe('run publication, cancellation, and settlement', () => { let index = 0 const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, @@ -720,6 +751,7 @@ describe('run publication, cancellation, and settlement', () => { request(undefined, parentAbort.signal), { cwd: '/workspace', + executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => child.handle, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index cbcdb12584..1880fc4359 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent/subagent-codex/README.md -README.md: c75a98550894f180d0f37e9cdd135b961488f726 -README.zh.md: 0dd3280b363c1a26250aa00cfeba5398114a2e3e +README.md: f10ebe0448b2942e2cad8efecb6be4681cf601a6 +README.zh.md: ef107577afcdc81a64ea46b2e996d46a562d1505 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index c75a985508..f10ebe0448 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. -Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default. +Shipped profiles load this provider once on the host and start no Codex process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. A custom host composition can still use both rows directly. ```yaml - id: subagent-codex @@ -38,6 +38,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 0dd3280b36..ef107577af 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -27,7 +27,7 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 -请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`。 +随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Codex 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。自定义宿主组装仍可直接使用两条配置行。 ```yaml - id: subagent-codex @@ -38,6 +38,7 @@ - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea741e98c1..d98cc8c053 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1226,6 +1226,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:^ + version: link:../../subagent/subagent-claude-code + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:^ + version: link:../../subagent/subagent-codex '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../subagent/subagent-fork From 37aab00c24d46b05f78ec3c1d812037624bc90c0 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 12:46:06 +0800 Subject: [PATCH 003/105] cleanup(subagent): remove stale lint suppression --- packages/subagent/subagent-claude-code/src/run.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 6c1e0a8dbf..f4600f4bc9 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -258,7 +258,6 @@ export async function startClaudeCodeRun( ) } } - // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } From 7ba4244d6671d5b7b2fbcb5904dab70a1e3dc5a8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 13:11:22 +0800 Subject: [PATCH 004/105] fix(subagent): retain cancellation lint rationale --- packages/subagent/subagent-claude-code/src/run.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index f4600f4bc9..6c1e0a8dbf 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -258,6 +258,7 @@ export async function startClaudeCodeRun( ) } } + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } From aee58c73e0ea69c6019283744bf03ec68aa06432 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 13:29:23 +0800 Subject: [PATCH 005/105] test(subagent): compare Windows Claude paths case-insensitively --- .../subagent-claude-code/tests/real-product.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 213166861b..6048c930de 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -224,7 +224,12 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') - expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) + const spawnedExecutable = harness.spawnSpecs[0]?.argv[0] + expect(spawnedExecutable).toBeDefined() + if (spawnedExecutable !== undefined) { + expect(process.platform === 'win32' ? spawnedExecutable.toLowerCase() : spawnedExecutable) + .toBe(process.platform === 'win32' ? harness.executable.toLowerCase() : harness.executable) + } expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! From 9f6ec0ead6e21f368c8ff08d048ae2f73505f326 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 13:34:10 +0800 Subject: [PATCH 006/105] test(subagent): exercise native Windows Claude shim --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +-- ...claude-code-and-codex-subagent-backends.md | 4 --- ...ude-code-and-codex-subagent-backends.zh.md | 4 --- apps/cli/tests/web-agent-presets.e2e.ts | 18 ------------- .../tests/real-product.spec.ts | 25 +++++++++++-------- 5 files changed, 16 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 2a66f8d5e4..8c39ed7525 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 783a8a6537d2b05ab99f1962bda88787e2e4e938 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f331dc698001e24df62733d219f28b59d2358fac +2026-08-04-claude-code-and-codex-subagent-backends.md: eb4c4ab0116cdf1e8b9e6dd53655035a78afdfa0 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c14b7977a267c2b7325e4a4382593c8f30dddea4 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 783a8a6537..eb4c4ab011 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -79,10 +79,6 @@ The project owner's distribution authorization is scoped to the official `@anthr **A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. -**Global product enable settings and a product-specific Web page.** Those controls make Codex and Claude Code exceptions to the Agent Preset composition that already owns one agent's tool set, and one process-wide choice cannot represent two sessions using different presets. The host always supplies the providers; the preset alone decides which fixed tools its agent receives. - -**One shipped preset per product combination.** Four preset variants encode a two-boolean choice in preset identities and multiply every future standard-preset change. Independent ordinary rows express the same result in the user's copied preset without adding a roster taxonomy. - **Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. **Plugin-managed login, product home, models, settings, or permissions.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index f331dc6980..c14b7977a2 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -79,10 +79,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 **面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 -**全局产品启用设置与产品专属 Web 页面。** 这类控制会让 Codex 与 Claude Code 成为 Agent Preset 组装的例外,而后者本就拥有单个 agent 的工具集;一个进程级选择也无法表达两个会话使用不同 preset。宿主始终提供 provider,只有 preset 决定其 agent 获得哪些固定工具。 - -**为每种产品组合交付一份 preset。** 四个 preset 变体把两个布尔选择编码成 preset 身份,并让未来每次标准 preset 修改都要同步多份副本。用户复制的 preset 中两条独立普通行已经能表达同一结果,无需新增名单分类。 - **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 **由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 8f54d6ddf6..4e0dcc3a1d 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -132,24 +132,6 @@ describe('the shipped Web composition', () => { expect(ctx.agentPresets.defaultId).toBe('standard') }) - it('keeps product providers on the host while shipped presets leave their tools disabled', async () => { - expect(ctx.subagents.list()).toEqual(expect.arrayContaining([ - 'spawn', 'fork', 'codex', 'claude-code', - ])) - - const handle = await ctx.agents.create({ - sessionId: SessionId('preset-products-disabled'), - setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), - }) - try { - expect(toolNames(ctx, handle.agent)).not.toEqual(expect.arrayContaining([ - 'subagent_codex', 'subagent_claude_code', - ])) - } finally { - await handle.dispose() - } - }) - it('composes the full agent from `standard`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-standard'), diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 6048c930de..d7555b9ed8 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -1,6 +1,5 @@ import { execFile } from 'node:child_process' import { - copyFileSync, mkdirSync, mkdtempSync, readFileSync, @@ -116,14 +115,17 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') - const nativeBin = join(root, 'native-bin') + const nativeBin = join(root, 'native bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) mkdirSync(nativeBin) - const executable = join(nativeBin, process.platform === 'win32' ? 'claude.exe' : 'claude') - if (process.platform === 'win32') copyFileSync(claudeBin, executable) - else symlinkSync(claudeBin, executable) + const executable = join(nativeBin, process.platform === 'win32' ? 'claude.cmd' : 'claude') + if (process.platform === 'win32') { + writeFileSync(executable, `@echo off\r\n"${claudeBin}" %*\r\n`) + } else { + symlinkSync(claudeBin, executable) + } writeFileSync( join(claudeConfig, 'settings.json'), `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, @@ -207,7 +209,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { expect(sdkPackage.version).toBe('0.3.220') expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') - const version = await execFileAsync(harness.executable, ['--version'], { + const version = await execFileAsync(process.platform === 'win32' ? claudeBin : harness.executable, ['--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') @@ -224,11 +226,12 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') - const spawnedExecutable = harness.spawnSpecs[0]?.argv[0] - expect(spawnedExecutable).toBeDefined() - if (spawnedExecutable !== undefined) { - expect(process.platform === 'win32' ? spawnedExecutable.toLowerCase() : spawnedExecutable) - .toBe(process.platform === 'win32' ? harness.executable.toLowerCase() : harness.executable) + if (process.platform === 'win32') { + expect(harness.spawnSpecs[0]?.argv.slice(0, 5)).toEqual([ + 'cmd.exe', '/d', '/s', '/c', harness.executable, + ]) + } else { + expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) } expect(fixture.requests).toHaveLength(1) From af1894eaef9f13e7a57d07c60e92701d469f706c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 13:40:29 +0800 Subject: [PATCH 007/105] test(subagent): cover cmd metacharacter install paths --- .../subagent/subagent-claude-code/tests/real-product.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index d7555b9ed8..504d5a5056 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -115,7 +115,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') - const nativeBin = join(root, 'native bin') + const nativeBin = join(root, 'native&bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) From bec5721f0cac545d8a7977000a6549fb9a6bb53c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 13:55:24 +0800 Subject: [PATCH 008/105] fix(subagent): quote Windows Claude batch paths --- ...-claude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...6-08-04-claude-code-and-codex-subagent-backends.md | 4 ++-- ...8-04-claude-code-and-codex-subagent-backends.zh.md | 4 ++-- .../subagent/subagent-claude-code/README.i18n.yaml | 4 ++-- packages/subagent/subagent-claude-code/README.md | 2 +- packages/subagent/subagent-claude-code/README.zh.md | 2 +- packages/subagent/subagent-claude-code/src/process.ts | 11 ++++++++--- .../tests/subagent-claude-code.spec.ts | 5 ++++- 8 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 8c39ed7525..776980f3fc 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: eb4c4ab0116cdf1e8b9e6dd53655035a78afdfa0 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c14b7977a267c2b7325e4a4382593c8f30dddea4 +2026-08-04-claude-code-and-codex-subagent-backends.md: 5bdb041b90f11b13d92d3cbac250d614a51d3a5e +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: ad1ff7f32de56b07967163aba8d511145b551da4 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index eb4c4ab011..5bdb041b90 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -47,7 +47,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe` as a quoted per-spawn environment expansion, so path metacharacters remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing a cmd metacharacter. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index c14b7977a2..ad1ff7f32d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -47,7 +47,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe`,因此路径元字符仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于含 cmd 元字符路径中的真实 Windows batch shim。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 4f62110d49..0f8ec86943 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent/subagent-claude-code/README.md -README.md: 5e3138b9211b01de9096fa1b8e8b68321aad0c7d -README.zh.md: cc536d13d58e08efc77f4f7b4374c8a1af8c5caa +README.md: 432a73474fee1c0cd3a3046247252a9b48cf865c +README.zh.md: 8af6f5050de44c01391662b4f92676711ef8892f diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 5e3138b921..432a73474f 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe` expands once, so valid path metacharacters remain data while the SDK's fixed arguments stay ordinary argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index cc536d13d5..8af6f5050d 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe` 展开一次,因此合法路径中的元字符仍只是数据,而 SDK 的固定参数继续使用普通 argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 6200ac559b..4fc73cf6ae 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -17,6 +17,8 @@ import { type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' +const WINDOWS_BATCH_EXECUTABLE_ENV = 'DSH_CLAUDE_CODE_EXECUTABLE' + function thrown(value: unknown): Error { /* v8 ignore next -- the subprocess seam rejects with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -53,16 +55,19 @@ export function claudeSpawnSpec( throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') } const extension = extname(options.command).toLowerCase() - const argv = platform === 'win32' && (extension === '.cmd' || extension === '.bat') - ? ['cmd.exe', '/d', '/s', '/c', options.command, ...options.args] + const batchShim = platform === 'win32' && (extension === '.cmd' || extension === '.bat') + const env = sdkEnvironmentOverlay(options.env) + const argv = batchShim + ? ['cmd.exe', '/d', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] : [options.command, ...options.args] + if (batchShim) env[WINDOWS_BATCH_EXECUTABLE_ENV] = `"${options.command}"` return { argv, cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env: sdkEnvironmentOverlay(options.env), + env, } } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 423841a9d4..7e1bf7ea05 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -464,9 +464,12 @@ describe('official spawn projection', () => { }), 7, 'win32') expect(spec.argv).toEqual([ - 'cmd.exe', '/d', '/s', '/c', command, + 'cmd.exe', '/d', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', '--output-format', 'stream-json', ]) + expect(spec.env).toEqual(expect.objectContaining({ + DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`, + })) }) it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { From d507894d43e5129db2681167207ad6de9b0a8058 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 14:12:22 +0800 Subject: [PATCH 009/105] fix(subagent): disable delayed expansion for Claude shims --- ...8-04-claude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- .../2026-08-04-claude-code-and-codex-subagent-backends.md | 4 ++-- ...26-08-04-claude-code-and-codex-subagent-backends.zh.md | 4 ++-- packages/subagent/subagent-claude-code/README.i18n.yaml | 4 ++-- packages/subagent/subagent-claude-code/README.md | 2 +- packages/subagent/subagent-claude-code/README.zh.md | 2 +- packages/subagent/subagent-claude-code/src/process.ts | 2 +- .../subagent-claude-code/tests/real-product.spec.ts | 8 +++++--- .../tests/subagent-claude-code.spec.ts | 2 +- 9 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 776980f3fc..cb5c2c6e14 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 5bdb041b90f11b13d92d3cbac250d614a51d3a5e -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: ad1ff7f32de56b07967163aba8d511145b551da4 +2026-08-04-claude-code-and-codex-subagent-backends.md: 40c622d85b427e2e85c160eb956aeae7d384ca65 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 85e71edb9139764a244277f731849d14ab1230d7 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 5bdb041b90..40c622d85b 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -47,7 +47,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe` as a quoted per-spawn environment expansion, so path metacharacters remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing a cmd metacharacter. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index ad1ff7f32d..85e71edb91 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -47,7 +47,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe`,因此路径元字符仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于含 cmd 元字符路径中的真实 Windows batch shim。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 0f8ec86943..05d8cd5705 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent/subagent-claude-code/README.md -README.md: 432a73474fee1c0cd3a3046247252a9b48cf865c -README.zh.md: 8af6f5050de44c01391662b4f92676711ef8892f +README.md: 7b19dc8e8f0e4bf05097dae15c9455f30bc7c998 +README.zh.md: 0dff24816024c0c44b9bdb532b5c7a3e5656fb3b diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 432a73474f..7b19dc8e8f 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe` expands once, so valid path metacharacters remain data while the SDK's fixed arguments stay ordinary argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data while the SDK's fixed arguments stay ordinary argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 8af6f5050d..0dff248160 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe` 展开一次,因此合法路径中的元字符仍只是数据,而 SDK 的固定参数继续使用普通 argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据,而 SDK 的固定参数继续使用普通 argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 4fc73cf6ae..03b9cfd2b3 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -58,7 +58,7 @@ export function claudeSpawnSpec( const batchShim = platform === 'win32' && (extension === '.cmd' || extension === '.bat') const env = sdkEnvironmentOverlay(options.env) const argv = batchShim - ? ['cmd.exe', '/d', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] + ? ['cmd.exe', '/d', '/v:off', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] : [options.command, ...options.args] if (batchShim) env[WINDOWS_BATCH_EXECUTABLE_ENV] = `"${options.command}"` return { diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 504d5a5056..7e1dae96d9 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -115,7 +115,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') - const nativeBin = join(root, 'native&bin') + const nativeBin = join(root, 'native&%literal%!bang!bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) @@ -227,9 +227,11 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { ) expect(initMessage?.claude_code_version).toBe('2.1.220') if (process.platform === 'win32') { - expect(harness.spawnSpecs[0]?.argv.slice(0, 5)).toEqual([ - 'cmd.exe', '/d', '/s', '/c', harness.executable, + expect(harness.spawnSpecs[0]?.argv.slice(0, 6)).toEqual([ + 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', ]) + expect(harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE) + .toBe(`"${harness.executable}"`) } else { expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 7e1bf7ea05..4fe2d709b1 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -464,7 +464,7 @@ describe('official spawn projection', () => { }), 7, 'win32') expect(spec.argv).toEqual([ - 'cmd.exe', '/d', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', + 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', '--output-format', 'stream-json', ]) expect(spec.env).toEqual(expect.objectContaining({ From eaf9c09d96352657918dab7c9a4e95147bff0535 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 14:22:20 +0800 Subject: [PATCH 010/105] test(subagent): normalize Windows Claude path casing --- .../subagent-claude-code/tests/real-product.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 7e1dae96d9..2a50d560a4 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -230,8 +230,11 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { expect(harness.spawnSpecs[0]?.argv.slice(0, 6)).toEqual([ 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', ]) - expect(harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE) - .toBe(`"${harness.executable}"`) + const batchExecutable = harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE + expect(batchExecutable?.startsWith('"')).toBe(true) + expect(batchExecutable?.endsWith('"')).toBe(true) + expect(batchExecutable?.slice(1, -1).toLowerCase()) + .toBe(harness.executable.toLowerCase()) } else { expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) } From 517367854331f6a9c87f05e746abb783a6badadb Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 14:45:34 +0800 Subject: [PATCH 011/105] review: symmetric policy-service type imports, drop stale inprocess peers, pin child-switch and fork-default cases - child-agent.ts declares both policy-service augmentations as explicit empty type imports, so removing the ApprovalPolicy import cannot silently degrade ctx.get('approval') typing. - dsh-subagent-inprocess no longer consumes the policy services in src, so its optional peers and tsconfig references are dropped; both policy-inheritance Agent Notes state the current ownership. - The continuable suite pins that a later child-side switch beats the delegation snapshot and that an unswitched fork parent seeds no policy events. --- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +-- .../2026-07-25-subagent-policy-inheritance.md | 2 +- ...26-07-25-subagent-policy-inheritance.zh.md | 2 +- ...able-subagent-policy-inheritance.i18n.yaml | 4 +-- ...continuable-subagent-policy-inheritance.md | 2 +- ...tinuable-subagent-policy-inheritance.zh.md | 2 +- .../subagent/subagent-inprocess/package.json | 10 ------ .../subagent/subagent-inprocess/tsconfig.json | 6 ---- packages/subagent/subagent/src/child-agent.ts | 3 ++ .../tests/continuation-inheritance.spec.ts | 36 +++++++++++++++++++ 10 files changed, 47 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 616a45e1d1..48074dd7bf 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: a2f4d578de857ee63df5e7741c433210d5f1ef86 -2026-07-25-subagent-policy-inheritance.zh.md: f069bf290586447afc0b7d46a41ad4de9bfcbe8f +2026-07-25-subagent-policy-inheritance.md: 910581a595f48b356eea9c6242a06159c52b3854 +2026-07-25-subagent-policy-inheritance.zh.md: a0edb3c6beb59a9fe8fdfb801ee718f7c034c296 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index a2f4d578de..910581a595 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -33,4 +33,4 @@ A confined child gets the ordinary denial marker. No answerer currently owns an - Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. - The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. -- Each delegation adds at most two log-only events. `dsh-subagent` and `dsh-subagent-inprocess` have optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. +- Each delegation adds at most two log-only events. `dsh-subagent` owns the optional peer types for the two policy services — its shared helpers hold the `ctx.get` consumption; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index f069bf2905..a0edb3c6be 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -33,4 +33,4 @@ Status: implemented - spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 - 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 -- 每次委派最多增加两条仅日志事件。`dsh-subagent` 和 `dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 +- 每次委派最多增加两条仅日志事件。两个策略服务的可选 peer 类型由 `dsh-subagent` 拥有——其共享辅助函数持有 `ctx.get` 消费;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml index dc23421912..54bc9adfb4 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.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-08-10-continuable-subagent-policy-inheritance.md -2026-08-10-continuable-subagent-policy-inheritance.md: 39df910a920e6995ba6048fdd2613d2c216f5ec2 -2026-08-10-continuable-subagent-policy-inheritance.zh.md: 2a977eaa9aade189213fdd20a7d888e8e62efb48 +2026-08-10-continuable-subagent-policy-inheritance.md: 04bcd0329a4608445b672d75b6c1e56dd265b25a +2026-08-10-continuable-subagent-policy-inheritance.zh.md: 9ef457df814ed848b04f42c5891024a0890bc9dd diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md index 39df910a92..04bcd0329a 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md @@ -24,6 +24,6 @@ The capture/append pair moved from the one-shot driver into the seam's shared ch ## Consequences - Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox and approval overrides; compositions without either policy service behave unchanged. -- `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` keeps its optional peers but delegates to the shared helpers. +- `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` drops its policy-service peers and type imports entirely and delegates to the shared helpers. - The continuable suite (`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`) pins fresh-start seeding, pre-await capture, default omission, cold-resume snapshot stability, and fork-seed precedence; the ACP snapshot scenario `subagent-continuable-inheritance` pins the child's delegation event and read-only runtime context through the assembled app and fails when the capture is removed. - Out-of-process providers (`acp`, `dsh-sdk`, `claude-code`, `codex`) support no continuable children (`prepareContinuable` absent), and their one-shot children keep their own deployment policy (`inheritsParentContext = false`); cross-process policy propagation remains out of scope. diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md index 2a977eaa9a..9ef457df81 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md @@ -24,6 +24,6 @@ Status: implemented ## 后果 - 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱与审批覆盖项;未组合任一策略服务的组合保持原有行为。 -- `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 保留自己的可选 peer,但委托给共享辅助函数。 +- `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 完全移除自己的策略服务 peer 与类型导入,委托给共享辅助函数。 - 可继续测试套件(`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`)锁定全新启动的种子写入、await 前捕获、默认值省略、冷恢复快照稳定性与 fork 种子优先级;ACP 快照场景 `subagent-continuable-inheritance` 经组装后的应用锁定子级的委派事件与只读运行时上下文,移除捕获时即失败。 - 进程外提供方(`acp`、`dsh-sdk`、`claude-code`、`codex`)不支持可继续子 agent(没有 `prepareContinuable`),其一次性子 agent 保留自身的部署策略(`inheritsParentContext = false`);跨进程策略传播仍不在范围内。 diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index e18752db74..2bdbe85234 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -28,22 +28,12 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^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-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, - "peerDependenciesMeta": { - "@deepseek-ai/dsh-sandbox-policy": { - "optional": true - }, - "@deepseek-ai/dsh-user-approval": { - "optional": true - } - }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index 23406e362e..02fd8e53d0 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -32,14 +32,8 @@ { "path": "../../core/tools" }, - { - "path": "../../sandbox/sandbox-policy" - }, { "path": "../../support/invariants" - }, - { - "path": "../../interaction/user-approval" } ] } diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index cb4e8fbd97..878b831dd5 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -17,7 +17,10 @@ import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve // to the policy services when composed — delegation consumes both // opportunistically (the documented `ctx.get` pattern), never as a hard dep. +// The user-approval side stays an explicit empty import so its augmentation +// does not ride the `ApprovalPolicy` import above. import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-user-approval' import { delegationDepthOf } from './depth.ts' /** Thrown when starting a child would exceed the requested depth cap. */ diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 246bafa1ed..92cefa261e 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -124,6 +124,42 @@ describe('continuable policy inheritance', () => { expect(policyEvents(loaded.events)).toEqual([]) }) + it('does not freeze deployment defaults into an unswitched fork child either', async () => { + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')]) + parent.followup(createUserMessage({ + content: [{ type: 'text', text: 'parent work' }], + source: { kind: 'user' }, + })) + await parent.whenIdle() + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.seedLength).toBeGreaterThan(0) + expect(policyEvents(loaded.events)).toEqual([]) + }) + + it('lets a later child-side switch win over the delegation snapshot', async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + setSandboxMode(parent.session, 'danger-full-access') + let child: Agent | undefined + ctx.on('agent/created', ({ agent }) => { + if (agent !== parent) child = agent + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + if (child === undefined) throw new Error('expected the continuable child to be created') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access') + // Last event wins: the child's own runtime switch beats the seeded snapshot. + setSandboxMode(child.session, 'read-only') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) + it('cold-resumes on the persisted snapshot without re-capturing the parent', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) setSandboxMode(parent.session, 'read-only') From f9b7a31ee287f9c4eec733acf3d80032a53d71a1 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 14:52:16 +0800 Subject: [PATCH 012/105] docs: regenerate module graph for moved policy-service edges --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 9 +++++---- docs/module-graph.zh.md | 9 +++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 54453b3411..8fa40f2e65 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: c41db02165740b19a9ef751e6f50316a76df28c3 -module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa +module-graph.md: 58e4ce7c23de28a85bf140b426b4ca43d8d18cf7 +module-graph.zh.md: f8605d0c94023fcf51c38e6a8d4cca80ae436220 diff --git a/docs/module-graph.md b/docs/module-graph.md index c41db02165..58e4ce7c23 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -809,6 +809,8 @@ flowchart TD pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence @@ -816,6 +818,7 @@ flowchart TD pkg_subagent --> pkg_session_projection_cache pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -999,12 +1002,10 @@ flowchart TD pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm - pkg_subagent_inprocess --> pkg_sandbox_policy pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools - pkg_subagent_inprocess --> pkg_user_approval pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm @@ -1366,7 +1367,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1397,7 +1398,7 @@ flowchart TD | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9071dbc0f2..f8605d0c94 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -811,6 +811,8 @@ flowchart TD pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence @@ -818,6 +820,7 @@ flowchart TD pkg_subagent --> pkg_session_projection_cache pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -1001,12 +1004,10 @@ flowchart TD pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm - pkg_subagent_inprocess --> pkg_sandbox_policy pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools - pkg_subagent_inprocess --> pkg_user_approval pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm @@ -1368,7 +1369,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1399,7 +1400,7 @@ flowchart TD | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | From 14dc8cd349ff03c551fe91bbfab7ad6ae1ac01ca Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 16:57:56 +0800 Subject: [PATCH 013/105] docs(agent-presets): record shared product provider placement --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 2 +- ...ubagent-providers-in-shared-host.i18n.yaml | 6 +++ ...oduct-subagent-providers-in-shared-host.md | 41 +++++++++++++++++++ ...ct-subagent-providers-in-shared-host.zh.md | 41 +++++++++++++++++++ ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 6 +-- ...ude-code-and-codex-subagent-backends.zh.md | 8 ++-- examples/acp-agent/tests/acp.snapshot.ts | 4 ++ packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 1 + packages/bundle/base/README.zh.md | 1 + .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 5 ++- .../subagent-claude-code/README.zh.md | 5 ++- .../subagent-claude-code/src/process.ts | 2 + .../tests/real-product.spec.ts | 2 +- 18 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 1f9868917b..95b171d364 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: ca3e6967504ac62b1d79ec28ef9dbd4bf8383bac -2026-08-03-per-session-agent-presets.zh.md: 94651d465939d760554ed8637376ae77cfae6812 +2026-08-03-per-session-agent-presets.md: 98ff20d1c0e6e5c0369a064790880438a67b2992 +2026-08-03-per-session-agent-presets.zh.md: 2845db46cbdaa64dcfaeda90cfed69325ef251e8 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index ca3e696750..98ff20d1c0 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -55,7 +55,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. -**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the fixed Codex and Claude Code product providers, are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. **A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 94651d4659..2845db46cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -56,7 +56,7 @@ Status: implemented **创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 -**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括固定的 Codex 与 Claude Code 产品 provider,都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 **真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml new file mode 100644 index 0000000000..b696b46749 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +2026-08-10-product-subagent-providers-in-shared-host.md: 33b6eb6cf7a6c19e9ea71cdb7dc8881e8052ef24 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: fd78c7a3fee4e4ee30d27d87c752e1a23576fd85 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md new file mode 100644 index 0000000000..33b6eb6cf7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -0,0 +1,41 @@ +# Agent Note: Product subagent providers live in the shared profile host + +Status: implemented + +English | [中文](2026-08-10-product-subagent-providers-in-shared-host.zh.md) + +## Problem + +The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself. + +The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while enabling a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts. + +## Decision + +Every shipped Profile loads the fixed `codex` and `claude-code` providers once through the base bundle's host plane. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows for `subagent_codex` and `subagent_claude_code`, so a preset can expose neither tool, either one, or both without changing the provider registry. + +This decision supersedes only the opt-in composition placement recorded by the provider-contract note. That note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. + +The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, test authentication, or add product-specific settings. Missing commands and product failures remain local to the attempted delegation. + +The current base dependency closure still includes the Claude Agent SDK's optional platform CLI payload even though production resolves the host `claude`. Removing that unused payload belongs to the separate product installation-closure follow-up; this placement decision neither installs it dynamically nor treats it as the production executable. + +## Verification + +The base Loader test proves both provider names register exactly once and no product process starts during Profile boot. Real Agent Preset composition covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Keyless ACP snapshots pin the model-visible tool schemas for one and both products, while provider tests separately prove native executable resolution, failure, cancellation, and process-tree quiescence. + +## Alternatives considered + +**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure, but a copied or agent-authored Preset row is not usable unless the person also discovers and edits a second composition layer. It leaves the general Preset entry incomplete for these otherwise ordinary tools. + +**Store global or per-Profile product enable switches.** A process switch competes with the Preset as owner of model-visible tools and cannot express two sessions using different combinations. Availability and authentication are deployment facts, not another persisted product state. + +**Mount a provider inside every Agent Preset.** Provider names belong to a process registry, so the second session would collide with the first. Host consumers also need the registry independently of any one agent's lifetime. + +**Ship four product-combination presets.** Four identities duplicate complete compositions to represent two independent tool rows. Ordinary rows already express the full matrix without adding roster or maintenance state. + +## Consequences + +A user manages both products through the same Agent Preset authoring path as other plugins, and each new session receives exactly the tools its chosen preset contributes. Every Profile carries two dormant provider registrations, so unused products consume package and module-loading footprint but no product process, login, model call, or product home. + +The Host registry remains the single provider authority and each Preset remains the single model-tool authority. The trade-off is the current Claude SDK optional-payload installation cost, which stays explicitly deferred rather than being hidden behind another enable state or installer lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md new file mode 100644 index 0000000000..fd78c7a3fe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 产品 subagent 提供方位于共享 profile 宿主 + +Status: implemented + +[English](2026-08-10-product-subagent-providers-in-shared-host.md) | 中文 + +## 问题 + +[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 行本身不完整。 + +归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 + +## 决策 + +每个随发行版交付的 Profile 都会通过 base 组合包的宿主平面,把固定的 `codex` 与 `claude-code` 提供方各加载一次。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不暴露任何工具、只暴露其中一个或同时暴露两者,而无需更改提供方注册表。 + +本决策仅取代提供方约定说明所记录的、原先由用户选择启用的组装位置。该说明仍负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 + +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。 + +当前 base 依赖闭包仍包含 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷,尽管生产环境解析的是宿主提供的 `claude`。移除这份未使用载荷属于独立的产品安装闭包后续项;本归属决策既不会动态安装它,也不会将它当作生产可执行文件。 + +## 验证 + +base Loader 测试证明两个提供方名称都恰好注册一次,而且 Profile 启动期间不会启动产品进程。真实 Agent Preset 组装覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。无密钥 ACP(Agent Client Protocol)快照固定单个产品与两个产品同时启用时的模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。 + +## 考虑过的替代方案 + +**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但复制或由 agent 创作的 Preset 行无法直接使用,除非用户还发现并编辑第二个组装层。对于这些本来与其他工具无异的工具,通用 Preset 入口仍不完整。 + +**存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 Preset 争夺模型可见工具的责任归属,也无法表示两个会话使用不同组合。可用性与身份验证属于部署事实,并非另一份需要持久化的产品状态。 + +**在每个 Agent Preset 内挂载一个提供方。** 提供方名称属于进程级注册表,因此第二个会话会与第一个冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。 + +**交付四个产品组合 preset。** 四个身份会复制完整组装,只为表示两条独立的工具行。普通行已经能表达完整矩阵,无需新增名单或维护状态。 + +## 后果 + +用户通过与其他插件相同的 Agent Preset 创作路径管理两个产品,每个新会话只会获得其所选 preset 所贡献的工具。每个 Profile 都携带两个休眠的提供方注册,因此未使用的产品会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录。 + +宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是当前 Claude SDK 可选载荷的安装成本继续被明确延期处理,而不会隐藏在另一种启用状态或安装程序生命周期之后。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index cb5c2c6e14..84cb091651 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 40c622d85b427e2e85c160eb956aeae7d384ca65 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 85e71edb9139764a244277f731849d14ab1230d7 +2026-08-04-claude-code-and-codex-subagent-backends.md: ccc96d6c998c4ab958a7eea1e502d036d16ec90d +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 740eeb633e336d5b01cb0b84fb656612e690959d diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 40c622d85b..ccc96d6c99 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot providers in the shared profile host: `codex` and `claude-code`. Loading the host providers starts no product process. An Agent Preset independently contributes ordinary `dsh-tool-subagent` rows when its agent should see `subagent_codex`, `subagent_claude_code`, both, or neither; the shipped full presets carry both rows disabled so copies have one accurate configuration template without changing the default model schema. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. +The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [shared-profile-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md) supersedes the original opt-in composition placement. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection and background execution are not model arguments. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -87,7 +87,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users copy or author an Agent Preset and independently enable either or both stable foreground tools. Every profile host supplies the reusable providers once, while each preset owns only its agent's model-visible tool rows. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. +Users delegate through two stable foreground tools backed by the official product integrations. Their Profile placement and per-Preset exposure are owned by the [shared-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md); this note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 85e71edb91..740eeb633e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 在共享 profile 宿主中交付两个一次性兄弟提供方:`codex` 与 `claude-code`。加载宿主提供方不会启动产品进程。某个 Agent Preset 是否让自己的 agent 看见 `subagent_codex`、`subagent_claude_code`、两者或两者皆无,由该 preset 独立贡献普通的 `dsh-tool-subagent` 行;随附的完整 preset 携带两条默认禁用的行,使复制品拥有一份准确配置模板,同时不改变默认模型 schema。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)取代原先由用户选择启用的组装位置。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -79,7 +79,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 **面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 -**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 +**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。 **由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 @@ -87,7 +87,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 ## 后果 -用户可以复制或创作一个 Agent Preset,并分别启用任一或两个稳定前台工具。每个 profile 宿主只提供一次可复用 provider,而每个 preset 只拥有自己 agent 的模型可见工具行。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 +用户通过官方产品集成支持的两个稳定前台工具进行委派。它们在 Profile 中的归属和按 Preset 暴露方式由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b8d67c0c67..02155a41ab 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -124,6 +124,10 @@ const SCENARIOS: Scenario[] = [ // text-turn is the default header pin and owns the prompt and tool-schema // sidecars reused by alternate classes with identical component sequences. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, + // Product-subagent scenarios are authored schema-isolation fixtures: they + // reuse the stable text-turn transcript so only Loader-composed headers and + // tool sidecars vary. Model output and usage are not evidence here, so record + // mode must not replace them with live-API output. { name: 'product-subagent-codex', hasModelTurn: true, diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 7d602ab7fd..efb4592fc8 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: d9dbf717d1d11adce2eae9498d4bfa596a46fd3e -README.zh.md: 9c0d47f7e4abc1b0636bd2097ea3e2bc8c826b81 +README.md: c3fc95b45a4f458387db7e0b4464cfca0ef82c6d +README.zh.md: 56e03c26625af1e34e3fde77ab3e07a6d91ad34a diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index d9dbf717d1..c3fc95b45a 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -19,4 +19,5 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. +- **Claude's SDK platform CLI remains in the Profile install closure** — the base bundle depends on the Claude provider, whose production path resolves the host `claude`; removing the SDK's unused optional payload is deferred to the product installation-closure follow-up. - **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`\dsh-`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 9c0d47f7e4..56e03c2662 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -19,4 +19,5 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 +- **Claude SDK 的平台 CLI(命令行界面)仍在 Profile 安装闭包中**:base 组合包依赖 Claude 提供方,其生产路径解析宿主提供的 `claude`;移除 SDK 中未使用的可选载荷,推迟到产品安装闭包后续项处理。 - **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`\dsh-`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 05d8cd5705..bbb3c9cf1a 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent/subagent-claude-code/README.md -README.md: 7b19dc8e8f0e4bf05097dae15c9455f30bc7c998 -README.zh.md: 0dff24816024c0c44b9bdb532b5c7a3e5656fb3b +README.md: 17b14e847baea3eadda7129b5e49f5e65b668cc8 +README.zh.md: 2f59144d5bd9f26a58773e6dd53909b2b0e8da14 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 7b19dc8e8f..17b14e847b 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data while the SDK's fixed arguments stay ordinary argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. @@ -52,7 +52,7 @@ Shipped profiles load this provider once on the host and start no Claude process ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation; the SDK's platform optional payload remains in the current installation closure and is tracked as a separate distribution follow-up. Required evidence exercises the compatible native product through a keyless loopback path and a credentialed DeepSeek path, while Loader composition proves that both product packages coexist without starting either product. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation. The keyless real-product test uses the SDK-distributed Claude Code 2.1.220 CLI as a deterministic fixture, routed through the same native executable-resolution and Windows batch-shim path; it does not claim compatibility with every independently installed version. Loader composition proves that both product packages coexist without starting either product. The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. @@ -91,6 +91,7 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. - **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. - **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. - **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 0dff248160..2f59144d5b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据,而 SDK 的固定参数继续使用普通 argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 @@ -52,7 +52,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装;SDK 的平台可选载荷仍处于当前安装闭包,并作为独立分发后续项跟踪。强制证据会通过无密钥回环路径与带密钥 DeepSeek 路径运行兼容的原生产品,而 Loader 组合则证明两个产品包能够共存且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装。无密钥真实产品测试使用由 SDK 分发的 Claude Code 2.1.220 CLI 作为确定性 fixture(测试前置数据),并通过同一套原生可执行文件解析路径与 Windows batch shim 路径运行;这项测试不声称兼容每个独立安装的版本。Loader 组合证明两个产品包能够共存且不会启动任一产品。 项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -91,6 +91,7 @@ Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 - **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 - **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 03b9cfd2b3..1e2a259ca2 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -45,6 +45,8 @@ export function sdkEnvironmentOverlay( * @param graceMs - process-tree termination grace. * @param platform - host platform selecting the Windows batch-shim boundary. * @returns the fully explicit shared subprocess request. + * @remarks The batch-shim path quotes only the resolved executable. The pinned SDK + * supplies fixed flag arguments without cmd metacharacters; cmd reparses that tail. */ export function claudeSpawnSpec( options: SpawnOptions, diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 2a50d560a4..7c44d000e8 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -196,7 +196,7 @@ function startRequest( }) } -describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { +describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 fixture', { timeout: 60_000, }, () => { it('inherits host settings and sends the exact task and fake key to local Messages', async () => { From e53f44865065d4583c109c2bfea64439d9c9bd27 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 17:46:34 +0800 Subject: [PATCH 014/105] fix(subagent): compose children from their parent's preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool and prompt-section visibility is inherited along dsh-scope's parent chain, and an agent's scope key is minted with no parent. Per-session agent presets moved every model-facing row onto the agent plane and made AgentPresets.mount() the one thing that binds that link, from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone — which is empty wherever a preset roster is composed. One-shot children reached the model with no tools, continuable ones with only the host-plane `report`, and neither carried its parent's persona, workspace context, or skill catalog. AgentPresets.composeFrom() joins one agent to the standing composition another already runs on. It is a bind, not a mount: the child gets its parent's exact generation, so a composition edited since the parent started cannot fork it onto another one, and it is synchronous, which is what lets a child creation window use it. applyChildComposition() now takes the parent and performs the join first, making a child composed without it unrepresentable at the call sites. childSessionMeta() records the joined id so a cold read rebuilds the composition the child actually ran under. The audit that followed found two api-proxy readers on the wrong authority: presenterScopeFor() and the live-agent branch of assertPresetUnchanged() both read header.agentPreset, which goes stale the moment a blank session switches preset. A switched session's cold transcript resolved presenters in the older composition's layer and silently degraded to generic cards, and the gateway refused to adopt a live session under the preset it actually runs while accepting the one it left. Both now resolve through resolveSessionPreset(), matching the resume branch fifteen lines above. The owning architecture Agent Note carried the stale claim that the header records what a session runs; it is corrected to name the header/log pair and its three readers. Fixes #2165 --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 2 +- ...-agents-join-their-parent-preset.i18n.yaml | 6 + ...0-child-agents-join-their-parent-preset.md | 47 +++++++ ...hild-agents-join-their-parent-preset.zh.md | 47 +++++++ apps/cli/package.json | 1 + apps/cli/tests/web-agent-presets.e2e.ts | 54 ++++++++ docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 37 ++++++ docs/subsystems/core.zh.md | 37 ++++++ packages/host/apiproxy/src/api-proxy.ts | 24 ++-- .../tests/api-proxy-agent-preset.spec.ts | 39 ++++++ .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 10 ++ packages/preset/agent-presets/README.zh.md | 10 ++ packages/preset/agent-presets/src/index.ts | 54 +++++++- packages/preset/agent-presets/src/mount.ts | 36 ++++-- .../preset/agent-presets/tests/mount.spec.ts | 73 +++++++++++ .../tool-cordis/src/api-catalog.ts | 8 ++ .../subagent/subagent-inprocess/package.json | 3 + .../subagent/subagent-inprocess/src/index.ts | 2 +- .../tests/fixtures/plugins/preset-tool.js | 20 +++ .../fixtures/presets/coding/agent.cordis.yml | 5 + .../tests/preset-inheritance.spec.ts | 116 ++++++++++++++++++ packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 + packages/subagent/subagent/README.zh.md | 4 + packages/subagent/subagent/package.json | 5 + packages/subagent/subagent/src/child-agent.ts | 47 +++++-- .../subagent/subagent/src/continuation.ts | 2 +- packages/subagent/subagent/tsconfig.json | 3 + pnpm-lock.yaml | 15 +++ 36 files changed, 698 insertions(+), 41 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md create mode 100644 packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js create mode 100644 packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml create mode 100644 packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index f3d763058b..f9e1b3c024 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 6f1643c25008c3363cb10adb7fbff7afeea31cbe -2026-08-03-per-session-agent-presets.zh.md: 7afe9ade5c98fadb96384a7e0acd47531c370e0c +2026-08-03-per-session-agent-presets.md: c39117ab0de001650a95f98ccf3e42f3a5034c92 +2026-08-03-per-session-agent-presets.zh.md: 5e98a2865013a355c134317ded8a4f2ddaccf42c diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 6f1643c250..c39117ab0d 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -31,7 +31,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) ## Consequences -**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session header enforces from the other side — the header records the id a session actually runs, so a resume rebuilds that composition rather than today's default, and the gateway rejects an attempt to adopt a live session under a different one. A snapshot would make the two disagree at exactly the moment the setting changes. +**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than today's default, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes. **A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 7afe9ade5c..5e98a28650 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -31,7 +31,7 @@ Status: implemented ## 后果 -**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session header 从另一侧执行的同一条——header 记录会话实际运行的 id,因此恢复重建的是那份组装而不是当下的默认值,网关也会拒绝把一个活着的会话收编到另一个 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 +**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是当下的默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 **直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml new file mode 100644 index 0000000000..9afec2a879 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +2026-08-10-child-agents-join-their-parent-preset.md: c9917c48d10c2b2515284405ea52aed8b476f8b1 +2026-08-10-child-agents-join-their-parent-preset.zh.md: 09e4de5292b65e50bb3973704fd803c819be9f1c diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md new file mode 100644 index 0000000000..c9917c48d1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -0,0 +1,47 @@ +# Agent Note: Child agents join their parent's preset composition + +Status: implemented + +English | [中文](2026-08-10-child-agents-join-their-parent-preset.zh.md) + +## Problem + +Tool and prompt-section visibility is inherited along `dsh-scope`'s parent chain, and an agent's scope key is minted with no parent. [Per-session agent presets](../architecture/2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane and made `AgentPresets.mount()` the one thing that binds that parent link — from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers compose their children through `applyChildComposition()`, which installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone. + +That layer is now empty in any deployment with a preset roster: the web-app patch layer disables every host-plane tool row. A one-shot child therefore reached the model with zero tools, a continuable child with only the host-plane `report`, and neither carried its parent's persona, workspace context, plan-mode section, or skill catalog. The fork path had already been given the same treatment for the same reason; delegation had not. + +The child's durable header compounded it. `childSessionMeta()` recorded no preset, so a cold read of a child session resolved the deployment default — a tool set the child never ran under, which is exactly what the model-visible ⟺ logged rule exists to prevent. + +## Decision + +`AgentPresets.composeFrom(agentCtx, parentCtx)` joins one agent to the standing composition another already runs on, and returns the preset id joined. It locates the parent's mount through `standingMountFor()` — the agent's key is parented to its preset's standing key, the same relation `serviceForAgent()` reads — and binds the child's key to that same standing key, keeping the binding under the roster's sole re-link authority. A parent that joined no preset yields no join and no error, which is the rosterless deployment: its model-facing rows sit in the host composition, where the child already resolves them through the global layer. + +This is a bind, not a mount, and both differences are load-bearing. The child gets its parent's exact generation, so a composition file edited since the parent started cannot hand the child a different one than its parent's history was produced under, and a preset deleted since cannot fail a child whose parent keeps running. It is also synchronous, which is what lets the child creation windows use it — both in-process drivers compose inside a synchronous `setup`. + +`applyChildComposition(childCtx, parent, composition)` takes the parent and performs the join before applying the child's own registrations. The parameter is the point: it makes composing a child without the join unrepresentable at the call sites, rather than leaving each new driver to remember a second step. `childSessionMeta()` records the joined id through `AgentPresets.composedPreset()`, read from the parent's live scope chain rather than its header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. + +`dsh-subagent` reaches the roster through `ctx.get('agentPresets')` with a type-only import and an optional peer dependency — the documented opportunistic-consumption pattern it already uses for `sandboxPolicy` and `approval`. + +## Alternatives considered + +**Re-mount the parent's preset by id in the child's setup.** Rejected on both semantics and mechanics. It re-reads the roster and re-stats the composition file, so an edit since the parent started forks the child onto a different generation, and a preset deleted since fails the child while its parent runs on. `mount()` is also asynchronous, which the synchronous creation windows cannot accept without restructuring both drivers. + +**Bind the child's key to the PARENT's key rather than to the standing mount.** Rejected because it changes what a child inherits: the parent's own scope layer carries its per-agent restrictions, which would then intersect into every descendant, and a child outliving its parent would hang off a disposed agent's key. Joining the standing mount gives the child its parent's composition and nothing else. + +**Extend the continuable activation setup registry to cover one-shot children.** Rejected because that registry's contribution type is synchronous `(childCtx) => () => void` with per-installation revocation, modelling deployment capabilities that come and go, while a preset join is a one-time bind with no revocation of its own. Widening it would have made the omission possible again for any driver that skipped the registry. + +**Let `dsh-subagent` import `resolveSessionPreset` and mount by the resolved id.** Rejected because it makes the preset roster a hard module edge for a package that must work without one, and it lands back on the remount semantics above. + +**Leave the durable header alone and fix only the live join.** Rejected because the live child and the same child read cold would then disagree about which composition produced its history — the same class of defect, moved rather than fixed. + +## Testing + +`packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank. + +## Consequences + +Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. + +`applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md new file mode 100644 index 0000000000..09e4de5292 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Child agents join their parent's preset composition + +Status: implemented + +[English](2026-08-10-child-agents-join-their-parent-preset.md) | 中文 + +## Problem + +工具与提示段的可见性沿 `dsh-scope` 的父链继承,而 agent 的 scope key 铸造出来时没有父。[逐会话 agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 把所有面向模型的行搬到了 agent 平面,并让 `AgentPresets.mount()` 成为绑定那条父链的唯一途径——调用点在 api-proxy 的会话创建、恢复与 fork 路径上。两个进程内 subagent 驱动通过 `applyChildComposition()` 组装子 agent,而它只安装了逐子 agent 的 persona 与工具限制,于是子 agent 的 scope 链长度为一,其注册表视图只能解析到全局层。 + +在任何配置了 preset roster 的部署里,那一层现在是空的:web-app 补丁层禁用了全部宿主平面工具行。因此一次性子 agent 抵达模型时工具为零,可继续子 agent 只剩宿主平面的 `report`,两者都不带父方的 persona、工作区上下文、plan-mode 段与技能目录。fork 路径此前已因同一理由做过相同处理;委派没有。 + +子 agent 的持久化 header 让问题更进一步。`childSessionMeta()` 不记录任何 preset,于是冷读一个子会话解析到的是部署默认值——一套该子 agent 从未运行过的工具集,而这正是"模型可见 ⟺ 已记录"规则要杜绝的情形。 + +## Decision + +`AgentPresets.composeFrom(agentCtx, parentCtx)` 让一个 agent 加入另一个 agent 已在运行的常驻组装,并返回所加入的 preset id。它通过 `standingMountFor()` 定位父方的挂载——agent 的 key 认父到其 preset 的常驻 key,正是 `serviceForAgent()` 读取的同一关系——再把子 agent 的 key 绑到同一个常驻 key 上,绑定句柄仍归 roster 独有的重链权威持有。未加入任何 preset 的父方不产生加入、也不报错,那就是无 roster 的部署:它面向模型的行位于宿主组装中,子 agent 已经能通过全局层解析到它们。 + +这是认父而非挂载,两处差别都要紧。子 agent 拿到的是父方那个确切的代际,因此父方启动后被编辑过的组装文件不可能把与父方历史所产出时不同的另一个代际交给它,此后被删除的 preset 也不可能让一个父方仍在运行的子 agent 失败。它还是同步的,这正是子 agent 创建窗口能够使用它的前提——两个进程内驱动都在同步的 `setup` 中完成组装。 + +`applyChildComposition(childCtx, parent, composition)` 接收父方,并在应用子 agent 自身注册之前完成加入。这个参数正是要点所在:它让"组装子 agent 却不做该加入"在各调用点无法表达,而不是把第二个步骤留给每个新驱动去记住。`childSessionMeta()` 通过 `AgentPresets.composedPreset()` 记录所加入的 id,该值从父方**活着的** scope 链读取而不是从其 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 + +`dsh-subagent` 以类型级导入加可选 peer 依赖的方式,通过 `ctx.get('agentPresets')` 触达 roster——这正是它对 `sandboxPolicy` 与 `approval` 已在使用的、有明确文档的机会性消费模式。 + +## Alternatives considered + +**在子 agent 的 setup 里按 id 重新挂载父方的 preset。** 语义与机制两方面都不成立而被否决。它会重读 roster 并重新 stat 组装文件,因此父方启动后的一次编辑就会把子 agent 分叉到另一个代际,而此后被删除的 preset 会让子 agent 失败、父方却照常运行。`mount()` 还是异步的,同步的创建窗口无法在不重构两个驱动的前提下接受它。 + +**把子 agent 的 key 绑到**父方的** key 而不是常驻挂载上。** 否决,因为这改变了子 agent 继承的内容:父方自己的 scope 层携带其逐 agent 限制,那些限制会就此与每个后代求交,而活得比父方久的子 agent 会挂在一个已 dispose 的 agent key 上。加入常驻挂载给到子 agent 的是父方的组装,仅此而已。 + +**扩展可继续 activation setup 注册表以覆盖一次性子 agent。** 否决,因为该注册表的贡献类型是同步的 `(childCtx) => () => void` 并带有逐次安装的撤销,建模的是会来会走的部署能力,而 preset 加入是一次性认父、自身没有撤销可言。扩展它反而会让任何绕过该注册表的驱动重新具备遗漏的可能。 + +**让 `dsh-subagent` 导入 `resolveSessionPreset` 并按解析出的 id 挂载。** 否决,因为这会给一个必须在没有 roster 时也能工作的包引入硬模块边,而且最终仍落回上述的重新挂载语义。 + +**只修活着的加入,不动持久化 header。** 否决,因为那样活着的子 agent 与冷读同一个子 agent 会对"哪份组装产出了这段历史"给出不同答案——同一类缺陷,只是被搬了个地方而不是被修掉。 + +## Testing + +`packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方。 + +## Consequences + +委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己的 `toolFilter` 所移除的部分;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 + +`applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。 diff --git a/apps/cli/package.json b/apps/cli/package.json index d312dd28d9..03bd4c3db9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1bfaed8c67..004de203c8 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -11,6 +11,7 @@ import type { PatchOptions } from '@cordisjs/plugin-include' import { beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' @@ -422,6 +423,59 @@ describe('a forked session', () => { }) }) +describe('a delegated child', () => { + it('runs on the composition its parent runs on', async () => { + const parent = await ctx.agents.create({ + sessionId: SessionId('preset-child-parent'), + meta: { agentPreset: 'standard' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + // Exactly what an in-process subagent driver's creation window does. + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('preset-child'), + meta: childSessionMeta(parent.agent, 1, 0), + setup: (agentCtx) => { + applyChildComposition(agentCtx, parent.agent, {}) + }, + }) + try { + expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent)) + // The shipped `standard` preset is the whole coding agent; an empty + // child here is the defect, and equality alone would not catch it. + expect(toolNames(ctx, child.agent)).toContain('bash') + expect(child.agent.session.header.agentPreset).toBe('standard') + } finally { + await child.dispose() + await parent.dispose() + } + }) + + it('follows a parent that switched preset while blank', async () => { + const parent = await ctx.agents.create({ + sessionId: SessionId('preset-child-switch-parent'), + meta: { agentPreset: 'standard' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + await ctx.agentPresets.recompose(parent.agent.ctx, 'minimal') + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('preset-child-switch'), + meta: childSessionMeta(parent.agent, 1, 0), + setup: (agentCtx) => { + applyChildComposition(agentCtx, parent.agent, {}) + }, + }) + try { + // The live scope chain is the authority, not the parent's creation + // header — which still names `standard`. + expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent)) + expect(child.agent.session.header.agentPreset).toBe('minimal') + } finally { + await child.dispose() + await parent.dispose() + } + }) +}) + describe('authoring a preset on the shipped composition', () => { let authorCtx: Context let userRoot: string diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 66486e97b7..b24ae41404 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: a2407f5d394020834172288e3d03518d1e8045db -module-graph.zh.md: 364033c29d773a764ce3f8f0036edeac7c9e0b21 +module-graph.md: 398b49ff2aa795c377abe19bf7a8649078aa0585 +module-graph.zh.md: 5cfcc612b27d64e098a51fda326190fdae1d7e7e diff --git a/docs/module-graph.md b/docs/module-graph.md index a2407f5d39..398b49ff2a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -822,6 +822,7 @@ flowchart TD pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm @@ -1386,7 +1387,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 364033c29d..5cfcc612b2 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -824,6 +824,7 @@ flowchart TD pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm @@ -1388,7 +1389,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 0b2b87a954..7b5ba07cd7 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: af27484160769156836f377e5b3aba2521280005 -core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a +core.md: b66d07194cbdb44037d4ea3a972f4646b7854c52 +core.zh.md: ce95df4d88160e0ffc9c0031a7aad47e72a4b4f1 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index af27484160..b66d07194c 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -419,6 +419,43 @@ async resolve(id?: string): Promise */ async mount(agentCtx: Context, id?: string): Promise +/** + * Join one agent to the SAME standing composition another already runs on. + * + * This is how a child agent inherits its parent's capabilities. It is a bind, + * not a mount: the parent's generation is already composed, so the child gets + * that exact instance — the same plugin objects, the same tool registrations, + * the same prompt sections. Re-resolving the parent's preset by id instead + * would re-read the roster, and a composition file edited since the parent + * started would hand the child a DIFFERENT generation than the one its + * parent's history was produced under (and a preset deleted since would fail + * the child outright while its parent keeps running). + * + * Synchronous and infallible for that reason, which is what lets a child + * creation window use it: the two in-process subagent drivers compose their + * children inside a synchronous `setup`. + * + * A parent that joined no preset — a rosterless deployment — yields no join + * and no error: there, the model-facing rows sit in the host composition and + * the child already sees them through the global layer. + * @param agentCtx - the joining agent's scope context. + * @param parentCtx - the scope context of the agent whose composition to join. + * @returns the preset id joined, or undefined when the parent joined none. + * @throws when `agentCtx` carries no scope, or has already joined a preset. + */ +composeFrom(agentCtx: Context, parentCtx: Context): string | undefined + +/** + * The preset one live agent runs on. + * + * Read from the live scope chain rather than from the session, so it answers + * for an agent whose session has not recorded a preset yet — a child agent + * whose durable header is being built from its parent's composition. + * @param agentCtx - the agent's scope context. + * @returns the preset id, or undefined when the agent joined none. + */ +composedPreset(agentCtx: Context): string | undefined + /** * Read one preset's composition text. * @param id - the preset id. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 12935f4d88..ce95df4d88 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -427,6 +427,43 @@ async resolve(id?: string): Promise */ async mount(agentCtx: Context, id?: string): Promise +/** + * Join one agent to the SAME standing composition another already runs on. + * + * This is how a child agent inherits its parent's capabilities. It is a bind, + * not a mount: the parent's generation is already composed, so the child gets + * that exact instance — the same plugin objects, the same tool registrations, + * the same prompt sections. Re-resolving the parent's preset by id instead + * would re-read the roster, and a composition file edited since the parent + * started would hand the child a DIFFERENT generation than the one its + * parent's history was produced under (and a preset deleted since would fail + * the child outright while its parent keeps running). + * + * Synchronous and infallible for that reason, which is what lets a child + * creation window use it: the two in-process subagent drivers compose their + * children inside a synchronous `setup`. + * + * A parent that joined no preset — a rosterless deployment — yields no join + * and no error: there, the model-facing rows sit in the host composition and + * the child already sees them through the global layer. + * @param agentCtx - the joining agent's scope context. + * @param parentCtx - the scope context of the agent whose composition to join. + * @returns the preset id joined, or undefined when the parent joined none. + * @throws when `agentCtx` carries no scope, or has already joined a preset. + */ +composeFrom(agentCtx: Context, parentCtx: Context): string | undefined + +/** + * The preset one live agent runs on. + * + * Read from the live scope chain rather than from the session, so it answers + * for an agent whose session has not recorded a preset yet — a child agent + * whose durable header is being built from its parent's composition. + * @param agentCtx - the agent's scope context. + * @returns the preset id, or undefined when the agent joined none. + */ +composedPreset(agentCtx: Context): string | undefined + /** * Read one preset's composition text. * @param id - the preset id. diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 5576f2e75c..a1f98d4f28 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -33,6 +33,7 @@ import { PresetNotWritableError, resolveSessionPreset, SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' +import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, @@ -1350,17 +1351,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * The registry view scope a transcript's presenters resolve in. * * A live agent is that scope itself (its chain passes through its preset's - * standing layer). A cold session names its preset on the header, and the + * standing layer). A cold session resolves its preset from the LOG, and the * preset's STANDING key serves without resuming anything — ensuring the * mount composes plugins but starts no agent, session, or turn. No roster, * no recorded preset, or a preset the roster no longer supplies all fall * back to the global layer: the transcript still serves, with the generic * cards a viewless entry renders. + * + * Reading the header alone would render a session that switched while blank + * through the composition it was CREATED with. Every tool only the newer + * preset registers resolves to no presenter there, and the transcript + * silently degrades to generic cards for exactly the calls its history is + * made of. * @param sessionId - the transcript being read. - * @param header - that session's header (attached or inspected). + * @param session - that session's header and log (attached or inspected). * @returns the scope to pass to presenter lookups, or undefined for global. */ - async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise { + async function presenterScopeFor( + sessionId: SessionId, + session: PresetBearingSession, + ): Promise { const live = ctx.get('agents')?.get(sessionId) if (live !== undefined) return live const presets = ctx.get('agentPresets') @@ -1370,7 +1380,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // through the DEFAULT preset's standing layer: that is the composition // an unnamed session composes today, and presenters are pure display, // so the worst a mismatch produces is the generic card it had anyway. - return await presets.standingKeyFor(header.agentPreset) + return await presets.standingKeyFor(resolveSessionPreset(session)) } catch { // Swallows only the unknown/unusable-preset rejection from the roster: // a deleted or broken preset must degrade this read, never fail it. @@ -1463,7 +1473,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Beside the cwd check for the same reason, and after the await so it // covers every path that yields a live agent — freshly created, adopted // live, resumed from disk, or recovered by the concurrent-creation catch. - assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset) + assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session)) if (agent.session.header.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd) } @@ -2003,7 +2013,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header)) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state)) return ok(request, { events: page.events, hasMore: page.hasMore, @@ -2982,7 +2992,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // The scope presenters resolve in — the live agent, else the recorded // preset's standing key, else the global layer — so a cold session's // '/' popup lists the catalog its composition actually serves. - const scope = await presenterScopeFor(sessionId, session.header) + const scope = await presenterScopeFor(sessionId, session) try { const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return ok(request, { diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index eb707c01f6..996af59986 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -186,6 +186,24 @@ describe('session.create with an agent preset', () => { }) }) + it('adopts a live session under the preset it SWITCHED to', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) + // Exactly what `agentPreset.select` leaves behind on a blank session: the + // header keeps the creation fact, the log states what the agent runs. + ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' })) + const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) + + // Comparing against the header would invert both answers: the preset the + // session actually runs would be refused, and the one it left would pass. + expect(adopted.result.ok).toBe(true) + expect(stale.result.ok).toBe(false) + if (stale.result.ok) throw new Error('unreachable') + expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' }) + }) + it('adopts a live session unchanged when the caller names no preset', async () => { const { api } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' })) @@ -660,6 +678,27 @@ describe('session.history presenter scope', () => { expect(standingKeyRequests).toEqual([]) }) + it('resolves a switched session from the LOG, not its creation header', async () => { + // The header is a creation fact; a switch while blank is a logged event, + // and every turn after it ran under the newer composition. Reading the + // header would render that history through the older preset's layer, + // where the tools it is made of have no presenter at all. + const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' } + const { api } = await harness(['standard', 'minimal'], { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ + meta, + events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }], + }), + }) + + standingKeyRequests.length = 0 + const response = await api.sessions.history(request({ sessionId: SessionId('p4') })) + + expect(response.result.ok).toBe(true) + expect(standingKeyRequests).toEqual(['minimal']) + }) + it('serves a COLD transcript whose standing mount is no longer usable', async () => { // A genuinely cold session: persistence knows it, no live agent exists. const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' } diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 65af853308..9c7f2c54ad 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d -README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21 +README.md: 5ccf1d7b224d0e3a67b3aeb9dc6679d6e802063f +README.zh.md: ed79cf48b96ed927feec8860b6211cedc369cdda diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index b6d469b26a..5ccf1d7b22 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -14,6 +14,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.list(): Promise` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason. - `ctx.agentPresets.resolve(id?): Promise` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row. - `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and cannot fail. +- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. - `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all. @@ -27,6 +29,14 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions. +### Composing a child agent + +A subagent's child joins its parent's standing composition through `composeFrom()`, never through `mount()`. Every model-facing row lives on the agent plane, so the tool registry's global layer is empty and a child that joins nothing reaches the model with no tools at all and none of its parent's prompt sections. + +Re-mounting the parent's preset by id would differ from the bind in two ways that both matter. A composition file edited since the parent started would hand the child a DIFFERENT generation than the one its parent's history was produced under, and a preset deleted since would fail the child outright while its parent keeps running. The bind is also synchronous, which is what lets the in-process subagent drivers use it at all — they compose their children inside a synchronous creation window. + +The child records the joined id on its own durable header ([`dsh-subagent`](../../subagent/subagent/README.md)), so a cold read of the child's history rebuilds the composition it actually ran under rather than the deployment default. + ### Which preset a session runs The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 60c7bc695c..ed79cf48b9 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -14,6 +14,8 @@ - `ctx.agentPresets.list(): Promise` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。 - `ctx.agentPresets.resolve(id?): Promise` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。 - `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步且不会失败。 +- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。 @@ -27,6 +29,14 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制。 +### 组装子 agent + +subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,绝不走 `mount()`。所有面向模型的行都在 agent 平面,工具注册表的全局层是空的,因此没有加入任何组装的子 agent 抵达模型时既没有任何工具,也没有父方的任何提示段。 + +按 id 重新挂载父方的 preset 与认父有两处差别,且两处都要紧。父方启动后被编辑过的组装文件会把与父方历史所产出时**不同**的一个代际交给子 agent;而此后被删除的 preset 会让子 agent 直接失败,尽管其父方仍在正常运行。认父还是同步的,这正是进程内 subagent 驱动能够使用它的前提——它们在同步的创建窗口里组装子 agent。 + +子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。 + ### 会话实际运行的是哪个 preset 创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。 diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 58f523c4e3..0fb428c425 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -28,7 +28,7 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' -import { mountPreset, serviceForAgent } from './mount.ts' +import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts' @@ -51,8 +51,8 @@ export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, } from './metadata.ts' export { - inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, - type PresetMount, + inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor, + type JoinedPresetMount, type PresetMount, } from './mount.ts' export { copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError, @@ -238,6 +238,54 @@ export class AgentPresets extends Service { return preset } + /** + * Join one agent to the SAME standing composition another already runs on. + * + * This is how a child agent inherits its parent's capabilities. It is a bind, + * not a mount: the parent's generation is already composed, so the child gets + * that exact instance — the same plugin objects, the same tool registrations, + * the same prompt sections. Re-resolving the parent's preset by id instead + * would re-read the roster, and a composition file edited since the parent + * started would hand the child a DIFFERENT generation than the one its + * parent's history was produced under (and a preset deleted since would fail + * the child outright while its parent keeps running). + * + * Synchronous and infallible for that reason, which is what lets a child + * creation window use it: the two in-process subagent drivers compose their + * children inside a synchronous `setup`. + * + * A parent that joined no preset — a rosterless deployment — yields no join + * and no error: there, the model-facing rows sit in the host composition and + * the child already sees them through the global layer. + * @param agentCtx - the joining agent's scope context. + * @param parentCtx - the scope context of the agent whose composition to join. + * @returns the preset id joined, or undefined when the parent joined none. + * @throws when `agentCtx` carries no scope, or has already joined a preset. + */ + composeFrom(agentCtx: Context, parentCtx: Context): string | undefined { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) { + throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset') + } + const standing = standingMountFor(parentCtx) + if (standing === undefined) return undefined + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + return standing.presetId + } + + /** + * The preset one live agent runs on. + * + * Read from the live scope chain rather than from the session, so it answers + * for an agent whose session has not recorded a preset yet — a child agent + * whose durable header is being built from its parent's composition. + * @param agentCtx - the agent's scope context. + * @returns the preset id, or undefined when the agent joined none. + */ + composedPreset(agentCtx: Context): string | undefined { + return standingMountFor(agentCtx)?.presetId + } + /** Whether this deployment configures a root locally authored presets go to. */ get authorable(): boolean { return this.config.roots.some(root => root.trust === 'user') diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index eb890255ca..e968a97c25 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -202,6 +202,33 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] { return leaked.sort((left, right) => left.localeCompare(right)) } +/** A live standing mount located through one agent already joined to it. */ +export type JoinedPresetMount = PresetMount & { + /** The standing key, definite because it is what the lookup matched on. */ + readonly key: ScopeKey +} + +/** + * The standing composition one agent is joined to. + * + * The agent's own key is parented to its preset's standing key, so the mount + * is found by matching that parent rather than by walking up from the agent — + * the mount is not under the agent's fiber. An agent that joined no preset — + * a deployment composing no roster, or a child agent before its join — has no + * parent link and resolves to undefined. + * @param agentCtx - the agent's scope context. + * @returns the mount the agent joined, or undefined when it joined none. + */ +export function standingMountFor(agentCtx: Context): JoinedPresetMount | undefined { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) return undefined + const standingKey = scopeParentOf(agentKey) + if (standingKey === undefined) return undefined + return livePresetMounts().find( + (candidate): candidate is JoinedPresetMount => candidate.key === standingKey, + ) +} + /** * One agent's instance of a service its preset mounted. * @@ -231,14 +258,7 @@ export function serviceForAgent( agent: { ctx: Context }, name: K, ): Context[K] | undefined { - // The agent's own key is parented to its preset's standing key; the mount - // is no longer under the agent's fiber, so the search roots at the standing - // mount instead of walking up from the agent. - const agentKey = scopeOf(agent.ctx) - if (agentKey === undefined) return undefined - const standingKey = scopeParentOf(agentKey) - if (standingKey === undefined) return undefined - const mount = livePresetMounts().find(candidate => candidate.key === standingKey) + const mount = standingMountFor(agent.ctx) if (mount === undefined) return undefined const store = ctx.reflect.store for (const key of Object.getOwnPropertySymbols(store)) { diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 77c549a689..afe297a406 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -153,6 +153,79 @@ describe('composing an agent from a preset', () => { }) }) +describe('composing a child agent from its parent', () => { + /** Create one agent joined to `parent`'s composition, as a child creation window does. */ + async function childOf(ctx: Context, id: string, parent: Agent): Promise { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + setup: (childCtx: Context) => void ctx.agentPresets.composeFrom(childCtx, parent.ctx), + }) + return handle.agent + } + + it('gives the child its parent\'s tools and prompt sections', async () => { + const parent = await agentOn(ctx, 'sess-parent', 'standard') + + const child = await childOf(ctx, 'sess-child', parent) + + expect(toolNames(ctx, child)).toEqual(['alpha']) + const prompt = await ctx.systemPrompt.assemble(assembleContextFor(child)) + expect(prompt.sections.map(section => section.name)).toContain('preset:alpha') + }) + + it('joins the parent\'s own generation rather than remounting its preset', async () => { + const parent = await agentOn(ctx, 'sess-shared', 'standard') + const before = livePresetMounts().length + + await childOf(ctx, 'sess-shared-child', parent) + + // A remount would compose a second copy of every row in the preset; the + // child must run on the plugin instances its parent already runs on. + expect(livePresetMounts()).toHaveLength(before) + }) + + it('keeps the child composed after its parent is disposed', async () => { + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('sess-dying-parent'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + const child = await childOf(ctx, 'sess-orphan', parentHandle.agent) + + await parentHandle.dispose() + + // Standing mounts outlive the agents that joined them, so a child outliving + // its parent — a background subagent — keeps the composition it started on. + expect(toolNames(ctx, child)).toEqual(['alpha']) + }) + + it('reports the preset id the child joined, for the durable header', async () => { + const parent = await agentOn(ctx, 'sess-named', 'minimal') + + const child = await childOf(ctx, 'sess-named-child', parent) + + expect(ctx.agentPresets.composedPreset(parent.ctx)).toBe('minimal') + expect(ctx.agentPresets.composedPreset(child.ctx)).toBe('minimal') + }) + + it('composes nothing when the parent joined no preset', async () => { + // The rosterless deployment: model-facing rows sit in the host composition + // and the child already resolves them through the registry's global layer. + const bare = (await ctx.agents.create({ sessionId: SessionId('sess-bare-parent') })).agent + + const child = await childOf(ctx, 'sess-bare-child', bare) + + expect(ctx.agentPresets.composedPreset(bare.ctx)).toBeUndefined() + expect(ctx.agentPresets.composeFrom(child.ctx, bare.ctx)).toBeUndefined() + expect(toolNames(ctx, child)).toEqual([]) + }) + + it('refuses to compose an unscoped context', async () => { + const parent = await agentOn(ctx, 'sess-unscoped-parent', 'standard') + + expect(() => ctx.agentPresets.composeFrom(ctx, parent.ctx)).toThrow(/unscoped context/) + }) +}) + describe('rejecting a composition that cannot be used', () => { it('refuses to mount into a context that carries no agent scope', async () => { await expect(ctx.agentPresets.mount(ctx, 'standard')) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index fdb852533d..907c3bd266 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -110,6 +110,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async mount(agentCtx: Context, id?: string): Promise', jsDoc: '/**\n * Compose one agent from a preset: ensure the preset\'s standing mount, then\n * parent the agent\'s scope key to it so the mount\'s registrations and\n * listeners cover this agent.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was composed, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, + { + signature: 'composeFrom(agentCtx: Context, parentCtx: Context): string | undefined', + jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous and infallible for that reason, which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */', + }, + { + signature: 'composedPreset(agentCtx: Context): string | undefined', + jsDoc: '/**\n * The preset one live agent runs on.\n *\n * Read from the live scope chain rather than from the session, so it answers\n * for an agent whose session has not recorded a preset yet — a child agent\n * whose durable header is being built from its parent\'s composition.\n * @param agentCtx - the agent\'s scope context.\n * @returns the preset id, or undefined when the agent joined none.\n */', + }, { signature: 'async read(id: string): Promise', jsDoc: '/**\n * Read one preset\'s composition text.\n * @param id - the preset id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */', diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index e18752db74..36646e7f7a 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -45,9 +45,12 @@ } }, "devDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index acb4e4d36e..ee7779447a 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -125,7 +125,7 @@ export async function startInProcessRun( if (inheritedPolicy !== undefined) { childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' }) } - applyChildComposition(childCtx, { + applyChildComposition(childCtx, parent, { persona: request.persona, toolFilter: request.toolFilter, }) diff --git a/packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js b/packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js new file mode 100644 index 0000000000..6fb224094d --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js @@ -0,0 +1,20 @@ +// A preset row standing in for the agent-plane tool rows a real preset mounts. +// Import-free on purpose — the Loader resolves entry modules through Node's ESM +// resolver, which cannot see this workspace's TypeScript sources. +export const name = 'preset-tool' +export const inject = ['tools', 'systemPrompt'] + +export function apply(ctx, config) { + ctx.effect(() => ctx.tools.register({ + name: config.tool, + description: `fixture tool ${config.tool}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }, + execute: () => Promise.resolve(config.tool), + })) + ctx.effect(() => ctx.systemPrompt.section({ + name: `preset:${config.tool}`, + order: 10, + text: `section for ${config.tool}`, + })) +} diff --git a/packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml b/packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml new file mode 100644 index 0000000000..a801659220 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml @@ -0,0 +1,5 @@ +# Agent-plane composition: the model-facing row lives here, not in the host. +- id: only + name: ../../plugins/preset-tool.js + config: + tool: preset_only diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts new file mode 100644 index 0000000000..01c190a833 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -0,0 +1,116 @@ +/** + * Composition inheritance: a child runs on the preset its parent runs on. + * + * With every model-facing row on the agent plane, the tool registry's global + * layer is empty, so a child that joins no preset reaches the model with no + * tools at all. These assert the model-visible result — the schemas in the + * child's own request — rather than the join that produces it. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +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 AgentPresets from '@deepseek-ai/dsh-agent-presets' +import { SessionId } from '@deepseek-ai/dsh-session' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { startInProcessRun } from '../src/index.ts' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [{ path: join(FIXTURES, 'presets'), trust: 'system' as const }] + +const contexts: Context[] = [] + +afterEach(async () => { + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() +}) + +/** A host composition carrying no model-facing rows, plus the preset roster. */ +async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; parent: Agent }> { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS }) + const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('parent'), + agentOptions: { provider: 'mock', model: 'mock' }, + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'coding'), + }) + return { ctx, adapter, parent: handle.agent } +} + +/** The one-shot spawn request shape both in-process providers build. */ +function spawnRequest(parent: Agent) { + return { + label: 'child task', + prompt: [{ type: 'text' as const, text: 'child task' }], + parent, + signal: new AbortController().signal, + descriptor: snapshotSubagentDescriptor({ + mode: 'one-shot' as const, + provider: 'spawn', + label: 'child task', + }), + } +} + +describe('a child agent composed in-process', () => { + it('reaches the model with its parent\'s preset tools', async () => { + const { ctx, adapter, parent } = await setupPresetHost() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + const childRequest = adapter.requests.at(-1) + expect(childRequest?.tools?.map(tool => tool.name)).toEqual(['preset_only']) + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) + await run.dispose() + }) + + it('carries its parent\'s prompt sections', async () => { + const { parent } = await setupPresetHost() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + expect(run.localAgent?.session.events.some(event => + event.type === 'request/header' + && JSON.stringify(event.data).includes('section for preset_only'))).toBe(true) + await run.dispose() + }) + + it('records the composition it ran under on the child header', async () => { + const { parent } = await setupPresetHost() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + // Without this the child's own history reads back under the deployment + // default, which is a different tool set than the one it actually used. + expect(run.localAgent?.session.header.agentPreset).toBe('coding') + await run.dispose() + }) + + it('follows a parent that switched preset while blank', async () => { + const { ctx, parent } = await setupPresetHost() + await ctx.agentPresets.recompose(parent.ctx, 'coding') + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) + await run.dispose() + }) +}) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 1241aa8042..495949dc0c 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 762030629c09305c48adebc71244655a5faa6585 -README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff +README.md: b69428e4af7d1f53adb22be1e59beb79c054713f +README.zh.md: 9f5eb5f1c508135c21bf3923f60f4f872de8e9f6 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 762030629c..b69428e4af 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -40,6 +40,10 @@ Start-time features are advertised in `provider.capabilities` because the servic - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. +Every in-process child is composed by one call, `applyChildComposition(childCtx, parent, composition)`, which joins the parent's agent-preset composition before applying the child's own persona and tool filter. The join is what gives the child its capabilities: with every model-facing row on the agent plane, a child that joined nothing would reach the model with an empty tool registry ([`dsh-agent-presets`](../../preset/agent-presets/README.md)). Taking the parent as a parameter is deliberate — it makes composing a child WITHOUT that join unrepresentable at the call sites, which is the defect the one call exists to prevent. A deployment composing no preset roster joins nothing and needs nothing: its model-facing rows sit in the host composition, where the child already resolves them through the tool registry's global layer. + +`childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. + Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. ## The durable descriptor diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 535cc25895..9f5eb5f1c5 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -40,6 +40,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 +每个进程内子 agent 都由一次调用完成组装:`applyChildComposition(childCtx, parent, composition)` 先加入父方的 agent-preset 组装,再应用该子 agent 自己的 persona 与工具限制。加入组装正是子 agent 获得能力的途径:所有面向模型的行都在 agent 平面,没有加入任何组装的子 agent 抵达模型时工具注册表是空的(见 [`dsh-agent-presets`](../../preset/agent-presets/README.md))。把父方作为参数是刻意的——这让"组装一个子 agent 却不做该加入"在各调用点无法表达,而这正是这一次调用所要杜绝的缺陷。未组装 preset roster 的部署不加入任何组装、也不需要加入:它的面向模型的行位于宿主组装中,子 agent 已经能通过工具注册表的全局层解析到它们。 + +`childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 + 可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。 ## 持久化描述符 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index ba1dd0fbc4..35504c9dc4 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -34,6 +34,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-presets": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -47,6 +48,9 @@ "cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-presets": { + "optional": true + }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -62,6 +66,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index c477954468..c501a19a56 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -12,6 +12,12 @@ import type { Context } from 'cordis' import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +// Type-only: make `ctx.get('agentPresets')` resolve to the preset roster when +// composed — a child inherits its parent's composition opportunistically (the +// documented `ctx.get` pattern), never as a hard dep. A rosterless deployment +// keeps its model-facing rows on the host plane, where the child already sees +// them through the tool registry's global layer. +import type {} from '@deepseek-ai/dsh-agent-presets' import { delegationDepthOf } from './depth.ts' /** Thrown when starting a child would exceed the requested depth cap. */ @@ -72,8 +78,15 @@ export function resolveChildAgentOptions( /** * Build the child session's durable creation metadata: the parent's workspace, * its direct lineage, coarse product origin, the recursion budget that must - * survive persistence, and the seed boundary that separates inherited parent - * history from child work. + * survive persistence, the seed boundary that separates inherited parent + * history from child work, and the composition the child runs under. + * + * The preset is read from the parent's LIVE scope chain rather than from its + * header, because a parent that switched preset while blank runs on the newer + * composition and its header still names the older one. Recording it is what + * makes a child's history reconstructable: without it a cold read of the child + * resolves the deployment default and rebuilds turns under a tool set the + * child never had. * @param parent - the delegating parent agent. * @param childDepth - the resolved delegation depth to persist. * @param lineageSeedLength - how many leading events came from the parent's log. @@ -85,8 +98,10 @@ export function childSessionMeta( lineageSeedLength: number, ): NonNullable { const parentHeader = parent.session.header + const agentPreset = parent.ctx.get('agentPresets')?.composedPreset(parent.ctx) return { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + ...agentPreset === undefined ? {} : { agentPreset }, parentSession: parentHeader.id, // Navigation classification only; the descriptor remains the authority // for mode and continuation capability. @@ -106,13 +121,31 @@ export interface ChildComposition { } /** - * Apply one child's scoped composition inside its creation window: a shadowing - * persona section and a tool restriction, both owned by the child's scope and - * therefore invisible to its parent and siblings. + * Compose one child inside its creation window: join its parent's preset, then + * apply the child's own shadowing persona section and tool restriction, both + * owned by the child's scope and therefore invisible to its parent and + * siblings. + * + * The join comes first and the child's own registrations second, which is the + * order the layering already implies — the nearest scope wins a name, and a + * per-child restriction intersects with everything its chain admits — but + * stating it here keeps the two steps from being read as independent. + * + * Both steps live in ONE call because a child composed with only the second is + * exactly the defect this function exists to prevent: with every model-facing + * row on the agent plane, a child that joins no preset sees an empty tool + * registry and none of its parent's prompt sections. Taking the parent as a + * parameter is what makes that omission unrepresentable at the call sites. * @param childCtx - the child agent's scoped creation context. - * @param composition - the persona and tool filter to install. + * @param parent - the delegating parent whose composition the child joins. + * @param composition - the per-child persona and tool filter to install. */ -export function applyChildComposition(childCtx: Context, composition: ChildComposition): void { +export function applyChildComposition( + childCtx: Context, + parent: Agent, + composition: ChildComposition, +): void { + childCtx.get('agentPresets')?.composeFrom(childCtx, parent.ctx) if (composition.persona !== undefined) { childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 3425a12851..1cb16daca6 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -885,7 +885,7 @@ export class SubagentContinuationManager { // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): AgentSetupCommit => { - applyChildComposition(childCtx, inputs.composition) + applyChildComposition(childCtx, parent, inputs.composition) return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index c72f2ef68d..afc6138bd9 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/scope" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../session/session-persistence" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2507f83973..a814f2a7d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../packages/settings/settings + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/subagent '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt @@ -6285,6 +6288,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6539,6 +6545,12 @@ importers: packages/subagent/subagent-inprocess: devDependencies: + '@cordisjs/plugin-include': + specifier: ^1.0.4 + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6548,6 +6560,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-fs-sandbox': specifier: workspace:^ version: link:../../fs/fs-sandbox From 86d5dd438437fbc7b5b1e57d97ccc03d8fbd3eb4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 18:02:11 +0800 Subject: [PATCH 015/105] fix(preset): align minimal agent with RL composition --- ...026-08-09-layered-skill-registry.i18n.yaml | 4 +- .../2026-08-09-layered-skill-registry.md | 2 +- .../2026-08-09-layered-skill-registry.zh.md | 2 +- ...nimal-preset-owns-rl-composition.i18n.yaml | 6 + ...8-10-minimal-preset-owns-rl-composition.md | 37 +++++ ...0-minimal-preset-owns-rl-composition.zh.md | 37 +++++ ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 4 +- ...9-persistent-bash-str-replace-editor.zh.md | 4 +- ...ssion-search-not-shipped-default.i18n.yaml | 4 +- ...8-02-session-search-not-shipped-default.md | 2 +- ...2-session-search-not-shipped-default.zh.md | 2 +- .../agent-presets/minimal/agent.cordis.yml | 88 +++++++---- .../config/agent-presets/minimal/preset.yml | 2 +- apps/cli/config/core-web.cordis.yml | 113 --------------- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 4 +- apps/cli/tests/built-bin.e2e.ts | 12 -- apps/cli/tests/web-agent-presets.e2e.ts | 26 +++- apps/web/tests/core-web-profile.snapshot.ts | 137 ------------------ apps/web/tests/minimal-preset.snapshot.ts | 115 +++++++++++++++ .../session.jsonl | 8 +- apps/web/tsconfig.json | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/subsystems/system-prompt.i18n.yaml | 4 +- docs/subsystems/system-prompt.md | 27 +++- docs/subsystems/system-prompt.zh.md | 27 +++- packages/bundle/web-app/cordis.patch.yml | 6 +- packages/core/system-prompt/README.i18n.yaml | 4 +- packages/core/system-prompt/README.md | 12 +- packages/core/system-prompt/README.zh.md | 12 +- packages/core/system-prompt/src/index.ts | 45 ++++-- .../system-prompt/tests/system-prompt.spec.ts | 28 ++++ .../tests/api-proxy-agent-preset.spec.ts | 22 +-- packages/preset/persona/README.i18n.yaml | 4 +- packages/preset/persona/README.md | 9 +- packages/preset/persona/README.zh.md | 9 +- packages/preset/persona/src/index.ts | 6 +- packages/preset/persona/src/invariant.ts | 3 +- packages/preset/persona/tests/persona.spec.ts | 17 +++ .../tool-cordis/src/api-catalog.ts | 6 +- tsconfig.host.json | 2 +- 48 files changed, 484 insertions(+), 406 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md delete mode 100644 apps/cli/config/core-web.cordis.yml delete mode 100644 apps/web/tests/core-web-profile.snapshot.ts create mode 100644 apps/web/tests/minimal-preset.snapshot.ts rename apps/web/tests/snapshots/{core-web-profile => minimal-preset}/session.jsonl (73%) diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml index 22e5090312..22296f97a9 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.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/architecture/2026-08-09-layered-skill-registry.md -2026-08-09-layered-skill-registry.md: 3f092cfb4b722e3dd51fa4dc46c620259eaffa39 -2026-08-09-layered-skill-registry.zh.md: 38b17329c8d46ee9bbd0863f3fae7cf6be39aa75 +2026-08-09-layered-skill-registry.md: 73897c3cb7e0055ff59221b7ea47c5d6ced06991 +2026-08-09-layered-skill-registry.zh.md: 655780d4ef154434d6debf478134d3293d6c564f diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md index 3f092cfb4b..73897c3cb7 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md @@ -24,7 +24,7 @@ The composition moves with it: the web-app bundle re-enables the base `skill` re **A deployment-level skill reaches every preset-composed session that mounts `tool-skill`.** The repository-plugin e2e's skill root and assertions are restored; the shipped-Web e2e proves the badge row (the same host-registration shape) merges into a standard-preset agent's catalog while the host view stays global-only. -**Layer visibility and consumption stay separate choices.** A core-web agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. +**Layer visibility and consumption stay separate choices.** A `minimal` agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. **Provider options are still the borrowed caller object.** `SkillViewOptions` extends `SkillLookupOptions`; the registry consumes `scope` and providers read only their own contract from the same readonly object, preserving the existing borrow-identity guarantee. diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md index 38b17329c8..655780d4ef 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md @@ -24,7 +24,7 @@ agent-preset stack 曾把整个 skill 能力——注册表、本地提供方和 **部署级 skill 会到达每个挂载 `tool-skill` 的 preset 会话。**repository-plugin e2e 的 skill 根目录与断言已恢复;shipped-Web e2e 证明 badge 行(同一种宿主注册形态)汇入 standard preset agent 的目录,而宿主视图保持仅全局。 -**层可见性与消费仍是两个独立选择。**core-web agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 +**层可见性与消费仍是两个独立选择。** `minimal` agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 **提供方选项仍是借用的调用方对象。**`SkillViewOptions` 扩展 `SkillLookupOptions`;注册表消费 `scope`,提供方只从同一个只读对象中读取自己的契约,保持既有的借用恒等保证。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml new file mode 100644 index 0000000000..6861aff43a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +2026-08-10-minimal-preset-owns-rl-composition.md: 043f2e45e3fe4fbb92aa6652ce099ebfde09de55 +2026-08-10-minimal-preset-owns-rl-composition.zh.md: 83f243b56b25237f19fa288f87e15eee6a264c94 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md new file mode 100644 index 0000000000..043f2e45e3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -0,0 +1,37 @@ +# Agent Note: The minimal preset owns the complete RL agent composition + +Status: implemented + +English | [中文](2026-08-10-minimal-preset-owns-rl-composition.zh.md) + +## Problem + +The Web surface offered two owners for the Claude SWE-compatible RL agent: a process-wide `core-web.cordis.yml` patch and the per-session `minimal` preset. Once [agent presets](../architecture/2026-08-03-per-session-agent-presets.md) became the agent-composition boundary, the preset's scoped `deployment:persona` shadowed the overlay's corrected global persona with stale coding-agent text. The overlay test mounted no preset, while the preset test booted without the overlay, so neither exercised the composition users selected. + +The split also hid other drift. The preset mounted one-shot Bash rather than the [persistent Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md) used by the RL harness and omitted the RL compaction policy. Keeping both owners makes every future prompt, tool, and policy change a cross-product. + +## Decision + +The shipped `minimal` preset is the sole RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract. + +The preset persona is exactly `You are a helpful software engineer assistant.` and sets `complete: true`. A complete `PromptSection` participates in ordinary assembly so tools, contexts, variables, and cooperative listeners still resolve; after the `system-prompt/assemble` waterfall, the prompt registry restores a detached copy of that section as the sole system-prompt section. Multiple effective complete sections reject assembly. This final registry constraint prevents harness identity, Web orientation, tool guidance, or an assembly listener from appending prompt text. + +The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace attachment, persistence, filesystem, subprocess, sandbox, permission, model routing, and other cross-session services remain host-owned. Selecting `minimal` changes one agent's model-facing composition without changing other sessions in the Web process. + +## Verification + +System-prompt and persona package tests prove final complete-section enforcement, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web surface text, and a test section are registered, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path. + +## Alternatives considered + +**Keep `core-web.cordis.yml` as a compatibility patch.** Rejected because a process patch and a session preset are two independent owners for one agent contract; precedence makes either one capable of silently undoing the other. + +**Disable every known prompt contributor in the preset.** Rejected because host rows are process-wide and new contributors would reopen the prompt. A final complete-section constraint expresses the negative guarantee at the registry that assembles the prompt. + +**Filter sections only with a prepended waterfall listener.** Rejected because another prepended wrapper can run outside it and append after the filter. Enforcement after the complete waterfall has stable final authority. + +**Mount PTY services on the Web host.** Rejected because only the minimal agent consumes them. An entry-local `pty` realm gives the services the same lifetime and scope as their sole consumer without publishing a process-global service from a preset. + +## Consequences + +The RL prompt is fixed rather than environment-overridable, and `minimal` is the only shipped place that states it. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md new file mode 100644 index 0000000000..83f243b56b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -0,0 +1,37 @@ +# Agent Note: minimal preset 拥有完整的 RL agent 组合 + +Status: implemented + +[English](2026-08-10-minimal-preset-owns-rl-composition.md) | 中文 + +## 问题 + +Web surface 同时由两个位置定义与 Claude SWE 兼容的 RL agent(智能体):进程级 `core-web.cordis.yml` patch,以及逐会话的 `minimal` preset。[agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 成为 agent 组合边界后,preset 中带作用域的 `deployment:persona` 会用陈旧的 coding-agent 文本遮蔽 overlay 修正过的全局 persona。overlay 测试没有挂载 preset,而 preset 测试启动时没有 overlay,因此两者都没有覆盖用户实际选择的组合。 + +这种拆分还掩盖了其他偏差。preset 挂载了一次性 Bash,而不是 RL harness 使用的[持久 Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md),并且遗漏了 RL 压缩(compaction)策略。保留两个所有者,会使今后每次修改提示词、工具或策略时都必须验证二者的交叉组合。 + +## 决策 + +随附的 `minimal` preset 是 RL agent 组合的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`、`str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。 + +preset persona 恰好是 `You are a helpful software engineer assistant.`,并设置 `complete: true`。complete `PromptSection` 参与常规组装,因此工具、上下文、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall(瀑布式事件)结束后,提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落。存在多个有效 complete 段时,组装会被拒绝。这项最终注册表约束可防止 harness 身份、Web 定位、工具引导或组装监听器追加提示词文本。 + +进程级 `core-web.cordis.yml` patch 不再存在。浏览器 UI、workspace 附加、持久化、文件系统、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 只会改变一个 agent 面向模型的组合,不会改变 Web 进程中的其他会话。 + +## 验证 + +系统提示词与 persona 包测试证明了 complete 段的最终约束,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web surface 文本和一个测试段落;随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。 + +## 考虑过的替代方案 + +**将 `core-web.cordis.yml` 保留为兼容 patch。** 被拒绝,因为进程 patch 与会话 preset 是同一 agent 约定的两个独立所有者;优先级会使任意一方都能静默撤销另一方的配置。 + +**在 preset 中禁用每个已知的提示词贡献方。** 被拒绝,因为宿主行属于整个进程,新的贡献方也会重新开放提示词。由组装提示词的注册表实施最终 complete 段约束,才能表达这项否定保证。 + +**仅使用前置 waterfall 监听器筛选段落。** 被拒绝,因为另一个前置包装层可以在该监听器外执行,并在筛选后追加内容。在整个 waterfall 结束后实施约束,才能稳定拥有最终决定权。 + +**在 Web 宿主上挂载 PTY 服务。** 被拒绝,因为只有 minimal agent 消费这些服务。entry 本地的 `pty` realm 与唯一消费方具有相同的生命周期和作用域,无需由 preset 发布进程级全局服务。 + +## 后果 + +RL 提示词固定不变,不能通过环境覆盖,且 `minimal` 是交付内容中唯一声明该提示词的位置。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 42a8e3e6bd..fbe84849b4 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.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-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: c4750e30370bfd253064c39cb1adc0f5b2baa60d -2026-07-29-persistent-bash-str-replace-editor.zh.md: 83159d9792fd9fadaaa342cc289300b35da34e4a +2026-07-29-persistent-bash-str-replace-editor.md: 2375ad7e40afb096d7e1bbec4de023433de1e012 +2026-07-29-persistent-bash-str-replace-editor.zh.md: fcabc4bd342224a8b2d024a48901af284b4c6d2e diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index c4750e3037..2375ad7e40 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. -The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface for the Claude SWE-compatible RL contract. It pins native tool mode and makes the complete system prompt `DSH_SYSTEM_PROMPT` when set or `You are a helpful software engineer assistant.` otherwise, with no harness identity, source-checkout section, Web orientation, Workspace instructions, or tool-mode guidance. It disables every other model-facing consumer, so the model receives exactly the persistent `bash` and `str_replace_editor` schemas, while the Web host, browser, Workspace, persistence, sandbox, and permission stack remains in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. +The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes native presentation and the complete system prompt, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. ## Alternatives considered @@ -32,4 +32,4 @@ The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis. ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. A minimal Web agent retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 83159d9792..fcabc4bd34 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,7 +18,7 @@ Status: implemented 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 -已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay 会在常规 Web 界面之上组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。它固定使用原生工具模式;完整的系统提示词在设置 `DSH_SYSTEM_PROMPT` 时采用其值,否则采用 `You are a helpful software engineer assistant.`,且不包含 harness 身份、源码 checkout 提示词段、Web 界面定位、Workspace 指令或工具模式指引。它会禁用其他所有面向模型的消费方,使模型恰好只收到持久 `bash` 和 `str_replace_editor` 两个 schema,同时保留 Web 宿主、浏览器、Workspace、持久化、沙箱与权限栈。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。 +随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定原生呈现和完整系统提示词,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)负责说明。 ## 考虑过的替代方案 @@ -32,4 +32,4 @@ Status: implemented ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。Core Web profile 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。minimal Web agent 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml index fd479c217e..1a4d923540 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.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-08-02-session-search-not-shipped-default.md -2026-08-02-session-search-not-shipped-default.md: 65bd72fff76210b726e7562fb8e88e5f8802434a -2026-08-02-session-search-not-shipped-default.zh.md: 4eb0851c1e584b84847b6bb5118c8bb2f3156845 +2026-08-02-session-search-not-shipped-default.md: c1bfd7f8e354a4480c5635619514fe782ea71d2c +2026-08-02-session-search-not-shipped-default.zh.md: 9b80c549425c26055700480dd57f1a0a7d01e4a8 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md index 65bd72fff7..c1bfd7f8e3 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md @@ -10,7 +10,7 @@ The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made ## Decision -The shipped TUI, Web, and headless surfaces no longer mount `@deepseek-ai/dsh-tool-session-query`: the row is removed from the shared `cordis.patch.yml`, the now-dangling `disabled` patch in the opt-in [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile goes with it, and the workspace dependency drops from `apps/cli/package.json`. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. +The shipped TUI, Web, and headless surfaces do not mount `@deepseek-ai/dsh-tool-session-query`, and no shipped agent preset carries it. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — and the Web overlay keeps patching it to an in-memory index for the browser content search. Only the model-facing consumer is removed. diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md index 4eb0851c1e..9b80c54942 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `cordis.patch.yml` 移除,opt-in 的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile 中那条已悬空的 `disabled` patch 也随之删除,workspace 依赖也从 `apps/cli/package.json` 中移除。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 +交付的 TUI、Web 与无头 surface 均不挂载 `@deepseek-ai/dsh-tool-session-query`,交付的 agent preset 也都不包含它。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 `ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。 diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 6ae88b9339..44d1bb45df 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -1,39 +1,71 @@ -# The `minimal` agent preset: the two-tool benchmark surface. +# The `minimal` agent preset: the Claude SWE-compatible RL surface. # -# The native model surface is exactly persistent `bash` plus -# `str_replace_editor`. Everything else a session could reach — skills, goals, -# plan mode, delegation, workflows, todo, web — is simply absent rather than -# disabled, because a preset composes what an agent has instead of subtracting -# from a shared default. -# -# The host composition is unchanged: this agent still runs inside the same -# sandbox, approval, persistence, and model routing as any other session. +# The persona is the complete system prompt, so global identity, Web surface, +# tool guidance, and later assembly listeners cannot add prompt text. The model +# composes only the persistent `bash` and `str_replace_editor` tools. - id: persona name: '@deepseek-ai/dsh-persona' config: - text: >- - You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + text: You are a helpful software engineer assistant. + complete: true -# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to -# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is -# the criterion for host-plane ownership — injection resolves before any session -# exists, so there is no agent to key by. Behind a preset realm those variables -# never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the -# sandbox policy owns it. -# -# `run_in_background` is off because this preset mounts no `tool-tasks`. The -# host registry already refuses a start for an owner no attached control -# surface serves, so this is not the safety boundary — it is the model-facing -# one: an agent that could never collect a task should not be offered the -# parameter at all, and disabling it drops the parameter from the schema. -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' +# The PTY registry is an agent-owned service, so it lives in an entry-local +# realm. The backend still consumes the host sandbox policy and subprocess +# implementation, while the tool registers into this agent's scoped catalog. +- id: persistent-shell + name: cordis:group + group: true + isolate: + pty: true config: - enableRunInBackground: false + - id: pty + name: '@deepseek-ai/dsh-pty' -- id: tool-str-replace-editor + - id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +# Absolute paths are unconditional in the current editor; the legacy +# `requireAbsolutePath` switch is no longer a configuration field. +- id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: maxOutputChars: 16000 + +# RL core's fixed 128K window now comes from the routed model metadata rather +# than compact-basic config. Its remaining policy is preserved explicitly. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationProvider: '' + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml index 5521dda140..86366626e1 100644 --- a/apps/cli/config/agent-presets/minimal/preset.yml +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -1,3 +1,3 @@ name: 极简模式 -description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +description: 固定 RL 系统提示词,只呈现持久 bash 与 str_replace_editor。 order: 3 diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml deleted file mode 100644 index 43860418c4..0000000000 --- a/apps/cli/config/core-web.cordis.yml +++ /dev/null @@ -1,113 +0,0 @@ -# Opt-in Web shell for the RL core agent contract. The model receives exactly -# the configured persona plus the native `bash` and `str_replace_editor` -# schemas; the Web host, browser shell, persistence, and permission stack stay. - -# Match the Claude SWE-compatible RL core prompt. Disabling the Web runtime's -# surface context removes its GUI orientation, managed shell variables, and the -# launcher's source-checkout section through one configuration contract. -# Workspace instructions are model-visible user context rather than a system -# section, but RL core disables them as part of the same prompt contract. -- id: system-prompt - config: - includeHarnessIdentity: false - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' - -- id: web-runtime - config: - surfaceContext: false - -- id: workspace-context - disabled: true - -- id: tools - config: - mode: native - -# Disable every model-facing consumer in the base/Web tree. plan-mode owns the -# always-registered exit_plan_mode tool even while the session is not planning. -- id: tool-bash - disabled: true - -- id: tool-tasks - disabled: true - -- id: tool-fs - disabled: true - -- id: tool-fs-search - disabled: true - -- id: tool-web - disabled: true - -- id: tool-skill - disabled: true - -- id: plan-mode - disabled: true - -- id: tool-subagent-control - disabled: true - -- id: tool-subagent-list-agents - disabled: true - -- id: tool-subagent - disabled: true - -- id: tool-subagent-fork - disabled: true - -- id: tool-workflow - disabled: true - -- id: tool-todo - disabled: true - -# These consumers are shared defaults on the ordinary shipped surfaces, but -# this opt-in profile keeps exactly its two named tools. -- id: tool-goal - disabled: true - -- id: tool-ralph - disabled: true - -- id: tool-str-replace-editor - disabled: true - -# The matching browser controls must not offer surfaces whose tool this -# overlay omits: the panels would render for a capability the model does not -# have. Turning the row off no longer removes a tool — `ui-question`'s host -# half is empty and `tool-ask-user` is composed per preset — so this is a UI -# decision now, not a capability one. -- id: ui-plan - disabled: true - -- id: ui-question - disabled: true - -- insert: - - id: pty - name: '@deepseek-ai/dsh-pty' - - # This backend consumes the existing Web sandbox and permission policy. - # It loads only on Linux/macOS; Windows and other platforms fail at boot. - # Its 300s send wait matches the persistent Bash command timeout instead of - # pty-local's 30s default. An open persistent shell fences permission-mode - # changes until it closes. - - id: pty-local - name: '@deepseek-ai/dsh-pty-local' - config: - timeoutMs: 300000 - - - id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - config: - timeoutMs: 300000 - - # The editor consumes the Web fs-sandbox provider and therefore retains - # the selected session permission mode. - - id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 4b5aed6cd2..bb2e240f91 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 0b5faf8993cd8065fffcfec5f240b0084508db91 -README.zh.md: b9c48c16dd4be186266d30a438329463c31aca70 +README.md: 12574a369acf2697842ae3aae95ce152d52c009d +README.zh.md: f80dfba10292a03b1d855481bf4fa947a42a53c2 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0b5faf8993..12574a369a 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -59,9 +59,7 @@ All modes treat the invoking directory as the default workspace root, load appli New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. -`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. - -`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation rules and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback. +`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index b9c48c16dd..f80dfba102 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -59,9 +59,7 @@ dsh web --dump-config 新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 -`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。 - -`DSH_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该约定的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。 +`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index b81f837cf8..128fbbd42c 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -const coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url)) const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url)) async function runBuiltBin( @@ -543,16 +542,5 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) - - it('shows the RL Web patch disabling runtime surface context', async () => { - const { stdout, code, stderr } = await runBuiltBin( - ['web', '--patch', coreWebOverlay, '--dump-config'], - { DSH_HOME: home }, - ) - expect(code).toBe(0) - expect(stderr).toBe('') - expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'") - expect(stdout).toContain('surfaceContext: false') - }, 30_000) }) }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1bfaed8c67..4a91016bf7 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -22,6 +22,15 @@ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the preset module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') +const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.' +const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.` /** * Boot the shipped Web composition, minus the rows that would bind a port, @@ -143,14 +152,20 @@ describe('the shipped Web composition', () => { } }) - it('composes exactly two tools from `minimal`', async () => { + it('composes the exact RL prompt and two tools from `minimal`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-minimal'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - // Exactly what the preset lists — nothing arrives from the host. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) + expect(assembly.sections).toEqual([ + { name: 'deployment:persona', text: MINIMAL_PROMPT }, + ]) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor']) + expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) + expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters)) + .toContain('Absolute path') } finally { await handle.dispose() } @@ -340,15 +355,14 @@ describe('the shipped Web composition', () => { expect(await readFile(path, 'utf8')).toBe(before) }) - it('gives each session its own persona', async () => { + it('gives each session its own complete persona', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-persona'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) - expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text) - .toContain('You are a coding agent powered by') + expect(assembly.sections).toEqual([{ name: 'deployment:persona', text: MINIMAL_PROMPT }]) } finally { await handle.dispose() } diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts deleted file mode 100644 index 1178390837..0000000000 --- a/apps/web/tests/core-web-profile.snapshot.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' -import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts' - -const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url)) -const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/core-web-profile', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -const PROMPT = 'Reply exactly CORE_WEB_REQUEST_OK and stop.' - -describe('core Web profile', () => { - let scaffold: WebScaffold - let agentHandle: AgentHandle - - beforeAll(async () => { - const systemPrompt = process.env.DSH_SYSTEM_PROMPT - Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT') - try { - scaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE }) - } finally { - if (systemPrompt !== undefined) process.env.DSH_SYSTEM_PROMPT = systemPrompt - } - agentHandle = await scaffold.ctx.agents.create({ - sessionId: SessionId('core-web-profile-smoke'), - meta: { cwd: scaffold.workspaceCwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - }) - - afterAll(async () => { - const failures: unknown[] = [] - await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) - await scaffold?.close().catch((error: unknown) => failures.push(error)) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed') - }) - - it('sends the RL prompt and tool schemas through a real request, then executes both tools', async () => { - agentHandle.agent.followup(createUserMessage({ - content: [{ type: 'text', text: PROMPT }], - source: { kind: 'user' }, - })) - await agentHandle.agent.whenIdle() - - const requestHeader = agentHandle.agent.session.requestHeader() - if (requestHeader === undefined) throw new Error('the core Web agent issued no model request') - - const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt') - await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n') - const signal = new AbortController().signal - const bash = await scaffold.ctx.tools.execute({ - signal, - callId: CallId('core-web-bash-smoke'), - name: 'bash', - arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" }, - agent: agentHandle.agent, - }) - const editor = await scaffold.ctx.tools.execute({ - signal, - callId: CallId('core-web-editor-smoke'), - name: 'str_replace_editor', - arguments: { command: 'view', path: seedPath }, - agent: agentHandle.agent, - }) - - const text = (result: typeof bash): string => result.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') - .replaceAll(scaffold.workspaceCwd, '{{cwd}}') - .trimEnd() - - expect({ - prompt: requestHeader.system, - tools: requestHeader.tools?.map(tool => tool.name), - bash: text(bash), - editor: text(editor), - }).toMatchInlineSnapshot(` - { - "bash": "CORE_WEB_BASH_OK", - "editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines): - 1 CORE_WEB_EDITOR_OK - 2", - "prompt": "You are a helpful software engineer assistant.", - "tools": [ - "bash", - "str_replace_editor", - ], - } - `) - expect(requestHeader.tools).toEqual(scaffold.ctx.tools.schemas(agentHandle.agent)) - - const entries = [...scaffold.ctx.loader.entries()] - expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'web-runtime')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'workspace-context')?.fiber).toBeUndefined() - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) - }) - - it('uses DSH_SYSTEM_PROMPT as the complete prompt when configured', async () => { - const previous = process.env.DSH_SYSTEM_PROMPT - process.env.DSH_SYSTEM_PROMPT = 'RL prompt override' - let overrideScaffold: WebScaffold | undefined - let overrideAgent: AgentHandle | undefined - try { - overrideScaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE }) - overrideAgent = await overrideScaffold.ctx.agents.create({ - sessionId: SessionId('core-web-profile-override'), - meta: { cwd: overrideScaffold.workspaceCwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - overrideAgent.agent.followup(createUserMessage({ - content: [{ type: 'text', text: PROMPT }], - source: { kind: 'user' }, - })) - await overrideAgent.agent.whenIdle() - expect(overrideAgent.agent.session.requestHeader()?.system).toBe('RL prompt override') - } finally { - try { - await overrideAgent?.dispose() - } finally { - try { - await overrideScaffold?.close() - } finally { - if (previous === undefined) Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT') - else process.env.DSH_SYSTEM_PROMPT = previous - } - } - } - }) -}) diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts new file mode 100644 index 0000000000..0c8c6fa765 --- /dev/null +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -0,0 +1,115 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/minimal-preset', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const PROMPT = 'Reply exactly MINIMAL_PRESET_REQUEST_OK and stop.' + +describe('minimal agent preset', () => { + let scaffold: WebScaffold + let agentHandle: AgentHandle + let disposeInjectedPrompt: () => void + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE }) + disposeInjectedPrompt = scaffold.ctx.systemPrompt.section({ + name: 'test:injected-prompt', + order: 999, + text: 'THIS TEXT MUST NOT REACH THE MODEL.', + }) + agentHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('minimal-preset-smoke'), + meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) + try { + disposeInjectedPrompt?.() + } catch (error: unknown) { + failures.push(error) + } + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed') + }) + + it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => { + agentHandle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: PROMPT }], + source: { kind: 'user' }, + })) + await agentHandle.agent.whenIdle() + + const requestHeader = agentHandle.agent.session.requestHeader() + if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') + + const stateDir = join(scaffold.workspaceCwd, 'persistent-state') + await mkdir(stateDir) + const signal = new AbortController().signal + await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-bash-state-setup'), + name: 'bash', + arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` }, + agent: agentHandle.agent, + }) + const bash = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-bash-state-read'), + name: 'bash', + arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' }, + agent: agentHandle.agent, + }) + const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt') + await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n') + const editor = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-editor-smoke'), + name: 'str_replace_editor', + arguments: { command: 'view', path: seedPath }, + agent: agentHandle.agent, + }) + + const text = (result: typeof bash): string => result.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .replaceAll(scaffold.workspaceCwd, '{{cwd}}') + .trimEnd() + + expect({ + prompt: requestHeader.system, + tools: requestHeader.tools?.map(tool => tool.name), + bash: text(bash), + editor: text(editor), + }).toMatchInlineSnapshot(` + { + "bash": "PERSISTED:{{cwd}}/persistent-state", + "editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines): + 1 MINIMAL_EDITOR_OK + 2", + "prompt": "You are a helpful software engineer assistant.", + "tools": [ + "bash", + "str_replace_editor", + ], + } + `) + expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name))) + .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name))) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) + }) +}) diff --git a/apps/web/tests/snapshots/core-web-profile/session.jsonl b/apps/web/tests/snapshots/minimal-preset/session.jsonl similarity index 73% rename from apps/web/tests/snapshots/core-web-profile/session.jsonl rename to apps/web/tests/snapshots/minimal-preset/session.jsonl index 04f0d62d15..49977be802 100644 --- a/apps/web/tests/snapshots/core-web-profile/session.jsonl +++ b/apps/web/tests/snapshots/minimal-preset/session.jsonl @@ -1,7 +1,7 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}"} -{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly CORE_WEB_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}","agentPreset":"minimal"} +{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly MINIMAL_PRESET_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"assistant/chunk","seq":1,"time":1785974400002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CORE_WEB_REQUEST_OK"}}} -{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORE_WEB_REQUEST_OK"}}}} +{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"MINIMAL_PRESET_REQUEST_OK"}}} +{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"MINIMAL_PRESET_REQUEST_OK"}}}} {"type":"assistant/chunk","seq":4,"time":1785974400005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} {"type":"assistant/chunk","seq":5,"time":1785974400006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index da25f0cc59..6b3c7518de 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,7 +24,7 @@ "exclude": [ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", - "tests/core-web-profile.snapshot.ts", + "tests/minimal-preset.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09c961ef69..bfbfbc9ec1 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 51c6ae46eeca1279390c9d9315a6161edd2de618 -config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df +config-catalog.md: e6dd9ddf067202d7b60158b72008b8d8ab8adb87 +config-catalog.zh.md: 50d4c518b9ec079fe8402ea2f79f32e91cfbabe1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51c6ae46ee..e6dd9ddf06 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1144,6 +1144,8 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } ``` @@ -1986,7 +1988,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dc93f5b4b5..50d4c518b9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1146,6 +1146,8 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } ``` @@ -1988,7 +1990,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a2caf7b784..58c381e879 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: b78171ce51931f02a3f39ef98104ea9dedc27360 -event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f +event-producer-consumer.md: 7df7cb82db2b5c90556166f0ae8a7641a52c1b86 +event-producer-consumer.zh.md: 85528cb1b2acefc6bfdfd564674fb53710e1b3ce diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b78171ce51..7df7cb82db 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,8 +41,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c044385bf9..85528cb1b2 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -43,8 +43,8 @@ | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index c24ae31019..a63870cd80 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.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 docs/subsystems/system-prompt.md -system-prompt.md: bdc0e994fb8e784a19814574c405d8cc3dce2d11 -system-prompt.zh.md: db6932b18f4721020fed567d49727f863eb06608 +system-prompt.md: 56617ef9d3d8da89673a4624abcef73e58d72cab +system-prompt.zh.md: cafea4f9689879b3fd8d0e1fff7249fcb02a7c12 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index bdc0e994fb..56617ef9d3 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## Prompt sections -`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. +`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. One effective `complete` section becomes the sole prompt section after cooperative assembly. ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -58,6 +58,13 @@ interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } ``` @@ -132,14 +139,16 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:334`](../../packages/core/system-prompt/src/index.ts) @@ -149,7 +158,7 @@ Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/sys #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. +Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** @@ -157,7 +166,9 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -167,7 +178,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc Types: [Scoped](scope.md) -Source: [`packages/core/system-prompt/src/index.ts:29`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:31`](../../packages/core/system-prompt/src/index.ts) @@ -184,5 +195,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:37`](../../packages/core/system-prompt/src/index.ts) diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index db6932b18f..cafea4f968 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## 提示词段落 -`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。 +`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。 ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -58,6 +58,13 @@ interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } ``` @@ -132,14 +139,16 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:334`](../../packages/core/system-prompt/src/index.ts) @@ -149,7 +158,7 @@ Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/sys #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. +Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** @@ -157,7 +166,9 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -167,7 +178,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc Types: [Scoped](scope.md) -Source: [`packages/core/system-prompt/src/index.ts:29`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:31`](../../packages/core/system-prompt/src/index.ts) @@ -184,5 +195,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:37`](../../packages/core/system-prompt/src/index.ts) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 216eb13199..6a1f2376c3 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -91,9 +91,9 @@ # assembly fact of dsh-web-app, never user config), mounts the # frontend-static fallback owner, registers the web-surface prompt # section and bash runtime variables, and prints the URL line. `dsh web` - # patches mode/lanAddresses over these defaults; complete-prompt overlays - # set surfaceContext false to suppress every model- and shell-visible Web - # runtime contribution. + # patches mode/lanAddresses over these defaults. A complete agent-preset + # persona suppresses the prompt section for that agent while retaining + # these host-owned shell variables. - id: web-runtime name: '@deepseek-ai/dsh-web-app' config: diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index 7d4e8f07bb..b1f068fa39 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/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/core/system-prompt/README.md -README.md: 13b05bfcd19212ade42f22ece455871d022e6260 -README.zh.md: 0f9e7a2358134018975db1bc3c6b7206a274b3ec +README.md: cedda783d549633f5be9765a9a074e968d99500d +README.zh.md: 41729cdd1cfe6ebbd86f38c15bab5c50bd6ff7d2 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 13b05bfcd1..cedda783d5 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -16,19 +16,19 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. A `complete: true` section becomes the exact complete prompt after the assembly waterfall; more than one effective complete section rejects assembly. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores an effective complete section as the sole prompt section. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects for multiple complete sections, when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. ### Live events -`system-prompt/assemble` is authoritative; listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts. +`system-prompt/assemble` is authoritative for ordinary sections; a complete section is the final prompt constraint applied after the waterfall. Listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts. ### Key types - `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. -- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. +- `PromptSection` — `{ name, order, text, complete? }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. One effective `complete` section suppresses all other sections after cooperative assembly. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. @@ -39,7 +39,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse - Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`. - Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …). - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. -- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller. +- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller before any complete-section constraint is enforced. Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). @@ -49,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple #### What the model sees -By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. +By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete; that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain. ##### Harness identity diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 0f9e7a2358..41729cdd1c 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -16,21 +16,21 @@ ### 公开 API -- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 +- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。一个 `complete: true` 段会在组装 waterfall 之后成为精确的完整提示词;有效 complete 段超过一个时,组装会被拒绝。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。 - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。 -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,之后将一个有效的 complete 段恢复为唯一的提示词段落。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。存在多个 complete 段、已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 ### 实时事件 -`system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。 +`system-prompt/assemble` 对普通段落具有权威性;complete 段是在 waterfall 之后应用的最终提示词约束。替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。 ### 关键类型 - `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 -- `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。 +- `PromptSection`:`{ name, order, text, complete? }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。协作式组装完成后,一个有效的 `complete` 段会抑制其他所有段落。 - `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。 - `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}`(`{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。 @@ -41,7 +41,7 @@ - 段提供方:工具包拥有跨调用引导(`tool:bash`、`tool:read` 等);此插件拥有 `harness:identity` 与 `deployment:persona`。 - 变量提供方:agent loop(智能体循环)注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。 - 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。 -- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。 +- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果,之后再实施 complete 段约束。 设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 @@ -51,7 +51,7 @@ #### 模型看到的内容 -默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 +默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete;此时,该确切段落会成为完整的系统提示词,而 waterfall 得到的上下文、工具和变量保持不变。 ##### Harness 身份 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 23b5936e08..22bcd1aa9d 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -21,7 +21,9 @@ declare module 'cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -63,6 +65,13 @@ export interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } /** Dynamic model context materialized as a durable user-role snapshot. */ @@ -428,9 +437,11 @@ export class SystemPrompt extends Service { /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ // Keep configuration failures on the declared asynchronous error path. async assemble(context: AssembleContext = {}): Promise { @@ -467,13 +478,25 @@ export class SystemPrompt extends Service { collected.push(...schemas) for (const name of acceptedKnownNames) knownNames.add(name) } + const completeSections = [...sectionByName.values()].filter(section => section.complete === true) + if (completeSections.length > 1) { + throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`) + } + const sections = [...sectionByName.values()] + .sort((a, b) => a.order - b.order) + .map(section => ({ + name: section.name, + text: typeof section.text === 'function' ? section.text(context) : section.text, + })) + const completeName = completeSections[0]?.name + let completeSection: AssembledSection | undefined + if (completeName !== undefined) { + const assembled = sections.find(section => section.name === completeName) + if (assembled === undefined) throw new Error(`complete prompt section ${JSON.stringify(completeName)} did not assemble`) + completeSection = { ...assembled } + } const assembly: PromptAssembly = { - sections: [...sectionByName.values()] - .sort((a, b) => a.order - b.order) - .map(section => ({ - name: section.name, - text: typeof section.text === 'function' ? section.text(context) : section.text, - })), + sections, contexts: [...contextByName.values()] .sort((a, b) => a.order - b.order) .map(entry => ({ @@ -483,10 +506,12 @@ export class SystemPrompt extends Service { tools: orderTools(collected, this.toolOrder, knownNames), variables, } - return this.ctx.waterfall( + const transformed = await this.ctx.waterfall( scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly), ) + if (completeSection === undefined) return transformed + return { ...transformed, sections: [completeSection] } } } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 834c17c341..b51eaf8fc9 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -264,6 +264,34 @@ describe('SystemPrompt', () => { expect(assembly.sections).toHaveLength(0) }) + it('restores one complete section after the assembly waterfall', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'complete', order: 10, text: 'Exact prompt.', complete: true }) + ctx.systemPrompt.section({ name: 'extra', order: 20, text: 'extra' }) + ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + const complete = assembly.sections.find(section => section.name === 'complete') + if (complete === undefined) throw new Error('complete section missing before waterfall') + complete.text = 'mutated' + assembly.sections.push({ name: 'late', text: 'late' }) + return next() + }, { prepend: true }) + + expect((await ctx.systemPrompt.assemble()).sections).toEqual([ + { name: 'complete', text: 'Exact prompt.' }, + ]) + }) + + it('rejects multiple effective complete sections', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'first', order: 10, text: 'first', complete: true }) + ctx.systemPrompt.section({ name: 'second', order: 20, text: 'second', complete: true }) + + await expect(ctx.systemPrompt.assemble()) + .rejects.toThrow('multiple complete prompt sections are active: "first", "second"') + }) + it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index eb707c01f6..00102e9e51 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -331,11 +331,11 @@ describe('agentPreset.select', () => { }) it('records the switch in the log, and the list reads it back', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' })) await api.agentPresets.select( - request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' })) + request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' })) // The header is written once at creation, so the switch lives in the log — // this is what a restart replays and what every projection resolves from. @@ -343,11 +343,11 @@ describe('agentPreset.select', () => { const session = ctx.sessions.get(SessionId('sel-log')) if (session === undefined) throw new Error('unreachable') expect(session.header.agentPreset).toBe('standard') - expect(resolveSessionPreset(session)).toBe('core-web') + expect(resolveSessionPreset(session)).toBe('minimal') const listed = await api.sessions.list(request({})) if (!listed.result.ok) throw new Error('unreachable') expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset) - .toBe('core-web') + .toBe('minimal') }) it('frames the committed switch so clients can drop that session\'s catalogs', async () => { @@ -382,14 +382,14 @@ describe('agentPreset.select', () => { }) it('serializes two concurrent selects on one session', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) // Both pass the blank check; unserialized, the second unmount finds no // record because the first already removed it, and two compositions end up // in one agent layer. The client's busy flag is not enforcement. const [first, second] = await Promise.all([ - api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })), + api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })), api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })), ]) @@ -612,7 +612,7 @@ describe('skills over the layered host registry', () => { }) it('resolves a cold session to its recorded preset standing key', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) const seen: unknown[] = [] ctx.provide('skills', { list: (options: { scope?: unknown }) => { @@ -620,12 +620,12 @@ describe('skills over the layered host registry', () => { return Promise.resolve([]) }, } as never) - ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } }) + ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } }) const response = await api.skills.list(request({ sessionId: SessionId('h2') })) expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) - expect(seen).toEqual([standingKeys.get('core-web')]) + expect(seen).toEqual([standingKeys.get('minimal')]) }) it('serves the global view when the roster no longer supplies the recorded preset', async () => { @@ -648,8 +648,8 @@ describe('skills over the layered host registry', () => { describe('session.history presenter scope', () => { it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => { - const { api } = await harness(['standard', 'core-web']) - await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' })) + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' })) // Cold: creation registered a live agent in this harness, so simulate the // cold path by asking for a session only persistence knows... the harness // has no persistence, so read the live one and assert no roster query. diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml index c4573b49f8..f40850a6c9 100644 --- a/packages/preset/persona/README.i18n.yaml +++ b/packages/preset/persona/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/preset/persona/README.md -README.md: 789776b32d907f7d217accccbca5508f88de0ed1 -README.zh.md: 4e28d75bbd4fd22b77a0fa3b18c5f19df08588d8 +README.md: 742141e65fa8d50b89e6b74e6d21aa8c5bfe98cd +README.zh.md: add106adb5b81e45d8c6929a9a0f98b5c0072a01 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md index 789776b32d..742141e65f 100644 --- a/packages/preset/persona/README.md +++ b/packages/preset/persona/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The agent persona as a composable row. One config field, one prompt section. +The agent persona as a composable row. It can either shadow the deployment persona or own the complete system prompt. [`dsh-system-prompt`](../../core/system-prompt/README.md) owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An [agent preset](../agent-presets/README.md) cannot mount the prompt registry itself — without a row of its own, a preset could change an agent's tools but never its identity. This package is that row. @@ -15,8 +15,9 @@ Mounting this row outside an agent scope collides with the registry's own `deplo | Field | Default | Meaning | |---|---|---| | `text` | required | Persona prose rendered as the `deployment:persona` section | +| `complete` | `false` | Restore this persona after assembly as the only system-prompt section | -`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. +`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. With `complete: true`, assembly still resolves contexts, tools, variables, and cooperative listeners, then the prompt registry restores this exact persona as the sole section; no identity, tool guidance, or listener can append prompt text. ## Model Experience @@ -24,11 +25,11 @@ Mounting this row outside an agent scope collides with the registry's own `deplo #### What the model sees -The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. +The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. In complete mode, the model sees only this rendered section as its system prompt. #### Token effect -Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. +Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. Complete mode removes every other system-prompt token for that agent. #### KV Cache effect diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md index 4e28d75bbd..add106adb5 100644 --- a/packages/preset/persona/README.zh.md +++ b/packages/preset/persona/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -把 agent(智能体)人设做成一个可组装的行:一个配置字段,一个提示词段落。 +把 agent(智能体)人设做成一个可组装的行:它既可以遮蔽部署级人设,也可以拥有完整系统提示词。 [`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。 @@ -15,8 +15,9 @@ | 字段 | 默认值 | 含义 | |---|---|---| | `text` | 必填 | 作为 `deployment:persona` 段落渲染的人设文本 | +| `complete` | `false` | 组装后将此人设恢复为唯一的系统提示词段落 | -`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。 +`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。启用 `complete: true` 时,组装仍会解析上下文、工具、变量和协作式监听器,之后提示词注册表将这份确切人设恢复为唯一段落;身份、工具引导或监听器都无法追加提示词文本。 ## Model Experience @@ -24,11 +25,11 @@ #### What the model sees -位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。 +位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。在完整模式下,模型只会看到这个渲染后的段落作为系统提示词。 #### Token effect -对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。 +对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。完整模式会移除该 agent 的其他所有系统提示词 token。 #### KV Cache effect diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts index ec56bcc780..a76238033d 100644 --- a/packages/preset/persona/src/index.ts +++ b/packages/preset/persona/src/index.ts @@ -38,23 +38,27 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } /** Runtime schema for the persona row. */ export const Config: z = z.object({ text: z.string().required(), + complete: z.boolean().default(false), }) /** * Register the persona section for the mounting context's scope. * @param ctx - an agent scope context; an unscoped context collides with the * prompt registry's own persona registration and rejects. - * @param config - the persona text. + * @param config - the persona text and complete-prompt policy. */ export function apply(ctx: Context, config: Config): void { ctx.effect(() => ctx.systemPrompt.section({ name: PERSONA_SECTION, order: PERSONA_ORDER, text: config.text, + complete: config.complete ?? false, }), 'persona.section()') } diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts index 5f9068fe24..be85fd285d 100644 --- a/packages/preset/persona/src/invariant.ts +++ b/packages/preset/persona/src/invariant.ts @@ -16,7 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: this row owns no event stream or mutable runtime data — it registers one - * prompt section and the prompt registry owns section identity, shadowing, and disposal. + * prompt section and the prompt registry owns identity, complete-prompt enforcement, shadowing, + * and disposal. */ const install: InvariantInstaller = () => {} diff --git a/packages/preset/persona/tests/persona.spec.ts b/packages/preset/persona/tests/persona.spec.ts index bb7555df7c..7c246e75a0 100644 --- a/packages/preset/persona/tests/persona.spec.ts +++ b/packages/preset/persona/tests/persona.spec.ts @@ -85,4 +85,21 @@ describe('the persona row', () => { expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))) .toContain('You run on deepseek-v4-pro.') }) + + it('makes a complete persona the exact prompt after every other contribution', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + ctx.systemPrompt.section({ name: 'global:extra', order: 100, text: 'global guidance' }) + + await scope.ctx.plugin(Persona, { text: 'Only this.', complete: true }) + scope.ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + assembly.sections.push({ name: 'late:extra', text: 'late guidance' }) + return next() + }, { prepend: true }) + + const assembly = await ctx.systemPrompt.assemble({ scope: key }) + expect(assembly.sections).toEqual([{ name: PERSONA_SECTION, text: 'Only this.' }]) + expect(renderPrompt(assembly)).toBe('Only this.') + }) }) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 9e63aa3ea3..2d575896d0 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1106,7 +1106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async assemble(context: AssembleContext = {}): Promise', - jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals; the returned waterfall value is authoritative.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the authoritative post-waterfall assembly.\n */', + jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals. The returned waterfall value is authoritative\n * except that an effective complete section is restored afterwards as the\n * sole prompt section.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the post-waterfall assembly with any complete prompt enforced.\n */', }, ], }, @@ -1602,7 +1602,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/assemble', mode: 'waterfall', signature: '\'system-prompt/assemble\'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise', - jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', + jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns. A registered complete section is\n * restored after this waterfall, so listeners cannot add to or replace\n * that scope\'s system prompt.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', summary: 'Expert waterfall over the assembled sections, contexts, tools, and variables.', }, { @@ -2413,7 +2413,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptSection', - declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', + declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', }, { name: 'ProviderRequestId', diff --git a/tsconfig.host.json b/tsconfig.host.json index d9bf1c29e4..027e00c7e6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -13,7 +13,7 @@ "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", - "apps/web/tests/core-web-profile.snapshot.ts", + "apps/web/tests/minimal-preset.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts", From 59a2e4d825226acc254dc37545835f2e466d0220 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 19:07:35 +0800 Subject: [PATCH 016/105] fix(ci): restore the native Windows coverage denominator to green The windows-native job has been red since #1990 put the sandbox-windows-acl sources into the Windows 100%-per-file denominator without tests carrying them, and #1543 dropped the authoring.ts V8 ignore for the POSIX-only owner-execute branch. Non-blocking at merge time, the red state has propagated to every later pull request. Cover every in-process ACL-sandbox failure branch with stub-based failure-path suites (ffi/acl/token/spawn/index), following the package's existing failure-paths pattern; the package now measures 100% per file under the Windows denominator. Exclude only the runner entry from the win32 denominator: it executes exclusively as a spawned child outside the instrumented run, and its behavior is pinned end-to-end by the runner suite. Restore the authoring.ts narrow V8 ignore and add one for the dispose token guard whose absent-token arm is lifecycle-unreachable. Update the dual-lane Agent Note with the denominator composition. --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- .../preset/agent-presets/src/authoring.ts | 1 + .../sandbox/sandbox-windows-acl/src/ffi.ts | 2 + .../sandbox/sandbox-windows-acl/src/index.ts | 2 + .../tests/acl-failure-paths.spec.ts | 456 ++++++++++++++++++ .../tests/failure-paths.spec.ts | 318 +++++++++++- .../sandbox-windows-acl/tests/ffi.spec.ts | 190 ++++++++ .../tests/index-failure-paths.spec.ts | 388 +++++++++++++++ .../tests/token-failure-paths.spec.ts | 436 +++++++++++++++++ vitest.config.ts | 10 + 12 files changed, 1806 insertions(+), 5 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 07eb13b5cd..d6e9a87840 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 6a62fddb79670c3ab4cc0446796dbffd7130aed9 -2026-08-08-native-windows-pull-request-ci.zh.md: 990b8ed1434934337b8ff20c5f3be2c03cd9c61b +2026-08-08-native-windows-pull-request-ci.md: 1c6a1c4dcf50ac6fc5d30ea5904fb81249e55dfe +2026-08-08-native-windows-pull-request-ci.zh.md: 4342362815ecf738a4730dc1417c1be0eaddf3af diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 6a62fddb79..1c6a1c4dcf 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 990b8ed143..4342362815 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,7 +18,7 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 5ac4e55874..0f8788ee9b 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -105,6 +105,7 @@ async function tightenModes(dir: string): Promise { if (entry.isDirectory()) { await tightenModes(target) } else { + /* v8 ignore next -- Windows exposes no POSIX owner-execute bit; the POSIX lane covers both file modes. */ await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700) } } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 99b3cfaff3..698f0dc2ee 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -168,12 +168,14 @@ export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { dwThreadId: 'uint32', }) +/* v8 ignore start -- layout-mismatch guards fire only on ABI breakage; verify/abi-probe.cpp pins both sizes. */ if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`) } if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) } +/* v8 ignore stop */ /** * Allocate one pointer-sized slot (for `T **` out-parameters). diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index efa966a441..4878d2a183 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -360,6 +360,8 @@ export class AclSandbox { } } const token = this.token + /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always + has its token; the guard mirrors the write-SID guard's defensive shape. */ if (token !== undefined) { try { if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts new file mode 100644 index 0000000000..e6d5914bd9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -0,0 +1,456 @@ +/** + * ACL failure-path tests with stub binding tables (the failure-paths.spec.ts + * pattern): every checked Win32 call in the lock, read-merge-write, and + * grant-skip sequence has a failing counterpart, and each failure closes the + * handles it created before throwing. The exact-ACE skip and the DACL-walk + * defenses are driven through crafted in-memory ACL/SID buffers. Pure + * stubs — no real Win32 calls, so these run on every platform; the + * real-FFI round-trip lives in acl.spec.ts (win32 only). + */ + +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts' +import { allocBytes, ptrAddress } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the grant/revoke happy path needs; every call succeeds until a field is overridden per test. */ +function aclApi(overrides: Partial = {}): Win32Bindings { + return { + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().replace(/[\\/]$/u, '') + buffer.write(temp, 'utf16le') + return temp.length + }), + createFileW: vi.fn(() => 7n), + lockFileEx: vi.fn(() => 1), + unlockFileEx: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(descriptor, PVOID, 0n) + return 0 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, 9n) + return 0 + }), + setNamedSecurityInfoW: vi.fn(() => 0), + localFree: vi.fn(() => 0n as NativePtr), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32Bindings +} + +/** One SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes), subauthorities@8. */ +function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 5]): NativePtr { + const sid = allocBytes(8) + koffi.encode(sid, 'uint8', revision) + koffi.encode(sid, 1, 'uint8', count) + authority.forEach((byte, index) => { + koffi.encode(sid, 2 + index, 'uint8', byte) + }) + return sid +} + +/** + * One in-memory ACL carrying the exact grant ACE the skip checks for: + * header (AclRevision@0, AclSize@2, AceCount@4) then one ACCESS_ALLOWED_ACE + * (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8). `match` selects + * whether the inline SID bytes equal `sid`. + */ +function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) // AclRevision + koffi.encode(acl, 2, 'uint16', 16) // AclSize: header + one 8-byte-SID ACE + koffi.encode(acl, 4, 'uint16', 1) // AceCount + const ace = 8 + koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE) + koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) + koffi.encode(acl, ace + 2, 'uint16', 8) + koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK) + const inlineSid = ace + 8 + for (let offset = 0; offset < 8; offset++) { + koffi.encode(acl, inlineSid + offset, 'uint8', match + ? koffi.decode(sid, offset, 'uint8') as number + : offset === 0 ? 9 : 0) + } + return acl +} + +describe('withPathLock failure paths', () => { + it('fails closed when CreateFileW returns an invalid handle', () => { + const api = aclApi({ createFileW: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateFileW') + }) + + it('closes the handle and reports when LockFileEx fails', () => { + const closeHandle = vi.fn(() => 1) + const api = aclApi({ lockFileEx: vi.fn(() => 0), closeHandle }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LockFileEx') + expect(closeHandle).toHaveBeenCalledWith(7n) + }) + + it('closes the handle and reports when UnlockFileEx fails', () => { + const closeHandle = vi.fn(() => 1) + const api = aclApi({ unlockFileEx: vi.fn(() => 0), closeHandle }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('UnlockFileEx') + expect(closeHandle).toHaveBeenCalledWith(7n) + }) + + it('reports a failed CloseHandle after a successful action', () => { + const api = aclApi({ closeHandle: vi.fn(() => 0) }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CloseHandle') + }) +}) + +describe('mergeAndApply failure paths', () => { + it('reports a SetEntriesInAclW failure when the directory carries no descriptor to free', () => { + const api = aclApi({ setEntriesInAclW: vi.fn(() => 5) }) // default descriptor: none + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('reports a NULL merged ACL when there is no descriptor to free', () => { + const api = aclApi({ setEntriesInAclW: vi.fn(() => 0) }) // no out slot write, no descriptor + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('frees the descriptor and reports when SetEntriesInAclW fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) // an existing explicit DACL + return 0 + }), + setEntriesInAclW: vi.fn(() => 5), + localFree, + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the descriptor and reports a NULL merged ACL', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot + localFree, + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the merged ACL and reports when SetNamedSecurityInfoW fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ setNamedSecurityInfoW: vi.fn(() => 5), localFree }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetNamedSecurityInfoW') + expect(localFree).toHaveBeenCalledWith(9n) + }) + + it('reports a failed descriptor LocalFree after a successful apply', () => { + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree: vi.fn(() => 1n as NativePtr), // both frees "fail"; the first is checked + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) + + it('reports a failed merged-ACL LocalFree after a successful apply', () => { + // No existing descriptor (the default stub): the merge's only LocalFree + // is the merged ACL's, which "fails" and is checked after the apply. + const api = aclApi({ localFree: vi.fn(() => 1n as NativePtr) }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) +}) + +describe('the exact-ACE skip and DACL-walk defenses', () => { + it('grantWrite skips the apply when the standing exact ACE matches (descriptor freed, nothing merged)', () => { + const sid = craftSid(1, 0) + const localFree = vi.fn(() => 0n as NativePtr) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree, + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('grantWrite skips the apply without freeing when the exact ACE stands but no descriptor owns it', () => { + const sid = craftSid(1, 0) + const localFree = vi.fn(() => 0n as NativePtr) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) + koffi.encode(descriptor, PVOID, 0n) // the read "returned" a bare ACL with no descriptor + return 0 + }), + localFree, + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(localFree).not.toHaveBeenCalled() + }) + + it('grantWrite reports a failed descriptor LocalFree on the exact-ACE skip path', () => { + const sid = craftSid(1, 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree: vi.fn(() => 1n as NativePtr), + }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) + + it('falls back to the merge path when the standing ACE names a different SID', () => { + const sid = craftSid(1, 0) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, false))) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats an implausibly small ACL size as no exact grant', () => { + const sid = craftSid(1, 0) + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header + koffi.encode(acl, 4, 'uint16', 1) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(acl)) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats an ACE that would overrun the ACL as no exact grant', () => { + const sid = craftSid(1, 0) + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE + koffi.encode(acl, 4, 'uint16', 1) + koffi.encode(acl, 10, 'uint16', 100) // the walk reads a lying ACE size + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(acl)) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) +}) + +describe('revokeWrite no-DACL path', () => { + it('reports nothing to revoke when the read yields neither DACL nor descriptor', () => { + // The default stub encodes a NULL DACL and a NULL descriptor. + const api = aclApi() + const sid = craftSid(1, 0) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false) + }) + + it('frees a descriptor that carries no DACL and reports nothing to revoke', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) // descriptor WITHOUT a DACL + return 0 + }), + localFree, + }) + const sid = craftSid(1, 0) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false) + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('reports a failed descriptor LocalFree on the no-DACL path', () => { + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree: vi.fn(() => 1n as NativePtr), + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + revokeWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts index 0c1d7f42b3..a6bea87998 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts @@ -11,7 +11,8 @@ import koffi from 'koffi' import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import { Win32Error } from '../src/errors.ts' -import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts' +import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from '../src/spawn.ts' +import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') @@ -136,3 +137,318 @@ describe('getTempPath buffer defense', () => { expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) }) }) + +/** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */ +function pipeOkApi(overrides: Partial = {}): { + api: Win32Bindings + closed: bigint[] + closeHandle: ReturnType +} { + const closed: bigint[] = [] + let next = 1n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, next++) + koffi.encode(writeSlot, PVOID, next++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + getLastError: vi.fn(() => 5), + closeHandle, + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32Bindings + return { api, closed, closeHandle } +} + +describe('spawn pipe failures close their handles', () => { + const token = 1n as NativePtr + + it('spawnSandboxed reports a CreatePipe failure', () => { + const api = { createPipe: vi.fn(() => 0), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreatePipe') + }) + + it('spawnSandboxed reports a NULL pipe handle after CreatePipe succeeds', () => { + const api = { createPipe: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreatePipe') + }) + + it('spawnSandboxed reports a SetHandleInformation failure', () => { + const { api } = pipeOkApi({ setHandleInformation: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetHandleInformation') + }) + + it('spawnSandboxed rejects NULL process/thread handles after a successful spawn', () => { + const { api } = pipeOkApi({ + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + }) + expect(() => spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + .toThrow(/null process\/thread handles/u) + }) +}) + +describe('spawnSandboxedInherited failure paths', () => { + const token = 1n as NativePtr + + /** The stub the inherited-happy path needs; overrides flip one call per test. */ + function inheritedApi(overrides: Partial = {}): { + api: Win32Bindings + closed: bigint[] + closeHandle: ReturnType + } { + const closed: bigint[] = [] + let std = 50n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createJobObjectW: vi.fn(() => 100n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn(() => std++), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + closeHandle, + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32Bindings + return { api, closed, closeHandle } + } + + it('closes the job and reports when GetStdHandle yields a NULL handle', () => { + const { api, closeHandle } = inheritedApi({ getStdHandle: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetStdHandle') + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('reports a SetHandleInformation failure while enabling stdio inheritance', () => { + const { api } = inheritedApi({ setHandleInformation: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetHandleInformation') + }) + + it('closes the job and reports when CreateProcessAsUserW fails', () => { + const { api, closeHandle } = inheritedApi({ createProcessAsUserW: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateProcessAsUserW') + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job and rejects NULL process/thread handles after a successful spawn', () => { + const { api, closeHandle } = inheritedApi({ + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + }) + expect(() => spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + .toThrow(/null process\/thread handles/u) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job and reports when SetInformationJobObject fails', () => { + const { api, closeHandle } = inheritedApi({ setInformationJobObject: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetInformationJobObject') + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job and reports a NULL job object', () => { + const { api } = inheritedApi({ createJobObjectW: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateJobObjectW') + }) + + it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => { + const { api, closeHandle } = inheritedApi() + const spawned = spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + expect(spawned.pid).toBe(1234) + expect(spawned.process).toBe(200n) + expect(spawned.job).toBe(100n) + // thread handle closed by the spawn; process and job handles stay with the caller. + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).not.toHaveBeenCalledWith(200n) + expect(closeHandle).not.toHaveBeenCalledWith(100n) + }) +}) + +describe('drainPipe', () => { + it('stops at ERROR_NO_DATA and closes the read end', () => { + const closeHandle = vi.fn(() => 1) + const api = { + peekNamedPipe: vi.fn(() => 0), + getLastError: vi.fn(() => abi.ERROR_NO_DATA), + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return drainPipe(api, 30n as NativePtr).then((buffer) => { + expect(buffer.length).toBe(0) + expect(closeHandle).toHaveBeenCalledWith(30n) + }) + }) + + it('reports a PeekNamedPipe failure that is not a clean EOF', () => { + const api = { + peekNamedPipe: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + closeHandle: vi.fn(() => 1), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'PeekNamedPipe' }) + }) + + it('reports a ReadFile failure after data was reported available', () => { + const api = { + peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => { + koffi.encode(totalAvail, 'uint32', 4) + return 1 + }), + readFile: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + closeHandle: vi.fn(() => 1), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'ReadFile' }) + }) + + it('drains one chunk and stops at ERROR_BROKEN_PIPE', () => { + let peeks = 0 + const api = { + peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => { + peeks++ + if (peeks > 1) return 0 + koffi.encode(totalAvail, 'uint32', 4) + return 1 + }), + readFile: vi.fn((_file: unknown, chunk: Buffer, _count: unknown, read: NativePtr) => { + chunk.write('ab', 0, 'utf8') + koffi.encode(read, 'uint32', 2) + return 1 + }), + getLastError: vi.fn(() => abi.ERROR_BROKEN_PIPE), + closeHandle: vi.fn(() => 1), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return drainPipe(api, 30n as NativePtr).then((buffer) => { + expect(buffer.toString('utf8')).toBe('ab') + }) + }) +}) + +describe('waitForExit', () => { + it('reports a WaitForSingleObject failure', () => { + const api = { + waitForSingleObject: vi.fn(() => 0xFFFFFFFF), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + }) + + it('reports a GetExitCodeProcess failure', () => { + const api = { + waitForSingleObject: vi.fn(() => 0), + getExitCodeProcess: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + }) + + it('returns the exit code and closes the process handle', () => { + const closeHandle = vi.fn(() => 1) + const api = { + waitForSingleObject: vi.fn(() => 0), + getExitCodeProcess: vi.fn((_process: unknown, slot: NativePtr) => { + koffi.encode(slot, 'uint32', 42) + return 1 + }), + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(waitForExit(api, 200n as NativePtr)).toBe(42) + expect(closeHandle).toHaveBeenCalledWith(200n) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts new file mode 100644 index 0000000000..8388911598 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -0,0 +1,190 @@ +/** + * FFI helper tests with stub binding tables (the failure-paths.spec.ts + * pattern): error formatting and temp-path decoding defenses, the + * last-error throwers' detail fallback, pointer decode NULL handling, and + * the bounded SID comparison's early exits. Pure stubs — no real Win32 + * calls, so these run on every platform; the real-FFI round-trip lives in + * acl.spec.ts and probe.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { Win32Error } from '../src/errors.ts' +import { + allocBytes, decodePtr, decodePtrAt, errorText, getTempPath, + isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32, +} from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +/** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */ +function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType } { + const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => { + const text = 'access denied' + buffer.write(text, 'utf16le') + return text.length + }) + const api = { + formatMessageW, + getLastError: vi.fn(() => 5), + } as unknown as Win32Bindings + return { api, formatMessageW } +} + +/** A minimal SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2, subauthorities@8. */ +function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 0], subs: number[] = []): NativePtr { + const sid = allocBytes(8 + subs.length * 4) + koffi.encode(sid, 'uint8', revision) + koffi.encode(sid, 1, 'uint8', count) + authority.forEach((byte, index) => { + koffi.encode(sid, 2 + index, 'uint8', byte) + }) + subs.forEach((sub, index) => { + koffi.encode(sid, 8 + index * 4, 'uint32', sub) + }) + return sid +} + +describe('errorText', () => { + it('decodes the formatted UTF-16 message and trims it', () => { + const { api } = formatApi() + expect(errorText(api, 5)).toBe('access denied') + }) + + it('returns an empty string when FormatMessageW formats nothing', () => { + const api = { formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + expect(errorText(api, 5)).toBe('') + }) +}) + +describe('getTempPath', () => { + it('decodes the NUL-terminated temp path GetTempPathW wrote', () => { + const api = { + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + buffer.write('C:\\TEMP', 'utf16le') + return 7 + }), + } as unknown as Win32Bindings + expect(getTempPath(api)).toBe('C:\\TEMP') + }) + + it('reports the Win32 failure when GetTempPathW writes nothing', () => { + const { api } = formatApi() + const failing = { ...api, getTempPathW: vi.fn(() => 0) } as Win32Bindings + let caught: unknown + try { + getTempPath(failing) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTempPathW') + }) +}) + +describe('throwLastError and throwWin32', () => { + it('throwLastError formats the system message when no detail is given', () => { + const { api } = formatApi() + let caught: unknown + try { + throwLastError(api, 'Probe') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied') + }) + + it('throwWin32 formats the system message when no detail is given', () => { + const { api } = formatApi() + let caught: unknown + try { + throwWin32(api, 'Probe', 5) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied') + }) + + it('Win32Error appends the detail when one is given', () => { + const error = new Win32Error('Probe', 5, 'the lock file path') + expect(error.name).toBe('Win32Error') + expect(error.api).toBe('Probe') + expect(error.win32Code).toBe(5) + expect(error.message).toBe('Probe failed (Win32 5): the lock file path') + }) + + it('Win32Error omits the detail suffix when none is given', () => { + const error = new Win32Error('Probe', 5) + expect(error.message).toBe('Probe failed (Win32 5)') + }) +}) + +describe('pointer NULL handling', () => { + it('isNullPtr accepts null, undefined, and the zero pointer', () => { + expect(isNullPtr(null)).toBe(true) + expect(isNullPtr(undefined)).toBe(true) + expect(isNullPtr(0n as NativePtr)).toBe(true) + expect(isNullPtr(42n as NativePtr)).toBe(false) + }) + + it('isInvalidHandle treats NULL as failure', () => { + expect(isInvalidHandle(null)).toBe(true) + expect(isInvalidHandle(undefined)).toBe(true) + expect(isInvalidHandle(0n as NativePtr)).toBe(true) + expect(isInvalidHandle(42n as NativePtr)).toBe(false) + }) + + it('decodePtrAt returns null for a NULL pointer stored in a buffer', () => { + const buffer = Buffer.alloc(8) + buffer.writeBigUInt64LE(0n, 0) + expect(decodePtrAt(buffer, 0)).toBeNull() + }) + + it('decodePtrAt returns the stored pointer value', () => { + const buffer = Buffer.alloc(8) + buffer.writeBigUInt64LE(42n, 0) + expect(decodePtrAt(buffer, 0)).toBe(42n) + }) + + it('decodePtr returns null for an unset out-parameter slot', () => { + const slot = koffi.alloc(PVOID, 1) as unknown as NativePtr + expect(decodePtr(slot)).toBeNull() + }) +}) + +describe('sameSidAt bounded comparison', () => { + it('rejects a revision mismatch before comparing anything else', () => { + const left = craftSid(1, 0) + const right = craftSid(2, 0) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('rejects a subauthority-count mismatch', () => { + const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + const right = craftSid(1, 2, [0, 0, 0, 0, 0, 5], [42, 43]) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('rejects an implausible subauthority count', () => { + const left = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1) + const right = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('rejects a differing identifier authority byte', () => { + const left = craftSid(1, 0, [0, 0, 0, 0, 0, 5]) + const right = craftSid(1, 0, [0, 0, 0, 0, 0, 6]) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('accepts identical SIDs at nonzero offsets', () => { + const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + const right = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + expect(sameSidAt(left, 4, right, 4)).toBe(true) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts new file mode 100644 index 0000000000..87fc23ea9f --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -0,0 +1,388 @@ +/** + * AclSandbox orchestration failure-path tests: the win32 resolver is mocked + * to hand each test a stub binding table, so every checked Win32 call in + * init/spawn/dispose has a failing counterpart without opening real token or + * ACL handles. Constructor validation, the fail-closed init cleanup, and the + * dispose aggregation use the same stubs. Pure stubs — no real Win32 calls, + * so these run on every platform; the real-FFI round-trip lives in + * acl.spec.ts and runner.spec.ts (win32 only). + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { PROCESS_INFORMATION } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import { AclSandbox } from '../src/index.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +type MockFn = ReturnType + +/** The stub binding table plus the mocks the assertions inspect directly. */ +interface HappyStubs { + api: Win32Bindings + setNamedSecurityInfoW: MockFn + convertStringSidToSidW: MockFn + closeHandle: MockFn + localFree: MockFn + createRestrictedToken: MockFn + createJobObjectW: MockFn + getNamedSecurityInfoW: MockFn +} + +const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined })) + +vi.mock('../src/ffi.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + win32: () => Promise.resolve(state.stubs?.api as Win32Bindings), + win32Sync: () => state.stubs?.api as Win32Bindings, + } +}) + +const scratchDirs: string[] = [] +afterAll(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-index-')) + scratchDirs.push(dir) + return dir +} + +/** + * The stub the whole happy pipeline needs: token opening, write-SID parse, + * workspace+temp grants, logon-SID scan, well-known SID, restricted token, + * default-DACL merge, piped/inherited spawns, drains, and exit waits all + * succeed. Every test flips one call per branch. + */ +function happyStubs(): HappyStubs { + let next = 0n + const fresh = () => ++next + + const openProcess = vi.fn(() => fresh()) + const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { + koffi.encode(slot, PVOID, fresh()) + return 1 + }) + const convertStringSidToSidW = vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, fresh()) + return 1 + }) + const getTempPathW = vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().replace(/[\\/]$/u, '') + buffer.write(temp, 'utf16le') + return temp.length + }) + const createFileW = vi.fn(() => fresh()) + const getNamedSecurityInfoW = vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 0n) + return 0 + }) + const setEntriesInAclW = vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, fresh()) + return 0 + }) + const setNamedSecurityInfoW = vi.fn(() => 0) + const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { + if (info === null) { + koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8) + return 0 // the size probe is expected to "fail" + } + if (cls === abi.TokenGroups) { + info.writeUInt32LE(1, 0) + info.writeBigUInt64LE(77n, abi.TOKEN_GROUPS_OFFSET) + info.writeUInt32LE(abi.SE_GROUP_LOGON_ID, abi.TOKEN_GROUPS_OFFSET + 8) + } else { + info.writeBigUInt64LE(88n, 0) // the token's current default DACL + } + return 1 + }) + const getLengthSid = vi.fn(() => 12) + const copySid = vi.fn(() => 1) + const createWellKnownSid = vi.fn(() => 1) + const isValidSid = vi.fn(() => 1) + const createRestrictedToken = vi.fn(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + _rc: unknown, _rs: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, fresh()) + return 1 + }) + const setTokenInformation = vi.fn(() => 1) + const createPipe = vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, fresh()) + koffi.encode(writeSlot, PVOID, fresh()) + return 1 + }) + const setHandleInformation = vi.fn(() => 1) + const createProcessAsUserW = vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }) + const peekNamedPipe = vi.fn(() => 0) + const readFile = vi.fn(() => 1) + const waitForSingleObject = vi.fn(() => 0) + const getExitCodeProcess = vi.fn((_process: unknown, slot: NativePtr) => { + koffi.encode(slot, 'uint32', 42) + return 1 + }) + const createJobObjectW = vi.fn(() => fresh()) + const setInformationJobObject = vi.fn(() => 1) + const assignProcessToJobObject = vi.fn(() => 1) + const resumeThread = vi.fn(() => 0) + const getStdHandle = vi.fn(() => fresh()) + const localFree = vi.fn(() => 0n) + const closeHandle = vi.fn(() => 1) + const getLastError = vi.fn(() => abi.ERROR_BROKEN_PIPE) // the drains' clean EOF + const formatMessageW = vi.fn(() => 0) + + const api = { + openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW, + lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), + getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation, + getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, + setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, + peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, + setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle, + localFree, closeHandle, getLastError, formatMessageW, + } as unknown as Win32Bindings + return { + api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, + createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, + } +} + +beforeEach(() => { + state.stubs = happyStubs() +}) + +describe('AclSandbox constructor validation', () => { + it('rejects a writable directory that does not exist', () => { + const missing = join(scratch(), 'missing') + expect(() => new AclSandbox({ writableDirs: [missing], tempDir: null, mode: 'read-only' })) + .toThrow(/writable dir does not exist/u) + }) + + it('resolves relative writable directories to absolute paths', () => { + const dir = scratch() + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' }) + expect(sandbox.writableDirs).toEqual([resolve(dir)]) + expect(sandbox.mode).toBe('read-only') + expect(sandbox.tempDir).toBeUndefined() + }) +}) + +describe('AclSandbox init', () => { + it('completes the happy workspace-write pipeline: workspace and temp grants, restricted token, resolved temp dir', async () => { + const { setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const temp = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) + await sandbox.init() + expect(sandbox.tempDir).toBe(resolve(temp)) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2) + }) + + it('defaults the temp dir to GetTempPathW when no tempDir option is given', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) + await sandbox.init() + expect(sandbox.tempDir).toBe(tmpdir().replace(/[\\/]$/u, '')) + }) + + it('applies no grants when the temp dir option is null', async () => { + const { setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + await sandbox.init() + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only + }) + + it('rejects a temp dir that does not exist', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: join(scratch(), 'missing'), writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u) + }) + + it('builds a read-only token without parsing a write SID or applying grants', async () => { + const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' }) + await sandbox.init() + expect(convertStringSidToSidW).not.toHaveBeenCalled() + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free + }) + + it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => { + const { setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false }) + await sandbox.init() + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke + }) + + it('refuses a second init on the same instance', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' }) + await sandbox.init() + await expect(sandbox.init()).rejects.toThrow(/already initialized/u) + }) + + it('reports a ConvertStringSidToSidW failure before granting anything', async () => { + const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs + convertStringSidToSidW.mockReturnValue(0) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' }) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + }) + + it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => { + const { convertStringSidToSidW } = state.stubs as HappyStubs + convertStringSidToSidW.mockImplementation(() => 1) // no out slot write + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error) + }) + + it('reports a failed close of the current process token', async () => { + const { closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' }) + // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the + // token-layer close of 1n succeeds and init's close of 2n fails. + closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1)) + await expect(sandbox.init()).rejects.toMatchObject({ api: 'CloseHandle' }) + // The failed init never stored a restricted token: dispose skips the + // token close and the already-drained allocations. + expect(() => { sandbox.dispose() }).not.toThrow() + }) + + it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => { + const { createRestrictedToken, localFree, getNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const temp = scratch() + let inCleanup = false + createRestrictedToken.mockImplementation(() => { + inCleanup = true // the grants already landed: every later call is the cleanup's + return 0 + }) + localFree.mockImplementation(() => (inCleanup ? 1n : 0n)) + getNamedSecurityInfoW.mockImplementation(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + if (inCleanup) return 2 // the cleanup's revocation read fails too + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 0n) + return 0 + }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-10', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toThrow(/3 grant revocation\(s\) also failed/u) + }) +}) + +describe('AclSandbox spawn', () => { + it('refuses to spawn before init', () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' }) + expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u) + }) + + it('pipe spawn drains empty pipes and settles with the child exit code', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace }) + expect(child.pid).toBe(1234) + const expected = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 } + await expect(child.wait()).resolves.toEqual(expected) + // The second wait reuses the settled exit-code promise instead of re-waiting. + await expect(child.wait()).resolves.toEqual(expected) + }) + + it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => { + const { closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) + await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }) + expect(closeHandle).toHaveBeenCalled() + }) + + it('inherit spawn reports a failed close of the kill-on-close job', async () => { + const { closeHandle, createJobObjectW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' }) + await sandbox.init() + let jobHandle = 0n + closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1)) + const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) + jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr + await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' }) + }) +}) + +describe('AclSandbox dispose', () => { + it('is a no-op before init', () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' }) + expect(() => { sandbox.dispose() }).not.toThrow() + }) + + it('aggregates a failing temp revocation into an AggregateError', async () => { + const { getNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const temp = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-16', mode: 'workspace-write' }) + await sandbox.init() + getNamedSecurityInfoW.mockReturnValue(2) + expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u) + }) + + it('aggregates SID and token cleanup failures into an AggregateError', async () => { + const { localFree } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' }) + await sandbox.init() + localFree.mockReturnValue(1n) + expect(() => { sandbox.dispose() }).toThrow(AggregateError) + }) + + it('reports a failed close of the restricted token', async () => { + const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' }) + let restrictedToken = 0n + createRestrictedToken.mockImplementation(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + _rc: unknown, _rs: unknown, slot: NativePtr, + ) => { + restrictedToken = 99n + koffi.encode(slot, PVOID, restrictedToken) + return 1 + }) + closeHandle.mockImplementation((handle: NativePtr) => (handle === restrictedToken ? 0 : 1)) + await sandbox.init() + expect(() => { sandbox.dispose() }).toThrow(AggregateError) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts new file mode 100644 index 0000000000..046a87664a --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -0,0 +1,436 @@ +/** + * Restricted-token failure-path tests with stub binding tables (the + * failure-paths.spec.ts pattern): every checked Win32 call in the token + * pipeline — open, logon-SID scan, well-known SID creation, default-DACL + * merge, restricted-token creation — has a failing counterpart, and each + * failure closes or frees what it created before throwing. Pure stubs — no + * real Win32 calls, so these run on every platform; the real-FFI round-trip + * lives in acl.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { allocBytes, isNullPtr } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import { + createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant, +} from '../src/token.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +describe('openCurrentProcessToken failure paths', () => { + it('reports when OpenProcess yields no handle', () => { + const api = { + openProcess: vi.fn(() => 0n), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('OpenProcess') + }) + + it('closes the process handle and reports when OpenProcessToken fails', () => { + const closeHandle = vi.fn(() => 1) + const api = { + openProcess: vi.fn(() => 7n), + openProcessToken: vi.fn(() => 0), + closeHandle, + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('OpenProcessToken') + expect(closeHandle).toHaveBeenCalledWith(7n) + }) + + it('reports a failed CloseHandle of the process handle', () => { + const api = { + openProcess: vi.fn(() => 7n), + openProcessToken: vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { + koffi.encode(slot, PVOID, 9n) + return 1 + }), + closeHandle: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CloseHandle') + }) + + it('rejects a NULL token handle after a successful OpenProcessToken', () => { + const api = { + openProcess: vi.fn(() => 7n), + openProcessToken: vi.fn(() => 1), // succeeds without writing the out slot + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('OpenProcessToken') + }) +}) + +/** + * The stub the logon-SID scan needs: the size probe writes `needed`, the + * second call fills a TOKEN_GROUPS buffer (GroupCount@0, SID pointer@8, + * attributes@16) with the state's one group. The CopySid mock comes back + * beside the table for the one test that asserts on its arguments. + */ +function logonApi(state: { + needed: number + groupCount: number + sidPtr: bigint + logon: boolean + secondOk?: boolean + sidLength?: number + copyOk?: boolean +}): { api: Win32Bindings; copySid: ReturnType } { + const copySid = vi.fn(() => (state.copyOk === false ? 0 : 1)) + const api = { + getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { + if (cls !== abi.TokenGroups) throw new Error(`unexpected token information class ${cls}`) + if (info === null) { + koffi.encode(needed, 'uint32', state.needed) + return 0 // the size probe is expected to "fail" + } + if (state.secondOk === false) return 0 + info.writeUInt32LE(state.groupCount, 0) + if (state.groupCount > 0) { + info.writeBigUInt64LE(state.sidPtr, abi.TOKEN_GROUPS_OFFSET) + info.writeUInt32LE(state.logon ? abi.SE_GROUP_LOGON_ID : 0, abi.TOKEN_GROUPS_OFFSET + 8) + } + return 1 + }), + getLengthSid: vi.fn(() => state.sidLength ?? 12), + copySid, + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, copySid } +} + +describe('findLogonSid failure paths', () => { + const token = 9n as NativePtr + + it('reports a size probe that wrote nothing', () => { + const { api } = logonApi({ needed: 0, groupCount: 0, sidPtr: 0n, logon: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('rejects an implausibly small TokenGroups size', () => { + const { api } = logonApi({ needed: 4, groupCount: 0, sidPtr: 0n, logon: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('reports a failed TokenGroups read', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, secondOk: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('skips a NULL group SID pointer and throws when no logon SID remains', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 0n, logon: true }) + expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u) + }) + + it('skips a non-logon group and throws when no logon SID remains', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: false }) + expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u) + }) + + it('reports a zero logon-SID length', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, sidLength: 0 }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetLengthSid') + }) + + it('reports a failed CopySid of the logon SID', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, copyOk: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CopySid') + }) + + it('copies the logon SID and returns the new allocation', () => { + const { api, copySid } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true }) + const copy = findLogonSid(api, token) + expect(isNullPtr(copy)).toBe(false) + expect(copySid).toHaveBeenCalledWith(12, copy, 77n) + }) +}) + +describe('makeWellKnownSid failure paths', () => { + it('reports when CreateWellKnownSid fails', () => { + const api = { + createWellKnownSid: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + makeWellKnownSid(api, abi.WinWorldSid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateWellKnownSid') + }) + + it('reports when the created well-known SID is invalid', () => { + const api = { + createWellKnownSid: vi.fn(() => 1), + isValidSid: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + makeWellKnownSid(api, abi.WinWorldSid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('IsValidSid') + }) +}) + +/** + * The stub the default-DACL merge needs: the size probe writes `needed`, the + * second call fills the DACL pointer slot, and the merge/apply calls follow + * the state's results. + */ +function daclApi(state: { + needed: number + currentDacl: bigint + secondOk?: boolean + mergeResult?: number + newDacl: bigint + setTokenInfo?: number +}): Win32Bindings { + const api = { + getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { + if (cls !== abi.TokenDefaultDacl) throw new Error(`unexpected token information class ${cls}`) + if (info === null) { + koffi.encode(needed, 'uint32', state.needed) + return 0 // the size probe is expected to "fail" + } + if (state.secondOk === false) return 0 + info.writeBigUInt64LE(state.currentDacl, 0) + return 1 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + if (state.mergeResult !== undefined && state.mergeResult !== 0) return state.mergeResult + koffi.encode(newAcl, PVOID, state.newDacl) + return 0 + }), + setTokenInformation: vi.fn(() => state.setTokenInfo ?? 1), + localFree: vi.fn(() => 0n), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return api +} + +describe('setTokenDefaultDaclGrant failure paths', () => { + const token = 9n as NativePtr + const sid = 77n as NativePtr + + it('reports a size probe that wrote nothing', () => { + const api = daclApi({ needed: 0, currentDacl: 0n, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('reports a failed default-DACL read', () => { + const api = daclApi({ needed: 8, currentDacl: 88n, secondOk: false, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('rejects a token that carries no default DACL', () => { + const api = daclApi({ needed: 8, currentDacl: 0n, newDacl: 0n }) + expect(() => { setTokenDefaultDaclGrant(api, token, sid) }).toThrow(/no default DACL/u) + }) + + it('reports a failed SetEntriesInAclW merge', () => { + const api = daclApi({ needed: 8, currentDacl: 88n, mergeResult: 5, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('rejects a NULL merged default DACL', () => { + const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('frees the merged DACL and reports when SetTokenInformation fails', () => { + const localFree = vi.fn(() => 0n) + const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n, setTokenInfo: 0 }) + ;(api.localFree as unknown as ReturnType).mockImplementation(localFree) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetTokenInformation') + expect(localFree).toHaveBeenCalledWith(99n) + }) + + it('frees the merged DACL after a successful apply', () => { + const localFree = vi.fn(() => 0n) + const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n }) + ;(api.localFree as unknown as ReturnType).mockImplementation(localFree) + setTokenDefaultDaclGrant(api, token, sid) + expect(localFree).toHaveBeenCalledWith(99n) + }) +}) + +describe('createRestrictedToken failure paths', () => { + it('builds the read-only restricting list without a write SID', () => { + const create = vi.fn(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + count: number, _sids: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, 9n) + expect(count).toBe(2) + return 1 + }) + const api = { createRestrictedToken: create } as unknown as Win32Bindings + const logon = allocBytes(12) + expect(createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')).toBe(9n) + }) + + it('builds the workspace-write restricting list with the write SID', () => { + const create = vi.fn(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + count: number, _sids: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, 9n) + expect(count).toBe(3) + return 1 + }) + const api = { createRestrictedToken: create } as unknown as Win32Bindings + const logon = allocBytes(12) + expect(createRestrictedToken(api, 1n as NativePtr, logon, 3n as NativePtr, { world: 2n as NativePtr }, 'workspace-write')).toBe(9n) + }) + + it('reports when CreateRestrictedToken fails', () => { + const api = { + createRestrictedToken: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const logon = allocBytes(12) + let caught: unknown + try { + createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateRestrictedToken') + }) + + it('rejects a NULL token handle after a successful CreateRestrictedToken', () => { + const api = { + createRestrictedToken: vi.fn(() => 1), // succeeds without writing the out slot + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const logon = allocBytes(12) + let caught: unknown + try { + createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateRestrictedToken') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 68bfafec8d..246f0e9a4d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,15 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' ] : [] +// The confinement runner entry executes exclusively as a spawned child +// process (the sandbox seam's argv-prefix wrapper): its module-level main() +// would run the confinement in-process if imported, and vitest's v8 coverage +// never measures child processes. Its behavior is pinned end-to-end by +// tests/runner.spec.ts, which spawns the real entry through tsx. +const windowsRunnerCoverageExclusions = process.platform === 'win32' + ? ['packages/sandbox/sandbox-windows-acl/src/runner.ts'] + : [] + // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh // (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts @@ -229,6 +238,7 @@ export default defineConfig({ 'packages/session/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, + ...windowsRunnerCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). From 501c3a8ab68f44628551eeac16332a53e41c94a7 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 19:17:37 +0800 Subject: [PATCH 017/105] fix(subagent): pin delegated child approvals to 'never' within the inherited sandbox scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delegated in-process child now acts only within the sandbox scope fixed at delegation: captureDelegatedPolicyOverrides still snapshots the parent's explicit sandbox override but pins the child approval policy to 'never' (instead of inheriting the parent's), so every child ask — sandbox_permissions escalations included — is rejected deterministically by ApprovalService before any answerer, with the audit pair still logged. Every in-process child additionally receives the scoped subagent:delegation runtime-context statement telling it to report a scope limitation instead of retrying. Supersedes the approval half of the policy-inheritance decision (new Agent Note cross-linked from both prior notes and the approval-seam Q&A); refreshed child snapshot fixtures carry the pinned event, and subagent-published-run-failure now persists a one-event child log. --- .../2026-07-06-approval-seam.i18n.yaml | 4 +- .../feature/2026-07-06-approval-seam.md | 2 +- .../feature/2026-07-06-approval-seam.zh.md | 2 +- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +- .../2026-07-25-subagent-policy-inheritance.md | 12 ++-- ...26-07-25-subagent-policy-inheritance.zh.md | 12 ++-- ...able-subagent-policy-inheritance.i18n.yaml | 4 +- ...continuable-subagent-policy-inheritance.md | 4 +- ...tinuable-subagent-policy-inheritance.zh.md | 4 +- ...0-subagent-approval-pinned-never.i18n.yaml | 6 ++ ...26-08-10-subagent-approval-pinned-never.md | 34 +++++++++ ...08-10-subagent-approval-pinned-never.zh.md | 34 +++++++++ .../advanced-toolchain/session.1.jsonl | 37 +++++----- .../advanced-toolchain/session.2.jsonl | 37 +++++----- .../advanced-toolchain/session.jsonl | 2 +- .../session.1.jsonl | 57 +++++++-------- .../session.jsonl | 2 +- .../session.1.jsonl | 35 ++++----- .../session.jsonl | 2 +- .../subagent-continuable/session.1.jsonl | 69 +++++++++--------- .../subagent-continuable/session.jsonl | 2 +- .../session.1.jsonl | 57 +++++++-------- .../session.2.jsonl | 57 +++++++-------- .../session.jsonl | 2 +- .../snapshots/subagent-fork/session.1.jsonl | 38 +++++----- .../subagent-list-agents/session.1.jsonl | 35 ++++----- .../subagent-list-agents/session.jsonl | 2 +- .../snapshots/subagent-mixed/session.1.jsonl | 43 +++++------ .../snapshots/subagent-mixed/session.2.jsonl | 40 ++++++----- .../snapshots/subagent-mixed/session.jsonl | 2 +- .../snapshots/subagent-multi/session.1.jsonl | 43 +++++------ .../snapshots/subagent-multi/session.2.jsonl | 45 ++++++------ .../snapshots/subagent-multi/session.jsonl | 2 +- .../session.1.jsonl | 2 + .../snapshots/subagent-report/session.1.jsonl | 55 +++++++------- .../snapshots/subagent-report/session.jsonl | 2 +- .../snapshots/subagent-spawn/session.1.jsonl | 43 +++++------ .../snapshots/subagent-spawn/session.jsonl | 2 +- .../snapshots/workflow-run/session.1.jsonl | 43 +++++------ .../snapshots/workflow-run/session.jsonl | 2 +- .../advanced-toolchain/session.1.jsonl | 27 +++---- .../advanced-toolchain/session.2.jsonl | 27 +++---- .../advanced-toolchain/session.jsonl | 28 ++++---- .../parent-override/child.expected.jsonl | 2 +- .../notifications.expected.jsonl | 63 ++++++++-------- .../snapshots/subagent-spawn/session.1.jsonl | 29 ++++---- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../tests/inheritance.spec.ts | 71 ++++++++++++++++--- .../tests/structured.spec.ts | 5 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 22 ++++-- packages/subagent/subagent/README.zh.md | 22 ++++-- packages/subagent/subagent/src/child-agent.ts | 69 ++++++++++++------ .../subagent/subagent/src/continuation.ts | 12 ++-- .../tests/continuation-inheritance.spec.ts | 49 +++++++++---- .../subagent/tests/continuation.spec.ts | 4 +- .../tests/tool-subagent-control.spec.ts | 4 +- .../tests/tool-subagent-report.spec.ts | 4 +- .../verify-package-readme-model-experience.ts | 1 - 61 files changed, 781 insertions(+), 550 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index c1ba01b255..ea386ac60b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.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-06-approval-seam.md -2026-07-06-approval-seam.md: 7c830d93f19a40ab193cfebabca854882ab68d62 -2026-07-06-approval-seam.zh.md: 9dedfddadc23b0da44b28e8750508653ee20bb83 +2026-07-06-approval-seam.md: 8aa9986139dae77e08c166b72545bfa688a389e0 +2026-07-06-approval-seam.zh.md: ef4ccf5fd2b54888a648737866ff6f5fe1678882 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index 7c830d93f1..8aa9986139 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -123,7 +123,7 @@ Costs and accepted limits: - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. - **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. -- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. A `'never'` parent seeds that override into each in-process child's log ([decision](2026-07-25-subagent-policy-inheritance.md)), so the child is told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). +- **How do subagents' approvals route?** They do not: delegation pins every in-process child to `'never'` ([approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md)), so each child ask resolves `rejected` before any answerer and the child is told up front through its runtime context. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). - **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the next atomic runtime-context snapshot states the policy; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when an answerer unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does a client get approval context?** The request carries the exact `callId` and the asker's human-readable `reason`; channel adapters may correlate richer tool-call state without duplicating arguments in the approval seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index 9dedfddadc..ef4ccf5fd2 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -123,7 +123,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 - **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。 - **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。 - **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。 -- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`'never'` 父级会把该覆盖项预置到每个进程内子 agent 的日志中([决策](2026-07-25-subagent-policy-inheritance.md)),因此子 agent 一开始就会得知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 +- **subagent 的审批如何路由?** 不路由:委派会把每个进程内子 agent 钉定为 `'never'`([审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)),因此子 agent 的每次 ask 都在任何应答者之前解析为 `rejected`,子 agent 则通过其运行时上下文一开始就会得知。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 - **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);下一份原子化的运行时上下文快照会声明该策略;每次成功的自动拒绝都会记录审计对。 - **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。 - **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 seam 中重复携带参数。 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 48074dd7bf..dbfaaad95a 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: 910581a595f48b356eea9c6242a06159c52b3854 -2026-07-25-subagent-policy-inheritance.zh.md: a0edb3c6beb59a9fe8fdfb801ee718f7c034c296 +2026-07-25-subagent-policy-inheritance.md: 34751a4e29e48c84d37425857b8b1b56c8d866eb +2026-07-25-subagent-policy-inheritance.zh.md: 5fa8edf04ed63da9b2e1b9a062ca2f649c8c96fb diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index 910581a595..34751a4e29 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -1,4 +1,4 @@ -# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox and approval overrides +# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox override Status: implemented @@ -6,11 +6,11 @@ English | [中文](2026-07-25-subagent-policy-inheritance.zh.md) ## Problem -Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`, or turn a parent's unattended `'never'` approval stance back into prompting behavior. +Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`. ## Decision -The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. +The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. The sandbox-policy service is optional, and only the explicit session override is copied, never deployment defaults or one-shot grants. The approval policy is not inherited: the same capture pins every child to `'never'` — the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md) supersedes this note's original approval-override inheritance. Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. @@ -18,7 +18,7 @@ Ordinary session appends validate the inherited events before publication, and p ### What a blocked child experiences -A confined child gets the ordinary denial marker. No answerer currently owns an in-process child, so an escalation request fails closed and the child reports upward; a controller-owned parent may widen its own session and delegate again. An inherited `'never'` policy tells the child not to request escalation in its first system prompt. +A confined child gets the ordinary denial marker, and an escalation request is rejected deterministically by the child's pinned `'never'` policy; the `subagent:delegation` runtime-context statement tells the child to report the limitation instead of retrying, and a controller-owned parent may widen its own session and delegate again ([approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md)). ## Alternatives considered @@ -27,10 +27,10 @@ A confined child gets the ordinary denial marker. No answerer currently owns an - **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already permits log appends before publication. - **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment. - **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening. -- **Forcing `'never'` or routing asks to the root controller** — rejected as inheritance behavior. A forced value forecloses a future child answerer; parent routing needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md). +- **Forcing `'never'`** — originally rejected here as inheritance behavior because a forced value forecloses a future child answerer; that verdict is reversed by the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md), which owns the current rationale. Routing asks to the root controller needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md). ## Consequences -- Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. +- Spawn, fork, and nested in-process children retain a parent's explicit sandbox override and are pinned to `'never'` approvals. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. - The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. - Each delegation adds at most two log-only events. `dsh-subagent` owns the optional peer types for the two policy services — its shared helpers hold the `ctx.get` consumption; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index a0edb3c6be..5fa8edf04e 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱与审批覆盖项下启动 +# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱覆盖项下启动 Status: implemented @@ -6,11 +6,11 @@ Status: implemented ## 问题 -沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级,或让父级无人值守的 `'never'` 审批立场重新变成会发起提示的行为。 +沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级。 ## 决策 -委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 +委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。沙箱策略服务为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。审批策略不继承:同一次捕获会把每个子 agent 钉定为 `'never'`——[审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)取代了本 note 原先的审批覆盖项继承。 每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 @@ -18,7 +18,7 @@ Status: implemented ### 被拦住的子 agent 会经历什么 -受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent,因此升级请求会以拒绝方式失败,由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。 +受限子 agent 会得到普通拒绝标记,升级请求则被子 agent 钉定的 `'never'` 策略确定性拒绝;`subagent:delegation` 运行时上下文声明告知子 agent 上报限制而不是重试,由控制器持有的父 agent 可以放宽自己的会话后重新委派([审批钉定决策](2026-08-10-subagent-approval-pinned-never.md))。 ## 考虑过的替代方案 @@ -27,10 +27,10 @@ Status: implemented - **首个提示词监听器**:不予采纳。尽管创建事务已经允许在发布前追加日志,它仍会引入监听器顺序与更晚的时序边界。 - **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会记录任何值,因此其子 agent 跟随当前部署。 - **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 -- **强制使用 `'never'` 或把 ask 路由到根控制器**:不作为继承行为采纳。强制值会排除未来的子 agent 应答器;父级路由需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 +- **强制使用 `'never'`**:本 note 当初不作为继承行为采纳,理由是强制值会排除未来的子 agent 应答器;该结论已被[审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)推翻,现行理由归其所有。把 ask 路由到根控制器需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 ## 后果 -- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 +- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱覆盖项,并被钉定为 `'never'` 审批。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 - 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 - 每次委派最多增加两条仅日志事件。两个策略服务的可选 peer 类型由 `dsh-subagent` 拥有——其共享辅助函数持有 `ctx.get` 消费;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml index 54bc9adfb4..ac90a1c70d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.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-08-10-continuable-subagent-policy-inheritance.md -2026-08-10-continuable-subagent-policy-inheritance.md: 04bcd0329a4608445b672d75b6c1e56dd265b25a -2026-08-10-continuable-subagent-policy-inheritance.zh.md: 9ef457df814ed848b04f42c5891024a0890bc9dd +2026-08-10-continuable-subagent-policy-inheritance.md: c9b75f2840eb2f124f040d138b761ee145fc6f83 +2026-08-10-continuable-subagent-policy-inheritance.zh.md: 8bd7f68c578ed827c756a415f017eb8cb61e5721 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md index 04bcd0329a..c9b75f2840 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md @@ -10,7 +10,7 @@ The one-shot in-process driver has seeded parent sandbox/approval overrides into ## Decision -The capture/append pair moved from the one-shot driver into the seam's shared child-agent module (`dsh-subagent/src/child-agent.ts`), the declared one home for shared child composition: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` through optional `ctx.get`, and `appendDelegatedPolicyOverrides(childSession, overrides)` appends the `source: 'delegation'` events. The one-shot driver and the continuation manager both call them, so the two paths cannot drift. +The capture/append pair moved from the one-shot driver into the seam's shared child-agent module (`dsh-subagent/src/child-agent.ts`), the declared one home for shared child composition: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf(parent.session)` through optional `ctx.get` and pins the child approval policy to `'never'` ([approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md)), and `appendDelegatedPolicyOverrides(childSession, overrides)` appends the `source: 'delegation'` events. The one-shot driver and the continuation manager both call them, so the two paths cannot drift. `startContinuable` captures before its first await (`prepareContinuable`), the same "a later parent switch belongs to the parent's future" boundary as one-shot. The snapshot travels in `MaterializeInputs.create`, so only fresh materialization appends the events during unpublished setup, after any fork seed. A cold resume passes no `create` inputs and appends nothing: the persisted child log already carries the delegation events, and replaying the log IS the state. The durable child log — not the current Activation, not the resuming parent — owns the child's effective policy, so a parent switch between residency epochs never retroactively changes a durable child. @@ -23,7 +23,7 @@ The capture/append pair moved from the one-shot driver into the seam's shared ch ## Consequences -- Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox and approval overrides; compositions without either policy service behave unchanged. +- Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox override and pins the child to `'never'` approvals; compositions without either policy service behave unchanged. - `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` drops its policy-service peers and type imports entirely and delegates to the shared helpers. - The continuable suite (`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`) pins fresh-start seeding, pre-await capture, default omission, cold-resume snapshot stability, and fork-seed precedence; the ACP snapshot scenario `subagent-continuable-inheritance` pins the child's delegation event and read-only runtime context through the assembled app and fails when the capture is removed. - Out-of-process providers (`acp`, `dsh-sdk`, `claude-code`, `codex`) support no continuable children (`prepareContinuable` absent), and their one-shot children keep their own deployment policy (`inheritsParentContext = false`); cross-process policy propagation remains out of scope. diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md index 9ef457df81..8bd7f68c57 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -捕获/追加这对函数从一次性驱动器移入该 seam 的共享子 agent 模块(`dsh-subagent/src/child-agent.ts`),即声明的共享子级组合唯一归属之处:`captureDelegatedPolicyOverrides(parent)` 通过可选的 `ctx.get` 对 `sandboxPolicy.overrideOf(parent.session)` 与 `approval.overrideOf(parent.session)` 建立快照,`appendDelegatedPolicyOverrides(childSession, overrides)` 则追加 `source: 'delegation'` 事件。一次性驱动器与继续执行管理器都调用它们,因此两条路径不会出现偏差。 +捕获/追加这对函数从一次性驱动器移入该 seam 的共享子 agent 模块(`dsh-subagent/src/child-agent.ts`),即声明的共享子级组合唯一归属之处:`captureDelegatedPolicyOverrides(parent)` 通过可选的 `ctx.get` 对 `sandboxPolicy.overrideOf(parent.session)` 建立快照,并把子级审批策略钉定为 `'never'`([审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)),`appendDelegatedPolicyOverrides(childSession, overrides)` 则追加 `source: 'delegation'` 事件。一次性驱动器与继续执行管理器都调用它们,因此两条路径不会出现偏差。 `startContinuable` 在其第一次 await(`prepareContinuable`)之前完成捕获,沿用与一次性路径相同的「父级后续切换属于父级的未来」边界。快照放在 `MaterializeInputs.create` 中传递,因此只有全新物化会在未发布的设置阶段、排在任何 fork 种子之后追加这些事件。冷恢复(cold resume)不传入 `create` 输入,也不追加任何内容:持久化的子日志已经携带委派事件,而回放该日志本身就是状态。子 agent 的生效策略由持久化子日志拥有,而不是当前 Activation,也不是发起恢复的父级,因此父级在驻留纪元(residency epoch)之间的切换绝不会追溯性地改变一个持久化子 agent。 @@ -23,7 +23,7 @@ Status: implemented ## 后果 -- 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱与审批覆盖项;未组合任一策略服务的组合保持原有行为。 +- 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱覆盖项,并把子级钉定为 `'never'` 审批;未组合任一策略服务的组合保持原有行为。 - `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 完全移除自己的策略服务 peer 与类型导入,委托给共享辅助函数。 - 可继续测试套件(`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`)锁定全新启动的种子写入、await 前捕获、默认值省略、冷恢复快照稳定性与 fork 种子优先级;ACP 快照场景 `subagent-continuable-inheritance` 经组装后的应用锁定子级的委派事件与只读运行时上下文,移除捕获时即失败。 - 进程外提供方(`acp`、`dsh-sdk`、`claude-code`、`codex`)不支持可继续子 agent(没有 `prepareContinuable`),其一次性子 agent 保留自身的部署策略(`inheritsParentContext = false`);跨进程策略传播仍不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml new file mode 100644 index 0000000000..cde23b2552 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md +2026-08-10-subagent-approval-pinned-never.md: 578dbe58cd4e0a3c97552e20f27a2818ee9c9f40 +2026-08-10-subagent-approval-pinned-never.zh.md: 45bc461505b07a4c10e063a122e8003f52afd259 diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md new file mode 100644 index 0000000000..578dbe58cd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md @@ -0,0 +1,34 @@ +# Agent Note: Delegated subagents run with approvals pinned to `'never'` + +Status: implemented + +English | [中文](2026-08-10-subagent-approval-pinned-never.zh.md) + +## Problem + +A delegated child that asked for approval had no one to ask. Under an interactive parent (`'ask'`), a background child's escalation became a pending question no product surface showed — subagent sessions are omitted from the Web sidebar, the parent's `list_agents` reports plain `running`/`idle`, and the catalog rows show only activity — so a permission-blocked child was indistinguishable from a working one; headless and unanswered compositions failed the same ask closed as `'unavailable'`. The rejection audit landed only in the child's own log, and no tool parameter or Web control can adjust a running child session's sandbox mode or approval policy ([deepseek-harness#1723](https://github.com/deepseek-harness/deepseek-harness/issues/1723)). The mechanism-heavy fix — a durable blocked-state projection, parent notices, catalog badges, and a permission write path through the subagent ownership fence — was disproportionate directly before release. + +## Decision + +A delegated child acts only within the permission scope fixed at delegation, and approval prompts are removed from its world entirely: `captureDelegatedPolicyOverrides(parent)` (`dsh-subagent/src/child-agent.ts`) still snapshots the parent session's explicit sandbox override, but pins `approvalPolicy: 'never'` whenever the approval capability is composed — it no longer reads the parent's own approval policy. `appendDelegatedPolicyOverrides()` writes the pin as the durable `approval/policy { policy: 'never', source: 'delegation' }` event on the child's log, through the same one-shot and continuable delegation paths as the sandbox snapshot, so cold resume replays it and a fork seed's stale parent policy loses to it. + +Enforcement is the existing `ApprovalService` `'never'` semantics at the one operation that decides asks: every child ask — a `sandbox_permissions` escalation from bash or fs, a hook-driven permission question, any future asker — resolves `'rejected'` deterministically before any answerer is consulted, still leaving the `approval/asked`/`approval/decided` audit pair on the child log. The child's whole permission story is therefore its sandbox scope: a `danger-full-access` parent delegates children that need no approvals, a `read-only` parent delegates children with no escape hatch, and a widening decision always belongs to the parent side (widen the parent session, then delegate or follow up again). + +Every in-process child is told, not trapped: `applyChildComposition` registers the scoped `subagent:delegation` runtime-context statement (order 120, after the `sandbox:policy` and `approval:policy` sentences) stating that the scope was fixed at start, approval-requiring operations are rejected automatically, and a task needing wider access ends with a reported limitation instead of retries. The statement is a runtime-context contribution rather than a system-prompt section, so the deployment's system prompt stays uniform across parents and children (the snapshot suite pins that uniformity) and the fact rides the same durable snapshot as the policy sentences. + +This supersedes the approval half of the [in-process delegation-policy decision](2026-07-25-subagent-policy-inheritance.md) and reverses its "forcing `'never'` forecloses a future child answerer" verdict: approval inheritance shipped, produced the invisible blocked states above, and a future child answerer now requires reversing this note first. + +## Alternatives considered + +- **Inheriting the parent's approval override** (the prior behavior) — rejected: only a parent already at `'never'` produced deterministic children; an interactive parent seeded children whose asks waited on a prompt no one was watching or failed closed `'unavailable'`, and the outcome depended on which surfaces happened to be attached. +- **Blocked-state visibility and per-child permission adjustment** (the original #1723 acceptance) — deferred, not rejected: a `list_agents` blocked annotation, parent notices over the settlement-delivery seam, catalog badges, and a subagent-routed permission channel remain the richer design, but each needs its own seam work and none is required once children cannot enter a blocked-waiting state. +- **Routing child asks to the parent controller** — still deferred in the [approval-seam Agent Note](2026-07-06-approval-seam.md): it needs parent-chain ownership and the spawning `callId`. +- **Pinning inside `ApprovalService` by session origin** — rejected: it couples the approval package to delegation vocabulary and duplicates a decision the delegation boundary already owns; the delegation-seeded event is enforceable because no current write path can switch a child session's policy (the `/permission` command requires generic Host routing, which the subagent ownership fence denies to child sessions). + +## Consequences + +- The child's sandbox inheritance is the complete delegation permission model; the `DelegatedPolicyOverrides.approvalPolicy` field narrows to `'never' | undefined` (`undefined` only without a composed approval capability). +- Model-visible: each child's runtime-context snapshot carries the `subagent:delegation` statement plus the standing disabled-approvals sentence; parent requests are unchanged. The executor-boundary test proves a child escalation is rejected without consulting a root answerer that would have granted it, with the audit pair logged. +- Boundaries: in-process one-shot, continuable, and workflow-spawned children are enforced through the shared helpers; `subagent-acp` children keep that provider's explicit machine `permission` policy; `claude-code`, `codex`, and `dsh-sdk` children run in external processes under their own composition. +- Children persisted before the pin fold to the deployment approval default on cold resume; pre-release, no migration is added. +- Snapshot fixtures record the pin: every in-process child log gains the delegation `approval/policy` event, and `subagent-published-run-failure` now persists a one-event child log where the child previously left no durable events. diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md new file mode 100644 index 0000000000..45bc461505 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 被委派的 subagent 以钉定为 `'never'` 的审批策略运行 + +Status: implemented + +[English](2026-08-10-subagent-approval-pinned-never.md) | 中文 + +## 问题 + +被委派的子 agent 发起审批请求时无人可问。在交互式父级(`'ask'`)之下,后台子 agent 的升级请求会变成一个任何产品界面都不展示的挂起问题——subagent 会话不进入 Web 侧边栏,父级的 `list_agents` 只报告普通的 `running`/`idle`,目录树的行也只显示活动状态——因此被权限拦住的子 agent 与正常干活的子 agent 无法区分;headless 与无应答者的组合则让同一次 ask 以 `'unavailable'` 失败关闭。拒绝的审计记录只落在子 agent 自己的日志里,而且没有任何工具参数或 Web 控件能调整一个正在运行的子会话的沙箱模式或审批策略([deepseek-harness#1723](https://github.com/deepseek-harness/deepseek-harness/issues/1723))。机制繁重的修复方案——持久化的受阻状态投影、父级通知、目录树徽标,以及穿过 subagent 所有权围栏的权限写入路径——在临近发布时代价不成比例。 + +## 决策 + +被委派的子 agent 只在委派时固定的权限范围内行动,审批提示则从它的世界中彻底移除:`captureDelegatedPolicyOverrides(parent)`(`dsh-subagent/src/child-agent.ts`)仍对父会话的显式沙箱覆盖项建立快照,但只要审批能力已组合,就把 `approvalPolicy: 'never'` 钉定下来——不再读取父级自身的审批策略。`appendDelegatedPolicyOverrides()` 把这个钉定作为持久化的 `approval/policy { policy: 'never', source: 'delegation' }` 事件写入子 agent 的日志,与沙箱快照走完全相同的一次性与可继续委派路径,因此冷恢复会重放它,fork 种子中陈旧的父级策略也会输给它。 + +强制执行沿用既有的 `ApprovalService` `'never'` 语义,落在裁决 ask 的唯一操作上:子 agent 的每次 ask——bash 或 fs 的 `sandbox_permissions` 升级、hook 驱动的权限询问、任何未来的请求方——都在咨询任何应答者之前确定性地解析为 `'rejected'`,同时仍在子日志上留下 `approval/asked`/`approval/decided` 审计对。子 agent 的全部权限故事因此就是它的沙箱范围:`danger-full-access` 父级委派出的子 agent 无需任何审批,`read-only` 父级委派出的子 agent 没有任何逃生通道,而放宽的决定始终属于父级一侧(先放宽父会话,再重新委派或继续 follow-up)。 + +每个进程内子 agent 都被告知而非被困住:`applyChildComposition` 注册作用域内的 `subagent:delegation` 运行时上下文声明(order 120,位于 `sandbox:policy` 与 `approval:policy` 语句之后),声明权限范围已在启动时固定、需要审批的操作会被自动拒绝、需要更宽访问的任务应以上报限制收尾而不是重试。该声明是运行时上下文贡献而非系统提示词 section,因此部署的系统提示词在父子之间保持统一(快照测试套件钉住了这一统一性),该事实也随策略语句乘坐同一份持久化快照。 + +本决策取代[进程内委派策略决策](2026-07-25-subagent-policy-inheritance.md)中的审批一半,并推翻其「强制 `'never'` 会排除未来的子 agent 应答器」的结论:审批继承已经落地,产生的正是上述不可见的受阻状态;未来若要引入子 agent 应答器,必须先推翻本 note。 + +## 考虑过的替代方案 + +- **继承父级的审批覆盖项**(先前的行为):不予采纳。只有已处于 `'never'` 的父级才产生确定性的子 agent;交互式父级种出的子 agent,其 ask 要么等待一个无人在看的提示,要么以 `'unavailable'` 失败关闭,结果取决于当时恰好接入了哪些界面。 +- **受阻状态可见性与逐子级权限调整**(#1723 原有的验收):延后而非否决。`list_agents` 的受阻标注、经由结算投递 seam 的父级通知、目录树徽标,以及 subagent 专用的权限通道仍是更完整的设计,但每一项都需要独立的 seam 工作;一旦子 agent 不可能进入等待审批的受阻状态,这些都不再是必需。 +- **把子 agent 的 ask 路由到父控制器**:仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 延后。它需要父链所有权与发起 spawn 的 `callId`。 +- **在 `ApprovalService` 内按会话来源钉定**:不予采纳。这会让审批包耦合委派词汇,并重复一个委派边界已经拥有的决定;委派种入的事件之所以可强制执行,是因为当前不存在任何能切换子会话策略的写入路径(`/permission` 命令要求通用 Host 路由,而 subagent 所有权围栏对子会话拒绝该路由)。 + +## 后果 + +- 子 agent 的沙箱继承就是委派权限模型的全部;`DelegatedPolicyOverrides.approvalPolicy` 字段收窄为 `'never' | undefined`(仅在未组合审批能力时为 `undefined`)。 +- 模型可见:每个子 agent 的运行时上下文快照携带 `subagent:delegation` 声明以及固定的审批已禁用语句;父级请求不变。executor 边界测试证明:即使根部有一个本会批准的应答者,子 agent 的升级仍被拒绝且不咨询该应答者,审计对照常落日志。 +- 边界:进程内一次性、可继续以及 workflow 派生的子 agent 都经由共享辅助函数强制执行;`subagent-acp` 子 agent 保留该提供方显式的机器 `permission` 策略;`claude-code`、`codex` 与 `dsh-sdk` 子 agent 运行在外部进程中,由各自的组合决定。 +- 在钉定之前持久化的子 agent 冷恢复时折叠到部署审批默认值;处于预发布阶段,不添加迁移。 +- 快照夹具记录了该钉定:每个进程内子日志都新增委派 `approval/policy` 事件,`subagent-published-run-failure` 现在会持久化一份单事件子日志,而此前该子 agent 不留任何持久化事件。 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 7908f0e71b..66be552da6 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,19 +1,20 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498801881,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"}]}} -{"type":"turn/start","seq":1,"time":1785821418076,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821418076,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821418091,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} -{"type":"step/start","seq":4,"time":1785730458555,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f9a2d1b6-8f23-43a5-8702-d413fed40990"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730458555,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730458555,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":12,"time":1785498801905,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730458561,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9c977ca-2c1a-4a5e-8397-e0b9381a9943"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785730458561,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":17,"time":1785730458561,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357538290,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357538290,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"}]}} +{"type":"turn/start","seq":2,"time":1786357538290,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357538290,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357538308,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","seq":5,"time":1786357538310,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538310,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"baed758f-a123-4c3d-8587-a5b7d854f71f"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357538310,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730458555,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":13,"time":1785498801905,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":15,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":1785730458561,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9c977ca-2c1a-4a5e-8397-e0b9381a9943"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730458561,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1785730458561,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index adf877c24b..a449b90550 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,19 +1,20 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498802039,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"}]}} -{"type":"turn/start","seq":1,"time":1785821418251,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821418251,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821418270,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"step/start","seq":4,"time":1785730458703,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"dfbcd587-db47-4c3d-bbe9-8c031b215fc3"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730458703,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730458703,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":12,"time":1785498802068,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730458709,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5c33b525-4844-4272-b6f2-e036356d0e22"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785730458709,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":17,"time":1785730458709,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357538450,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357538450,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"}]}} +{"type":"turn/start","seq":2,"time":1786357538450,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357538450,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357538469,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":5,"time":1786357538470,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538471,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"01f64214-c832-47ef-8e90-052047edc27d"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357538471,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730458703,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":13,"time":1785498802068,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":15,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":1785730458709,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5c33b525-4844-4272-b6f2-e036356d0e22"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730458709,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1785730458709,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index d6935b6c98..aea9e3107c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821417919,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498801761,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"6e45782a-31be-4ba7-8c4a-7411a2027e36"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9f38e2b8-1d4e-4c90-8896-00aa42307ea7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"06416873-c855-452d-8996-ea5cf45223d1"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730458430,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498801765,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730458431,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl index 68527f63ac..857dea88b0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl @@ -1,29 +1,30 @@ {"type":"session","version":0,"id":"55555555-5555-4555-8555-555555555555","createdAt":2001,"cwd":"{{cwd}}","parentSession":"44444444-4444-4444-8444-444444444444","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1786173701247,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} -{"type":"turn/start","seq":1,"time":1786173701247,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1786173701247,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1786173701270,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} -{"type":"step/start","seq":4,"time":1786173701272,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1786173701272,"data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786173701272,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"fafefa37-7640-4c80-a00a-6a0c3ce46281"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1786173701272,"data":{"title":"Call ask_user_question once to ask","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1786173701272,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1786173701273,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":1786173701278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}} -{"type":"assistant/chunk","seq":12,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}}} -{"type":"assistant/chunk","seq":13,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1786173701279,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"301e1969-74b2-45d8-a764-604b806f1c01"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1786173701279,"data":{"turn":1,"step":1,"callId":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} -{"type":"tool/result","seq":17,"time":1786173701292,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_question"},"content":[{"type":"tool-result","toolCallId":"call_child_question","content":[{"type":"text","text":"Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result"}],"isError":true}],"role":"user","id":"b9fc0a38-47bb-4335-a8e4-c881ed66bbc3"},"error":{"name":"UserInteractionError","code":"DELEGATED_CALLER"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1786173701292,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":19,"time":1786173701309,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":20,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}} -{"type":"assistant/chunk","seq":22,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}}} -{"type":"assistant/chunk","seq":23,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} -{"type":"assistant/chunk","seq":24,"time":1786173701315,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1786173701315,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f2ada85-5967-4ed8-9e16-eaff2af847b5"},"usage":{"inputTokens":10,"outputTokens":4}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1786173701315,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":27,"time":1786173701315,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357535138,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357535138,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} +{"type":"turn/start","seq":2,"time":1786357535138,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357535138,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357535155,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} +{"type":"step/start","seq":5,"time":1786357535158,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786173701272,"data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357535158,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d8734c8a-d956-4e3f-8d28-399adf51a203"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357535158,"data":{"title":"Call ask_user_question once to ask","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786173701272,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786173701273,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":1786173701278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}} +{"type":"assistant/chunk","seq":13,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}}} +{"type":"assistant/chunk","seq":14,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1786173701279,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"301e1969-74b2-45d8-a764-604b806f1c01"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1786173701279,"data":{"turn":1,"step":1,"callId":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} +{"type":"tool/result","seq":18,"time":1786173701292,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_question"},"content":[{"type":"tool-result","toolCallId":"call_child_question","content":[{"type":"text","text":"Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result"}],"isError":true}],"role":"user","id":"b9fc0a38-47bb-4335-a8e4-c881ed66bbc3"},"error":{"name":"UserInteractionError","code":"DELEGATED_CALLER"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786173701292,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1786173701309,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}} +{"type":"assistant/chunk","seq":23,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}}} +{"type":"assistant/chunk","seq":24,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":25,"time":1786173701315,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1786173701315,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f2ada85-5967-4ed8-9e16-eaff2af847b5"},"usage":{"inputTokens":10,"outputTokens":4}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1786173701315,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1786173701315,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl index ed155f158f..92efdfa71a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1786173701175,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1786173701216,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1786173701216,"data":{"content":[{"type":"text","text":"Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result."}],"source":{"kind":"user"},"role":"user","id":"851bea02-2961-471a-84ec-3b068c451db0"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1786173701217,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"8ef74c46-9e80-475c-9093-0e85ba92e346"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786173701217,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e1f92805-80c9-46b7-94ac-6cdb05d23f86"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786173701217,"data":{"title":"Delegate one question check. Ask","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1786173701218,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1786173701219,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl index 478198cf0c..bd9557666f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl @@ -2,20 +2,21 @@ {"type":"subagent/descriptor","seq":0,"time":1786333735890,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1786333735890,"data":{}} {"type":"sandbox/mode","seq":2,"time":1786333735890,"data":{"mode":"read-only","source":"delegation"}} -{"type":"agent/inbox/spliced","seq":3,"time":1786333735891,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"}]}} -{"type":"turn/start","seq":4,"time":1786333735891,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":5,"time":1786333735891,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":6,"time":1786333735916,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":7,"time":1786333735916,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"},"surfaceOp":"append"} -{"type":"user/message","seq":8,"time":1786333735916,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"5f181e93-3fd7-40f3-b5f7-21674b53d7c7"},"surfaceOp":"append"} -{"type":"session/title","seq":9,"time":1786333735916,"data":{"title":"Reply with exactly the word","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":10,"time":1786333735916,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":11,"time":1786333735916,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":12,"time":1786333735920,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":13,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":14,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":15,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":16,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":17,"time":1786333735921,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e2b94008-b067-4ca7-a576-6b4a9060cd83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1786333735921,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":19,"time":1786333735921,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":3,"time":1786357527742,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":4,"time":1786357527743,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"}]}} +{"type":"turn/start","seq":5,"time":1786357527743,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":6,"time":1786357527743,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":7,"time":1786357527768,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":8,"time":1786333735916,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"},"surfaceOp":"append"} +{"type":"user/message","seq":9,"time":1786357527769,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"1b760052-ffcb-44d2-aae2-fd73d7c444f1"},"surfaceOp":"append"} +{"type":"session/title","seq":10,"time":1786357527769,"data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":11,"time":1786333735916,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":12,"time":1786333735916,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":13,"time":1786333735920,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":15,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":16,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":17,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":18,"time":1786333735921,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e2b94008-b067-4ca7-a576-6b4a9060cd83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786333735921,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":20,"time":1786333735921,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl index 0968357f90..15b1748ea5 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","seq":3,"time":1786333735845,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":4,"time":1786333735878,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":1786333735878,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786333735878,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9a793bfc-c155-44f8-ba56-c6843338d6be"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786333735878,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f931abf5-bb3a-44b4-8fe2-2d06e8766184"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1786333735878,"data":{"title":"Follow these steps exactly, then","messageSeqs":[5],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1786333735879,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1786333735879,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index 554de6f448..9352ca6fb9 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,37 +1,38 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785544945198,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785544945198,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":1785730451347,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"}]}} -{"type":"turn/start","seq":3,"time":1785821409024,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":1785730917162,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"agent/inbox/spliced","seq":5,"time":1785730917192,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"}]}} -{"type":"agent/inbox/spliced","seq":6,"time":1785821409076,"data":{"target":"next-turn","start":1,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"755c76db-6ee8-432d-a2d0-f8a3b7914e08"}]}} -{"type":"step/start","seq":7,"time":1785730917198,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":8,"time":1785730917198,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"},"surfaceOp":"append"} -{"type":"user/message","seq":9,"time":1785730917198,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"49acbc16-4d58-460e-8cc0-62838472dce6"},"surfaceOp":"append"} -{"type":"session/title","seq":10,"time":1785730917198,"data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":11,"time":1785730917198,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":12,"time":1785730917199,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":13,"time":1785730696668,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":14,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":15,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":16,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":17,"time":1785730451397,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":18,"time":1785730696668,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178ea526-9e19-49d2-b3b0-57b682320028"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":1785730696668,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":20,"time":1785730696669,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":21,"time":1785821409092,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":22,"time":1785821409092,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":23,"time":1785730696682,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":24,"time":1785730696682,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"},"surfaceOp":"append"} -{"type":"assistant/chunk","seq":25,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":26,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} -{"type":"assistant/chunk","seq":27,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} -{"type":"assistant/chunk","seq":28,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":29,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1785730696686,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ced209bf-5d6d-4880-b187-18cb816a150c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1785730696686,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":32,"time":1785730696686,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":33,"time":1785821409110,"data":{"turn":3}} -{"type":"agent/inbox/spliced","seq":34,"time":1785821409110,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"turn/end","seq":35,"time":1785821409122,"data":{"turn":3,"reason":{"kind":"error","error":{"message":"snapshot disk full","code":"UNKNOWN"}}}} +{"type":"approval/policy","seq":2,"time":1786357526242,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357526243,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"}]}} +{"type":"turn/start","seq":4,"time":1786357526243,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1785730917192,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","seq":6,"time":1785821409076,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"}]}} +{"type":"agent/inbox/spliced","seq":7,"time":1786357526278,"data":{"target":"next-turn","start":1,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"755c76db-6ee8-432d-a2d0-f8a3b7914e08"}]}} +{"type":"step/start","seq":8,"time":1786357526284,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":9,"time":1785730917198,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"},"surfaceOp":"append"} +{"type":"user/message","seq":10,"time":1786357526284,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"7f1d7407-d9bc-4ec6-ae42-a8767e0e1153"},"surfaceOp":"append"} +{"type":"session/title","seq":11,"time":1786357526284,"data":{"title":"Reply with exactly the word","messageSeqs":[9],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":12,"time":1785730917198,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":13,"time":1785730917199,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":14,"time":1785730696668,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":16,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":17,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":18,"time":1785730451397,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":1785730696668,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178ea526-9e19-49d2-b3b0-57b682320028"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1785730696668,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":21,"time":1785730696669,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":22,"time":1785821409092,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":23,"time":1785821409092,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":24,"time":1785730696682,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":25,"time":1785730696682,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":26,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":27,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} +{"type":"assistant/chunk","seq":29,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":30,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1785730696686,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ced209bf-5d6d-4880-b187-18cb816a150c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"} +{"type":"step/end","seq":32,"time":1785730696686,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":33,"time":1785730696686,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":34,"time":1785821409110,"data":{"turn":3}} +{"type":"agent/inbox/spliced","seq":35,"time":1785821409110,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"turn/end","seq":36,"time":1785821409122,"data":{"turn":3,"reason":{"kind":"error","error":{"message":"snapshot disk full","code":"UNKNOWN"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 898d4b250c..a759c35a32 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821408972,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730451327,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785730451327,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"125665d3-8c03-4190-b4f9-c27d61d245f4"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730451328,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e7521889-28d8-4434-84b2-21ff0e044fe7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730451328,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"533e9513-a329-4a36-9a8d-ddaf544b57c3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730451328,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730451329,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730451329,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index 8b8b8cc0c2..44d587c851 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -1,29 +1,30 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498798860,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"}]}} -{"type":"turn/start","seq":1,"time":1785821414174,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821414174,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821414185,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} -{"type":"step/start","seq":4,"time":1785730456013,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730456014,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730456014,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"3244b13c-f211-445f-acf5-fb8d1534537c"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730456014,"data":{"title":"Call subagent once. Ask that","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730456014,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730456014,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_one_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}} -{"type":"assistant/chunk","seq":12,"time":1785498798883,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1785730456018,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"21044d12-2e0e-40e3-b47e-4920e21c3e83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1785730456019,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}} -{"type":"tool/result","seq":17,"time":1785730456072,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"aa5451a8-812b-4a51-a52c-dbc5c84f16d0"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1785730456072,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":19,"time":1785730456082,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":20,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_ONE_DONE"}}} -{"type":"assistant/chunk","seq":22,"time":1785498798949,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}}} -{"type":"assistant/chunk","seq":23,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":24,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1785730456086,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1911c6-f487-458c-b802-4a66221ec046"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1785730456086,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":27,"time":1785730456086,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357533581,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357533582,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"}]}} +{"type":"turn/start","seq":2,"time":1786357533582,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357533582,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357533600,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} +{"type":"step/start","seq":5,"time":1786357533602,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730456014,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533602,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"82a11d62-da49-4ad3-a243-789ea3cd7c08"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357533602,"data":{"title":"Call subagent once. Ask that","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730456014,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730456014,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_one_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}} +{"type":"assistant/chunk","seq":13,"time":1785498798883,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1785730456018,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"21044d12-2e0e-40e3-b47e-4920e21c3e83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1785730456019,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}} +{"type":"tool/result","seq":18,"time":1785730456072,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"aa5451a8-812b-4a51-a52c-dbc5c84f16d0"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1785730456072,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1785730456082,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_ONE_DONE"}}} +{"type":"assistant/chunk","seq":23,"time":1785498798949,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}}} +{"type":"assistant/chunk","seq":24,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":25,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1785730456086,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1911c6-f487-458c-b802-4a66221ec046"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730456086,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1785730456086,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 7f2ae89966..89de9b1c4f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -1,29 +1,30 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"{{cwd}}","parentSession":"22222222-2222-4222-8222-222222222222","origin":"subagent","delegationDepth":2} -{"type":"agent/inbox/spliced","seq":0,"time":1785498798891,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"}]}} -{"type":"turn/start","seq":1,"time":1785821414201,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821414201,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821414214,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} -{"type":"step/start","seq":4,"time":1785730456041,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730456041,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730456041,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4a252f7d-8523-433f-a3fc-33812be802ec"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730456041,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730456041,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730456042,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}} -{"type":"assistant/chunk","seq":12,"time":1785498798916,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1785730456047,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"467433db-5dbf-42ee-94c0-25c011ce711b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1785730456048,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} -{"type":"tool/result","seq":17,"time":1785730456056,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_three_rejected"},"content":[{"type":"tool-result","toolCallId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true}],"role":"user","id":"9a3d59f3-542a-4400-a62c-be28dcea3bd1"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1785730456056,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":19,"time":1785730456066,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":20,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}}} -{"type":"assistant/chunk","seq":22,"time":1785498798937,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_REJECTED"}}}} -{"type":"assistant/chunk","seq":23,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":24,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1785730456070,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"57c0ecaf-3f72-4da9-9eb9-a0726e8f097a"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1785730456071,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":27,"time":1785730456071,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357533611,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357533611,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"}]}} +{"type":"turn/start","seq":2,"time":1786357533611,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357533611,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357533628,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} +{"type":"step/start","seq":5,"time":1786357533630,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730456041,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533630,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d5488efe-eea2-4019-8fcb-7e6e49077d8a"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357533630,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730456041,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730456042,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}} +{"type":"assistant/chunk","seq":13,"time":1785498798916,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1785730456047,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"467433db-5dbf-42ee-94c0-25c011ce711b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1785730456048,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} +{"type":"tool/result","seq":18,"time":1785730456056,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_three_rejected"},"content":[{"type":"tool-result","toolCallId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true}],"role":"user","id":"9a3d59f3-542a-4400-a62c-be28dcea3bd1"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1785730456056,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1785730456066,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}}} +{"type":"assistant/chunk","seq":23,"time":1785498798937,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_REJECTED"}}}} +{"type":"assistant/chunk","seq":24,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":25,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1785730456070,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"57c0ecaf-3f72-4da9-9eb9-a0726e8f097a"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730456071,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1785730456071,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl index 6288b6d516..ab699d7d18 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821414127,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1784540790308,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498798839,"data":{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"b2260a25-4667-49ed-9297-16b233f22332"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730455980,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"d3ba1b18-4d27-4c90-a95d-125e9ffc9f29"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730455980,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"55365caf-6fcc-484b-a4b7-646914654bbb"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730455980,"data":{"title":"Delegate through two child generations.","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498798841,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730455981,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index a56f7ccf60..c663606bbd 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -20,21 +20,23 @@ {"type":"step/end","seq":40,"time":1785730448979,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":41,"time":1785730448979,"data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","seq":42,"time":1785730449008,"data":{}} -{"type":"agent/inbox/spliced","seq":43,"time":1785498796160,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"}]}} -{"type":"turn/start","seq":44,"time":1785821406523,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":45,"time":1785821406523,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":46,"time":1785821406543,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} -{"type":"step/start","seq":47,"time":1785730449027,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":48,"time":1785730449027,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"},"surfaceOp":"append"} -{"type":"request/header","seq":49,"time":1785730449027,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":50,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":51,"time0":1783352138046,"data":{"turn":2,"step":1,"index":0,"dt":[0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0,0,0,0,30,2],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} -{"type":"assistant/chunk","seq":85,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":86,"time0":1783352138307,"data":{"turn":2,"step":1,"index":1,"dt":[1790166963,239266980,117223942],"texts":["M","ARM","AL","ADE"]}} -{"type":"assistant/chunk","seq":90,"time":1785498796192,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} -{"type":"assistant/chunk","seq":91,"time":1785498796192,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} -{"type":"assistant/chunk","seq":92,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":93,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":94,"time":1785730449034,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc56e00-c648-4669-92b2-7299e41cb743"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":95,"time":1785730449035,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":96,"time":1785730449035,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":43,"time":1786357523264,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":44,"time":1786357523265,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"}]}} +{"type":"turn/start","seq":45,"time":1786357523265,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":46,"time":1786357523265,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":47,"time":1786357523283,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"step/start","seq":48,"time":1786357523286,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":49,"time":1786357523286,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"},"surfaceOp":"append"} +{"type":"user/message","seq":50,"time":1786358035356,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"257e572f-6f95-48f9-b3d7-4ea8b162f374"},"surfaceOp":"append"} +{"type":"request/header","seq":51,"time":1786358035356,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":52,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":53,"time0":1783352138074,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0,0,0,0,30,2,0,0],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} +{"type":"assistant/chunk","seq":87,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":88,"time0":1785381572250,"data":{"turn":2,"step":1,"index":1,"dt":[117223942,0,0],"texts":["M","ARM","AL","ADE"]}} +{"type":"assistant/chunk","seq":92,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} +{"type":"assistant/chunk","seq":93,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} +{"type":"assistant/chunk","seq":94,"time":1786357523292,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":95,"time":1786358035361,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":96,"time":1786358035361,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc56e00-c648-4669-92b2-7299e41cb743"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":97,"time":1786358035361,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":98,"time":1786358035361,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl index d6e54e2096..d81f1964b2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl @@ -1,20 +1,21 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785531795641,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785531795641,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":1785730454803,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"}]}} -{"type":"turn/start","seq":3,"time":1785821412774,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":1785821412774,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":5,"time":1785730454835,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":6,"time":1785730454835,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1785730454835,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"524be394-8639-4c12-a41d-799b9e0120a1"},"surfaceOp":"append"} -{"type":"session/title","seq":8,"time":1785730454835,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":9,"time":1785730454835,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":10,"time":1785730454835,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":11,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":12,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":13,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":14,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":15,"time":1785730454843,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":16,"time":1785730454843,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f6a952dd-2d09-4b5c-b8ae-5456cfdfeab0"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":1785730454843,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":18,"time":1785730454844,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":2,"time":1786357532080,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357532080,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"}]}} +{"type":"turn/start","seq":4,"time":1786357532081,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1786357532081,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":1786357532106,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":1785730454835,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1786357532106,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"5fda3f8d-fbac-4878-a9e3-9953a4e1da09"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1786357532106,"data":{"title":"Reply with exactly the word","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1785730454835,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1785730454835,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":14,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":15,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":16,"time":1785730454843,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":1785730454843,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f6a952dd-2d09-4b5c-b8ae-5456cfdfeab0"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785730454843,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":19,"time":1785730454844,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl index f1deec5af9..06bd463ba8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821412725,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730454783,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785730454783,"data":{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"c2febfff-792d-4457-a944-933ff0de0570"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730454783,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"cc8cb20d-5802-46a9-87b8-d3ee784f8e52"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730454783,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"c9d1f853-56bc-4082-ae08-00d4bcbb04a6"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730454783,"data":{"title":"Call the subagent tool once","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730454784,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730454784,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index e275607cbf..e510929344 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498797416,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"}]}} -{"type":"turn/start","seq":1,"time":1785821407754,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821407754,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821407767,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} -{"type":"step/start","seq":4,"time":1785730450187,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730450187,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730450187,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4a5a7c59-b6f8-47b0-8c09-d9a05607deac"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730450187,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730450187,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730450188,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352146042,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,0,0,0,0,0,29,0,0,0,0,29,0,0],"texts":["The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":30,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":31,"time0":1783352146129,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} -{"type":"assistant/chunk","seq":34,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":35,"time":1785498797444,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":36,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":37,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":38,"time":1785730450194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cfff210d-8dd3-4acc-bbc3-fa860baf88cf"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785730450194,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":40,"time":1785730450195,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357524735,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357524735,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"}]}} +{"type":"turn/start","seq":2,"time":1786357524735,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357524735,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357524752,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} +{"type":"step/start","seq":5,"time":1786357524755,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730450187,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357524755,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"56ad93fa-0cc0-4ccb-a1b4-258f4801c681"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357524755,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730450187,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730450188,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352146042,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,0,0,0,0,0,29,0,0,0,0,29,0,0],"texts":["The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":31,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":32,"time0":1783352146129,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} +{"type":"assistant/chunk","seq":35,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498797444,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":38,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730450194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cfff210d-8dd3-4acc-bbc3-fa860baf88cf"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730450194,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730450195,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index fb6e5e0971..a5f4ab88a4 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821407687,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498797379,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"473ecf9e-52c4-4db2-be56-1c8f7fa7d932"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e0a9678e-ff95-49f4-b4f7-4ace69a670a3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730450135,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498797380,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730450136,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -20,21 +20,23 @@ {"type":"step/end","seq":34,"time":1785730450146,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":35,"time":1785730450146,"data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","seq":36,"time":1785730450227,"data":{}} -{"type":"agent/inbox/spliced","seq":37,"time":1785498797482,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"}]}} -{"type":"turn/start","seq":38,"time":1785821407808,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":39,"time":1785821407808,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":40,"time":1785821407826,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} -{"type":"step/start","seq":41,"time":1785730450246,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":42,"time":1785730450246,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} -{"type":"request/header","seq":43,"time":1785730450247,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":44,"time":1783352148076,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":45,"time0":1783352148076,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31,1,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} -{"type":"assistant/chunk","seq":76,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":77,"time0":1785142306309,"data":{"turn":2,"step":1,"index":1,"dt":[239267243,117223959],"texts":["SA","FF","RON"]}} -{"type":"assistant/chunk","seq":80,"time":1785498797511,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} -{"type":"assistant/chunk","seq":81,"time":1785498797511,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} -{"type":"assistant/chunk","seq":82,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":83,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":84,"time":1785730450254,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1f347c1-ce65-4ca9-8a9e-05e4366ef365"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":85,"time":1785730450254,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":86,"time":1785730450254,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":37,"time":1786357524782,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":38,"time":1786357524783,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"}]}} +{"type":"turn/start","seq":39,"time":1786357524783,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":40,"time":1786357524783,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":41,"time":1786357524800,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"step/start","seq":42,"time":1786357524803,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":43,"time":1786357524803,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} +{"type":"user/message","seq":44,"time":1786358036899,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"6ea5a774-b0da-47ff-84b7-226a4a207bbf"},"surfaceOp":"append"} +{"type":"request/header","seq":45,"time":1786358036900,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":46,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":47,"time0":1783352148077,"data":{"turn":2,"step":1,"index":0,"dt":[0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31,1,0,0,1790157964],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} +{"type":"assistant/chunk","seq":78,"time":1785381573552,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":79,"time0":1785498797511,"data":{"turn":2,"step":1,"index":1,"dt":[0,0],"texts":["SA","FF","RON"]}} +{"type":"assistant/chunk","seq":82,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} +{"type":"assistant/chunk","seq":83,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} +{"type":"assistant/chunk","seq":84,"time":1786357524808,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":85,"time":1786358036906,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":86,"time":1786358036906,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1f347c1-ce65-4ca9-8a9e-05e4366ef365"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":87,"time":1786358036906,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":88,"time":1786358036906,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index fa390cf176..4963b5275b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821407687,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498797379,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"473ecf9e-52c4-4db2-be56-1c8f7fa7d932"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e0a9678e-ff95-49f4-b4f7-4ace69a670a3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730450135,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498797380,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730450136,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index 7948013736..da79d6b23c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498794788,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"}]}} -{"type":"turn/start","seq":1,"time":1785821405232,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821405232,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821405245,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} -{"type":"step/start","seq":4,"time":1785730447828,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730447828,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730447828,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"bab5cdff-7925-478d-b55a-daa2ef524d7c"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730447828,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730447828,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730447828,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352128280,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,19,0,0,0,0,1,31,0,0,0,0,32,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":30,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":31,"time0":1783352128365,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} -{"type":"assistant/chunk","seq":34,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":35,"time":1785498794825,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":36,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":37,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":38,"time":1785730447834,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1e6087-da72-4a56-9bc0-ae1ac6618a8a"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785730447834,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":40,"time":1785730447834,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357521737,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357521737,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"}]}} +{"type":"turn/start","seq":2,"time":1786357521737,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357521737,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357521754,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} +{"type":"step/start","seq":5,"time":1786357521756,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730447828,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521756,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"f654b5a4-b4c0-4443-8eab-d84624d804f1"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357521756,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730447828,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730447828,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352128280,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,19,0,0,0,0,1,31,0,0,0,0,32,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":31,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":32,"time0":1783352128365,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} +{"type":"assistant/chunk","seq":35,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498794825,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":38,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730447834,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1e6087-da72-4a56-9bc0-ae1ac6618a8a"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730447834,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730447834,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index 64e58b3741..f7326e37c8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -1,23 +1,24 @@ {"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498794853,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"}]}} -{"type":"turn/start","seq":1,"time":1785821405286,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821405286,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821405299,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} -{"type":"step/start","seq":4,"time":1785730447881,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730447881,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730447881,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"036067ef-a106-4955-841c-a0d2effe51ef"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730447881,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730447881,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730447881,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352130413,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,35,0,0,0,0,0,36,0,0,0,0,0,43],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","B","ETA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":29,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":30,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} -{"type":"assistant/chunk","seq":31,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} -{"type":"assistant/chunk","seq":32,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":33,"time":1785498794882,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} -{"type":"assistant/chunk","seq":34,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":35,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":36,"time":1785730447887,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"adc4527d-efd1-4c89-b42b-826c33f2bb12"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785730447887,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":38,"time":1785730447887,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357521782,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357521783,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"}]}} +{"type":"turn/start","seq":2,"time":1786357521783,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357521783,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357521799,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} +{"type":"step/start","seq":5,"time":1786357521801,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730447881,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521802,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"05843da4-4a5f-46fc-a00f-5257b2bd271d"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357521802,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730447881,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730447881,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352130413,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,35,0,0,0,0,0,36,0,0,0,0,0,43],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","B","ETA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":30,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":32,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} +{"type":"assistant/chunk","seq":33,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":34,"time":1785498794882,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} +{"type":"assistant/chunk","seq":35,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":36,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730447887,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"adc4527d-efd1-4c89-b42b-826c33f2bb12"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730447887,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730447887,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index 3a48c2d760..a1337e166b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821405184,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352126252,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498794765,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"07bf16df-0499-420d-9510-3204061f0122"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730447790,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9b4b262d-cbd7-4cd8-b24b-70b2b401b0fe"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730447790,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"50a1100d-448e-41f2-8f99-39be199db492"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730447790,"data":{"title":"Use the subagent tool TWICE,","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498794766,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730447791,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl new file mode 100644 index 0000000000..a0433f1290 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl @@ -0,0 +1,2 @@ +{"type":"session","version":0,"id":"eb69342c-62b6-4320-a78b-961745f89333","createdAt":1786358409171,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786358409171,"data":{"policy":"never","source":"delegation"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl index b9992f1519..e32d1bdee0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl @@ -1,30 +1,31 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785594881508,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Report a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785594881508,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":1785730453612,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"}]}} -{"type":"turn/start","seq":3,"time":1785821411475,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":1785821411475,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":5,"time":1785730453639,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":6,"time":1785730453639,"data":{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1785730453639,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"67c76a21-6142-45a6-9a49-0485f51edc8d"},"surfaceOp":"append"} -{"type":"session/title","seq":8,"time":1785730453639,"data":{"title":"Call the report tool once","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":9,"time":1785730453639,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":10,"time":1785730453639,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":11,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":12,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_report_1","name":"report","argumentsDelta":"{\"output\": \"CHILD_REPORT_OK\"}"}}} -{"type":"assistant/chunk","seq":13,"time":1789000001010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}}}} -{"type":"assistant/chunk","seq":14,"time":1789000001011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":15,"time":1785730453647,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":16,"time":1785730453647,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c9e50afb-b732-41ab-b0fc-8e98948ad9ec"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} -{"type":"tool/call","seq":17,"time":1785730453647,"data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} -{"type":"tool/result","seq":18,"time":1785730453654,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 824dc60a-f9d7-48ea-a0d4-6d56df83bd4f"}],"isError":false}],"role":"user","id":"e6764773-c667-40b5-a13f-8bdc5a9c7762"}},"sourceEventSeqs":[17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":1785730453654,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":20,"time":1785730453664,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":21,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":22,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Reported."}}} -{"type":"assistant/chunk","seq":23,"time":1789000001020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Reported."}}}} -{"type":"assistant/chunk","seq":24,"time":1789000001021,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":25,"time":1785730453668,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":26,"time":1785730453668,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"96784835-2d0f-4d00-aef5-ee3a14820dd1"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} -{"type":"step/end","seq":27,"time":1785730453668,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":28,"time":1785730453668,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":2,"time":1786357530605,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357530605,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"}]}} +{"type":"turn/start","seq":4,"time":1786357530605,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1786357530605,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":1786357530633,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":1785730453639,"data":{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1786357530633,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"1677b901-cce7-461a-8b2a-7f119dd9d845"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1786357530633,"data":{"title":"Call the report tool once","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1785730453639,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1785730453639,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_report_1","name":"report","argumentsDelta":"{\"output\": \"CHILD_REPORT_OK\"}"}}} +{"type":"assistant/chunk","seq":14,"time":1789000001010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1789000001011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":16,"time":1785730453647,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":17,"time":1785730453647,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c9e50afb-b732-41ab-b0fc-8e98948ad9ec"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","seq":18,"time":1785730453647,"data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} +{"type":"tool/result","seq":19,"time":1785730453654,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 1f4b61e2-6c6d-4db8-836b-ac5760c5e484"}],"isError":false}],"role":"user","id":"cee5f084-bfab-423d-b8bc-1b1b7d88d4fa"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1785730453654,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":21,"time":1785730453664,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":22,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Reported."}}} +{"type":"assistant/chunk","seq":24,"time":1789000001020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Reported."}}}} +{"type":"assistant/chunk","seq":25,"time":1789000001021,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":26,"time":1785730453668,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":27,"time":1785730453668,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"96784835-2d0f-4d00-aef5-ee3a14820dd1"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":1785730453668,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":29,"time":1785730453668,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl index db5cd4a77f..eb36af2399 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821411429,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730453591,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785730453591,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"5cf78378-e004-4fd5-af4f-cef3b7e190ad"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"77141070-eb99-4ec0-908d-646c387982f6"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"d1a851a3-604f-4a42-8e5f-4e480857a3b4"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730453592,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730453592,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730453593,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 7fa229c2e3..5eb0455932 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"{{cwd}}","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498793648,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"}]}} -{"type":"turn/start","seq":1,"time":1785821404007,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821404007,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821404020,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} -{"type":"step/start","seq":4,"time":1785730446720,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730446720,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730446720,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"1b537017-6493-4f52-8504-01a7384e8cc6"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730446720,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730446720,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730446721,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352121664,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,27,0,0,29,0,0,27,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":28,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":29,"time0":1783352121777,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["CH","ILD","_OK"]}} -{"type":"assistant/chunk","seq":32,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} -{"type":"assistant/chunk","seq":33,"time":1785498793670,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":34,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":35,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":36,"time":1785730446727,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"16118fc6-2262-476e-9a4a-4b533cff09bc"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785730446727,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":38,"time":1785730446727,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357520283,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357520283,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"}]}} +{"type":"turn/start","seq":2,"time":1786357520283,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357520283,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357520300,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} +{"type":"step/start","seq":5,"time":1786357520303,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730446720,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357520303,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"24630f5a-f790-469f-96a6-cf234ded3759"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357520303,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730446720,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730446721,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352121664,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,27,0,0,29,0,0,27,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":29,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":30,"time0":1783352121777,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["CH","ILD","_OK"]}} +{"type":"assistant/chunk","seq":33,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} +{"type":"assistant/chunk","seq":34,"time":1785498793670,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":35,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":36,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730446727,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"16118fc6-2262-476e-9a4a-4b533cff09bc"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730446727,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730446727,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index edf8950dac..0ac1be7454 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821403947,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352119275,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498793625,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"a9485ebd-2b4a-434a-bc35-afd757ce141b"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730446685,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e40b1354-1856-48c3-a638-1be67af32920"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730446685,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"bfd99a70-ad54-4073-9c0d-8a63711fe34a"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730446685,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498793626,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730446686,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index 7b77a09f5e..080198e7bc 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{cwd}}","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498800317,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"}]}} -{"type":"turn/start","seq":1,"time":1785821416523,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821416523,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821416542,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"step/start","seq":4,"time":1785730457309,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730457309,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730457309,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e076edc0-a2bf-4fc6-aa58-d44bf1e8fd00"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730457309,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730457310,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730457310,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783600638189,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,24,0,0,0,0,29,0,0,0,0,0,34,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":29,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":30,"time0":1783600638276,"data":{"turn":1,"step":1,"index":1,"dt":[4,0,0],"texts":["WF","_CH","ILD","_OK"]}} -{"type":"assistant/chunk","seq":34,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} -{"type":"assistant/chunk","seq":35,"time":1785498800343,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":36,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":37,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":38,"time":1785730457316,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0ddaf3d1-53dc-45df-bc19-54ad72d6d7fb"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785730457316,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":40,"time":1785730457316,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357536718,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357536719,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"}]}} +{"type":"turn/start","seq":2,"time":1786357536719,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357536719,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357536736,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":5,"time":1786357536738,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730457309,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357536738,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"12bbd4dd-4040-4cc7-8acf-e526144f1ee5"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357536738,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730457310,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730457310,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783600638189,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,24,0,0,0,0,29,0,0,0,0,0,34,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":31,"time0":1783600638276,"data":{"turn":1,"step":1,"index":1,"dt":[4,0,0],"texts":["WF","_CH","ILD","_OK"]}} +{"type":"assistant/chunk","seq":35,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498800343,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":38,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730457316,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0ddaf3d1-53dc-45df-bc19-54ad72d6d7fb"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730457316,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730457316,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 6ee104dd0c..eff3a129a4 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821416248,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783600631839,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498800152,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"5188a9c7-d3ca-4679-b8df-1443e0a0a4df"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730457160,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"1c92c213-1d4f-45ad-be50-161f26a23e65"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730457160,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"8f2bd7f3-ba01-4448-b00a-0d6e9c868fc3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730457160,"data":{"title":"Use the workflow tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498800153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730457161,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 44cbf0e360..8a1c23140b 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,18 +1,19 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"1c70f3f7-2e85-4808-8376-03d4d3bee6e6"}]}} {"type":"turn/start","seq":1,"time":1785821454445,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454445,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454466,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730501506,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse 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.\n\n## Writing code for run_code\n\nPass `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:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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 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 `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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"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.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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 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 `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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cac680cf-1d70-4fb2-91a3-da1e3a317d2e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785730501507,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"1c70f3f7-2e85-4808-8376-03d4d3bee6e6"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786358103673,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"c9a305d4-add2-453e-8789-4e5c127725f7"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786358103673,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse 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.\n\n## Writing code for run_code\n\nPass `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:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** 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. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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 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 `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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"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.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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 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 `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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c5d4e091-9632-4535-af35-097bc74abdd3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1785730501507,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 6988595618..8882bda5af 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,18 +1,19 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"12fb8b24-9214-4e43-b3d3-47f2af3531f1"}]}} {"type":"turn/start","seq":1,"time":1785821454599,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454599,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454618,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730501645,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse 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.\n\n## Writing code for run_code\n\nPass `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:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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 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 `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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"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.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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 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 `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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2b31dae5-8939-44e1-bbcd-9f64aa637d76"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785730501646,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"12fb8b24-9214-4e43-b3d3-47f2af3531f1"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786358103827,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"14cf8f47-3a7a-4857-a548-02fe407683fb"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786358103827,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse 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.\n\n## Writing code for run_code\n\nPass `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:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** 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. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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 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 `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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"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.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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 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 `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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bb9c39a9-3239-4ee1-939a-bab0046e3028"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1785730501646,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 646110b6d9..8f0a211969 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"2b5f9025-324a-4c76-b883-4af3b5c3060a"}]}} {"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"2b5f9025-324a-4c76-b883-4af3b5c3060a"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse 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.\n\n## Writing code for run_code\n\nPass `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:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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 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 `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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"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.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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 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 `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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse 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.\n\n## Writing code for run_code\n\nPass `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:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** 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. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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 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 `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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"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.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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 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 `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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e65c0ebe-8e3d-44c0-833f-68efcbc0acb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b596133f-aafe-4485-9871-ade1dda23373"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"abb8ecee-cb03-4a66-9477-38a52458ab05"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"99819f4a-5e53-4a6f-92f6-5be96b765bce"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,11 +22,11 @@ {"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6697e967-e6bd-46b2-8574-18aeb914e7c6"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} {"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"831070a8-3dc0-4275-9f11-0476b47b8ef2"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -34,9 +34,9 @@ {"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba4958e9-231c-437f-a2fc-7a13f392d3ba"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cef24cf0-5f9e-4be2-93d8-93a0d89c6e82"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} {"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"b9ebb37d-e565-4882-95b0-5343da1d68d8"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"d7ea395e-840a-46c4-a143-b6e15cf74114"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} {"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -44,9 +44,9 @@ {"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4757f4b9-9bde-488b-a54a-1bdea55dd15f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"242be4fa-3293-45de-ab55-a017999f2333"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} {"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"35baa460-54ff-4fa1-ba9d-66b6661f84e9"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"ee764f11-8827-4984-acef-ec3c5880f5a0"}},"sourceEventSeqs":[46],"surfaceOp":"append"} {"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} {"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -54,9 +54,9 @@ {"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"739166e2-ed48-4df2-a9a5-207f34058030"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} +{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6c3dab82-c2ed-492a-ab0d-f235b340a6c1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} {"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"98b05c06-cb77-41a9-8310-324bc72fc7a0"}},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"74554b42-640e-4119-a413-ee5ac01e546e"}},"sourceEventSeqs":[56],"surfaceOp":"append"} {"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} {"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -64,6 +64,6 @@ {"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a4ca8f2-92c1-4dbc-beb8-923b8791c298"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} +{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1a3bef32-0610-4891-9071-6bdc2e8a8fd2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl index dbcd59cc5a..1711b58c84 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} {"type":"step/start","seq":5,"time":0,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl index 434027310a..fc0a24eb66 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl @@ -106,37 +106,38 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[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],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":36,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":7,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":9,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":35,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[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],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":37,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} {"method":"subagent.finished","params":{"provider":"spawn","agentId":"{{sessionId}}","parentSessionId":"{{sessionId}}","childSessionId":"{{sessionId}}","status":"ok","stopReason":"completed","lastAssistantMessage":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}]}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":99,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404"},"content":[{"type":"tool-result","toolCallId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","content":[{"type":"text","text":"child answer 42."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[98],"surfaceOp":"append"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 9c45784001..0e7855cd86 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -5,17 +5,18 @@ {"type":"subagent/descriptor","seq":3,"time":1785821461003,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}} {"type":"step/start","seq":4,"time":1785730507335,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":1785730507335,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"7ae1698c-db1d-4fca-8404-3a9dece9c1d0"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730507335,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498591175,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730507336,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":10,"time0":1785097411011,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,24,1,0,0,0,25,0,1,0,51,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} -{"type":"assistant/chunk","seq":24,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":25,"time0":1785097411114,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,0],"texts":["child"," answer"," ","42","."]}} -{"type":"assistant/chunk","seq":30,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}} -{"type":"assistant/chunk","seq":31,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} -{"type":"assistant/chunk","seq":32,"time":1785498591184,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":33,"time":1785730507343,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":34,"time":1785730507344,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3d9970cd-d000-4fd5-8712-a88c301ddb19"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785730507344,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":36,"time":1785730507344,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":6,"time":1786358111405,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"17bd0771-d228-4805-a797-7be9c0b59d20"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786358111405,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498591175,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730507336,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1785097411011,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,24,1,0,0,0,25,0,1,0,51,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} +{"type":"assistant/chunk","seq":25,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":26,"time0":1785097411114,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,0],"texts":["child"," answer"," ","42","."]}} +{"type":"assistant/chunk","seq":31,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}} +{"type":"assistant/chunk","seq":32,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} +{"type":"assistant/chunk","seq":33,"time":1785498591184,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":34,"time":1785730507343,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":35,"time":1785730507344,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3d9970cd-d000-4fd5-8712-a88c301ddb19"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":1785730507344,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":37,"time":1785730507344,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 7598b6dc3e..78106c0d6d 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 4189979806ccd4e7dcfee231ab3e9e2331550b0d -README.zh.md: c6a9005cbfdb9d40a54383f921671fa22a31dc32 +README.md: 209f1e9526ff4a01af6f4c96955068de4b2b06c0 +README.zh.md: 8623be4bc1ab39aa7718de204dd0507843b0ab14 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 4189979806..209f1e9526 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -20,7 +20,7 @@ The child gets the parent's working-directory/session lineage and inherits the p This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output. -The driver applies the seam's [delegated policy inheritance](../subagent/README.md#delegated-policy-inheritance) through the shared child-agent helpers: it captures the parent's explicit sandbox/approval overrides before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). +The driver applies the seam's [delegated policy](../subagent/README.md#delegated-policy) through the shared child-agent helpers: it captures the parent's explicit sandbox override and the `'never'` approval pin before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [delegation-policy decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). ## Cancellation and ownership diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index c6a9005cbf..8623be4bc1 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -20,7 +20,7 @@ 该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 -驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略继承](../subagent/README.md#delegated-policy-inheritance):它会在创建子 agent 前捕获父级的显式沙箱/审批覆盖项,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 +驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略](../subagent/README.md#delegated-policy):它会在创建子 agent 前捕获父级的显式沙箱覆盖项与 `'never'` 审批钉定,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 ## 取消与所有权 diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 804249ba77..17620f7774 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -1,4 +1,7 @@ -/** Policy inheritance through child session events appended before publication. */ +/** + * Delegation policy through child session events appended before publication: + * the parent's sandbox override plus the pinned `approval/policy: never`. + */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' @@ -13,7 +16,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService from '@deepseek-ai/dsh-user-approval' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -76,12 +79,14 @@ function toolResultTexts(agent: Agent): string[] { } describe('in-process policy inheritance', () => { - it('records parent overrides before publishing a spawn child', async () => { + it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => { const script: Script = [] const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'spawn-blocked.txt') setSandboxMode(parent.session, 'read-only') - setApprovalPolicy(parent.session, 'never') + // The parent keeps the interactive deployment default: the child pin must + // not depend on any parent approval override. + expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() const parentLogLength = parent.session.events.length script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), @@ -120,7 +125,11 @@ describe('in-process policy inheritance', () => { .join('\n') expect(contextText).toContain('Current DSH file policy: read-only') expect(contextText).toContain('Approval prompts are disabled') + // The delegation-scope statement is a runtime-context fact, so the + // deployment system prompt stays uniform across parents and children. + expect(contextText).toContain('You are a delegated subagent') expect(request.data.header.system).not.toContain('Approval prompts are disabled') + expect(request.data.header.system).not.toContain('You are a delegated subagent') expect(parent.session.events).toHaveLength(parentLogLength) } finally { await run.dispose() @@ -179,7 +188,7 @@ describe('in-process policy inheritance', () => { } }) - it('does not freeze deployment defaults into an unswitched child', async () => { + it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => { const script: Script = [] const { parent } = await setupWalled(script) const allowed = join(workspace, 'default-allowed.txt') @@ -193,12 +202,58 @@ describe('in-process policy inheritance', () => { await run.result const child = run.localAgent as Agent expect(await readFile(allowed, 'utf8')).toBe('fine') - expect(child.session.events.some( - event => event.type === 'sandbox/mode' || event.type === 'approval/policy', - )).toBe(false) + expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false) + expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([ + { seq: 0, data: { policy: 'never', source: 'delegation' } }, + ]) expect(child.session.firstLiveSeq).toBe(0) } finally { await run.dispose() } }) + + it('rejects a child escalation deterministically even when an answerer would allow it', async () => { + const script: Script = [] + const { ctx, parent } = await setupWalled(script) + // A root answerer that would GRANT: the pinned 'never' must resolve + // before any answerer is consulted, so this never runs for the child. + let consulted = false + ctx.on('approval/request', () => { + consulted = true + return Promise.resolve('allowed-once' as const) + }) + const blocked = join(workspace, 'escalation-blocked.txt') + setSandboxMode(parent.session, 'read-only') + script.push( + toolCallResponse('write', 'write', { + file_path: blocked, + content: 'escaped', + sandbox_permissions: 'workspace-write', + justification: 'test escalation from a delegated child', + }), + textResponse('child done'), + ) + + const run = await startInProcessRun(spawnRequest(parent), {}) + try { + await run.result + const child = run.localAgent as Agent + + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(consulted).toBe(false) + expect(toolResultTexts(child).join('\n')) + .toContain('the user rejected escalating this operation to "workspace-write"') + // The deterministic rejection still leaves the full audit pair on the child log. + const asked = child.session.events.find( + (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', + ) + const decided = child.session.events.find( + (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided', + ) + expect(asked?.data.toolName).toBe('write') + expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' }) + } finally { + await run.dispose() + } + }) }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 06fa641336..36e63283da 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -247,10 +247,11 @@ describe('in-process structured output', () => { const result = await run.result expect(result.stopReason).toBe('error') expect(result.structured).toBeUndefined() - // Exactly one model request and one user message: no nudge turn exists. + // Exactly one model request and one caller-supplied user message (the + // delegation runtime-context snapshot aside): no nudge turn exists. expect(adapter.requests.length).toBe(1) const child = ctx.agents.get(run.id)! - expect(child.session.events.filter(e => e.type === 'user/message').length).toBe(1) + expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) await run.dispose() }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 7c64fb7228..3c89396d41 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 6cea175de3d07290b293ad55ccbe60d7d918d37c -README.zh.md: c5fecd554357146d317b5f75824f40a1cb976f2c +README.md: 30ecd187b08cd3d098ce791535914f80e4be9aed +README.zh.md: 5e32a74469a67e02a0eb927c368080768e27d508 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6cea175de3..30ecd187b0 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -52,9 +52,9 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority. -## Delegated policy inheritance +## Delegated policy -Both in-process delegation paths seed the parent's explicit policy overrides into the child through the shared child-agent helpers: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf()` and `approval.overrideOf()` synchronously at the delegation boundary (both services are optional `ctx.get` consumers), and `appendDelegatedPolicyOverrides()` writes each captured value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state, a later child switch wins the snapshot, and the child's effective policy stays reconstructable from its log alone. Deployment defaults are never copied: an unswitched parent stamps nothing and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) policy-inheritance Agent Notes. +Both in-process delegation paths fix the child's permission scope at the delegation boundary through the shared child-agent helpers. `captureDelegatedPolicyOverrides(parent)` snapshots the parent session's explicit sandbox override (`sandboxPolicy.overrideOf()`) and pins the child's approval policy to `'never'` whenever the approval capability is composed — regardless of the parent's own policy — so a delegated child acts only within its inherited sandbox scope and every ask (for example a `sandbox_permissions` escalation) is rejected deterministically instead of waiting on a prompt no one is watching (both services are optional `ctx.get` consumers). `appendDelegatedPolicyOverrides()` writes each value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state and the child's effective policy stays reconstructable from its log alone. The sandbox deployment default is never copied: an unswitched parent stamps no `sandbox/mode` and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. Every in-process child also receives a scoped runtime-context statement (`subagent:delegation`) telling it the scope is fixed and that a task needing wider access ends with a reported limitation, not retries. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) delegation-policy Agent Notes. ## One-shot ownership and lifecycle @@ -96,11 +96,25 @@ Continuable Activations await a best-effort final session flush without treating ## Model Experience -Indirectly, through `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. The first owns delegation schemas, the second owns parent continuation and discovery, and the third contributes `report` only to continuable child scopes. +### Child delegation-scope statement + +#### What the model sees + +Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences; parent-side rendering stays with `dsh-tool-subagent` (delegation schemas), `dsh-tool-subagent-control` (continuation and discovery), and `dsh-tool-subagent-report` (the child-scoped `report`). + +##### The delegation-scope statement + +```markdown +You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it. +``` + +#### Token effect + +One fixed statement in each child's runtime-context snapshot; none in the parent's requests. #### KV Cache effect -No direct invalidation; the named consumers own any request-prefix changes. +Prefix-stable within a child: the statement never changes during the child's lifetime, so it is written once into the first runtime-context snapshot. Parent-side, no direct invalidation; the named tool consumers own any request-prefix changes. ## Known Limitations and Deferred Work diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index c5fecd5543..5e32a74469 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -52,9 +52,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。 -## 委派策略继承 +## 委派策略 -两条进程内委派路径都会通过共享的子 agent 辅助函数,把父级的显式策略覆盖项作为种子注入子 agent:`captureDelegatedPolicyOverrides(parent)` 在委派边界同步对 `sandboxPolicy.overrideOf()` 与 `approval.overrideOf()` 获取快照(这两个服务都是可选的 `ctx.get` 消费方),`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个捕获值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,子 agent 后续的切换压过该快照,而子 agent 的生效策略始终可以仅凭其日志重建。部署默认值绝不复制:未切换的父级不会记录任何值,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇策略继承 Agent Note。 +两条进程内委派路径都会通过共享的子 agent 辅助函数,在委派边界固定子 agent 的权限范围。`captureDelegatedPolicyOverrides(parent)` 对父会话的显式沙箱覆盖项(`sandboxPolicy.overrideOf()`)获取快照,并在审批能力已组合时把子 agent 的审批策略钉定为 `'never'`——无论父级自身的策略是什么——因此被委派的子 agent 只在其继承的沙箱范围内行动,每次请求(例如一次 `sandbox_permissions` 升级)都被确定性拒绝,而不是等待一个无人在看的提示(这两个服务都是可选的 `ctx.get` 消费方)。`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,而子 agent 的生效策略始终可以仅凭其日志重建。沙箱的部署默认值绝不复制:未切换的父级不会记录 `sandbox/mode`,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。每个进程内子 agent 还会收到一条作用域内的运行时上下文声明(`subagent:delegation`),告知其权限范围已固定,需要更宽访问的任务应以上报限制收尾,而不是重试。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇委派策略 Agent Note。 ## 一次性所有权与生命周期 @@ -96,11 +96,25 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 模型体验 -通过 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 间接产生影响。第一个工具负责委派 schema,第二个负责父级延续和发现,第三个只向可继续子级作用域贡献 `report`。 +### 子级委派范围声明 + +#### 模型看到的内容 + +每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后;父级侧的渲染仍归 `dsh-tool-subagent`(委派 schema)、`dsh-tool-subagent-control`(延续与发现)和 `dsh-tool-subagent-report`(子级作用域的 `report`)所有。 + +##### 委派范围声明 + +```markdown +You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it. +``` + +#### Token 影响 + +每个子 agent 的运行时上下文快照中一条固定声明;父级请求中没有任何新增。 #### KV Cache 影响 -不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。 +子级内部前缀稳定:该声明在子 agent 生命周期内绝不变化,因此只写入第一份运行时上下文快照一次。父级侧不会直接使缓存失效;具名工具消费方共同负责请求前缀的任何变化。 ## 已知限制与暂缓事项 diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index 878b831dd5..bc0cf949b9 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -1,9 +1,9 @@ /** * Shared in-process child composition: the delegation-depth budget, the * durable session metadata, the resolved child `AgentOptions`, the delegated - * policy snapshot, and the scoped setup a child agent needs. Both the one-shot + * policy seed, and the scoped setup a child agent needs. Both the one-shot * provider driver and the continuation manager compose children this way, so - * depth accounting, lineage stamping, and policy inheritance have one home. + * depth accounting, lineage stamping, and delegation policy have one home. * * @module @deepseek-ai/dsh-subagent/child-agent */ @@ -13,12 +13,10 @@ import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-a import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' -import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve // to the policy services when composed — delegation consumes both -// opportunistically (the documented `ctx.get` pattern), never as a hard dep. -// The user-approval side stays an explicit empty import so its augmentation -// does not ride the `ApprovalPolicy` import above. +// opportunistically (the documented `ctx.get` pattern), never as a hard dep — +// and merge the `sandbox/mode` / `approval/policy` session-event payloads. import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' import { delegationDepthOf } from './depth.ts' @@ -115,51 +113,80 @@ export interface ChildComposition { } /** - * Apply one child's scoped composition inside its creation window: a shadowing - * persona section and a tool restriction, both owned by the child's scope and - * therefore invisible to its parent and siblings. + * Model-facing statement every in-process child receives: the permission + * scope is fixed at delegation and approval prompts are unavailable, so the + * child reports a scope limitation instead of retrying denied operations. + * A runtime-context contribution (not a system-prompt section) because it is + * a per-session fact: the deployment's system prompt stays uniform across + * parents and children, and the statement joins the same durable snapshot + * that carries the sandbox-policy and approval-policy sentences. + */ +export const SUBAGENT_DELEGATION_CONTEXT + = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be ' + + 'widened from inside this session — operations that require approval are rejected automatically. ' + + 'When the task needs access beyond that scope, do not retry the denied operation; state the ' + + 'limitation in your reply so the delegating agent can handle it.' + +/** + * Apply one child's scoped composition inside its creation window: the fixed + * delegation-scope statement, a shadowing persona section, and a tool + * restriction, all owned by the child's scope and therefore invisible to its + * parent and siblings. Both creation and cold resume pass through here, so a + * resumed child keeps the same statement. * @param childCtx - the child agent's scoped creation context. * @param composition - the persona and tool filter to install. */ export function applyChildComposition(childCtx: Context, composition: ChildComposition): void { + // After sandbox:policy (110) and approval:policy (115): scope, then policy, + // then what a delegated child does about a denial. + childCtx.systemPrompt.context({ name: 'subagent:delegation', order: 120, text: SUBAGENT_DELEGATION_CONTEXT }) if (composition.persona !== undefined) { childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) } if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter) } -/** Parent-session policy overrides captured at the delegation boundary. */ +/** Policy seeded onto a child session's log at the delegation boundary. */ export interface DelegatedPolicyOverrides { /** The parent session's explicit sandbox-mode override, or `undefined` without one. */ readonly sandboxMode: SandboxMode | undefined - /** The parent session's explicit approval-policy override, or `undefined` without one. */ - readonly approvalPolicy: ApprovalPolicy | undefined + /** + * The child's pinned approval policy, or `undefined` when no approval + * capability is composed. Always `'never'` with one composed: a delegated + * child acts only within the sandbox scope fixed at delegation, so the + * composed `ApprovalService` rejects every child ask deterministically + * instead of waiting on a prompt no one is watching. + */ + readonly approvalPolicy: 'never' | undefined } /** - * Capture the parent session's explicit policy overrides for one delegation. - * Call synchronously before the child start's first await: a later parent - * switch belongs to the parent's future, not to this child. Deployment - * defaults and one-shot grants are never captured, so an unswitched parent - * leaves the child following the deployment default dynamically. + * Capture the policy to seed into one delegation. Call synchronously before + * the child start's first await: a later parent switch belongs to the + * parent's future, not to this child. The sandbox scope is the parent + * session's explicit override — deployment defaults and one-shot grants are + * never captured, so an unswitched parent leaves the child following the + * deployment default dynamically. The approval policy is never inherited: it + * is pinned to `'never'` whenever the approval capability is composed, + * regardless of the parent's own policy. * @param parent - the delegating parent agent. - * @returns the overrides to seed into the child, each `undefined` without one. + * @returns the sandbox override (or `undefined` without one) and the approval pin. */ export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides { return { sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session), - approvalPolicy: parent.ctx.get('approval')?.overrideOf(parent.session), + approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never', } } /** - * Append captured parent overrides onto the child's own log as + * Append the captured delegation policy onto the child's own log as * `source: 'delegation'` events inside the unpublished creation window, so the * child's effective policy is reconstructable from its log alone. Appends land * after any fork seed, so fresh policy wins stale seed state; later child * switches still win over these events. * @param childSession - the unpublished child's session. - * @param overrides - the overrides captured at delegation. + * @param overrides - the policy captured at delegation. */ export function appendDelegatedPolicyOverrides( childSession: Session, diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 743d6d63de..2f54f90218 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -214,8 +214,8 @@ interface MaterializeInputs { create?: { seed: readonly SessionEvent[] meta: NonNullable - /** Parent policy overrides captured at the delegation boundary. */ - inheritedPolicies: DelegatedPolicyOverrides + /** Policy captured at the delegation boundary: the parent's sandbox override plus the approval pin. */ + delegatedPolicies: DelegatedPolicyOverrides } agentOptions: AgentOptions composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } @@ -355,7 +355,7 @@ export class SubagentContinuationManager { }) // Capture before the first await: a later parent switch belongs to the // parent's future, not to this child. - const inheritedPolicies = captureDelegatedPolicyOverrides(parent) + const delegatedPolicies = captureDelegatedPolicyOverrides(parent) const prepared = await this.host.prepareContinuable(spec.provider, { sessionId: childId, @@ -372,7 +372,7 @@ export class SubagentContinuationManager { childId, provider: spec.provider, parent, - create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), inheritedPolicies }, + create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies }, agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, @@ -900,11 +900,11 @@ export class SubagentContinuationManager { // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): AgentSetupCommit => { - // Only fresh creation seeds captured parent policy onto the child's own + // Only fresh creation seeds the delegation policy onto the child's own // log (after any fork seed, so fresh policy wins stale seed state); a // cold resume replays those persisted events instead. if (create !== undefined) { - appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.inheritedPolicies) + appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.delegatedPolicies) } applyChildComposition(childCtx, inputs.composition) return this.setupRegistry.apply(childCtx) diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 92cefa261e..1e539c28c2 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -1,9 +1,9 @@ /** - * Continuable-child policy inheritance: a fresh continuable start seeds the - * parent's explicit sandbox/approval overrides onto the child's own log as - * `source: 'delegation'` events, and a cold resume replays that persisted - * snapshot instead of re-capturing the parent (the one-shot - * `subagent-inprocess/tests/inheritance.spec.ts` counterpart). + * Continuable-child delegation policy: a fresh continuable start seeds the + * parent's explicit sandbox override and the pinned `approval/policy: never` + * onto the child's own log as `source: 'delegation'` events, and a cold + * resume replays that persisted snapshot instead of re-capturing the parent + * (the one-shot `subagent-inprocess/tests/inheritance.spec.ts` counterpart). */ import { afterEach, describe, expect, it, vi } from 'vitest' @@ -21,7 +21,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' -import ApprovalService, { effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService from '../src/index.ts' @@ -71,10 +71,12 @@ function policyEvents(events: readonly SessionEvent[]) { } describe('continuable policy inheritance', () => { - it('seeds parent overrides into a fresh continuable child', async () => { + it('seeds the parent sandbox override and pins approval to never', async () => { const { ctx, parent } = await setup([textResponse('child done')]) setSandboxMode(parent.session, 'danger-full-access') - setApprovalPolicy(parent.session, 'never') + // The parent keeps the interactive deployment default: the child pin must + // not depend on any parent approval override. + expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() let child: Agent | undefined ctx.on('agent/created', ({ agent }) => { if (agent !== parent) child = agent @@ -93,9 +95,20 @@ describe('continuable policy inheritance', () => { { type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } }, { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) - // Durable: a reload folds the same effective policy. + // Durable: a reload folds the same effective policy; the parent keeps its own. expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access') expect(effectiveApprovalPolicy(loaded.events)).toBe('never') + expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() + // The child's runtime-context snapshot states the fixed delegation scope. + const runtimeContext = loaded.events.find( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt', + ) + const contextText = runtimeContext?.data.content + .flatMap(block => block.type === 'text' ? [block.text] : []) + .join('\n') + expect(contextText).toContain('You are a delegated subagent') }) it('captures policy at delegation before asynchronous child creation', async () => { @@ -114,17 +127,20 @@ describe('continuable policy inheritance', () => { expect(effectiveSandboxMode(loaded.events)).toBe('read-only') }) - it('does not freeze deployment defaults into an unswitched child', async () => { + it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => { const { ctx, parent } = await setup([textResponse('child done')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(policyEvents(loaded.events)).toEqual([]) + expect(policyEvents(loaded.events)).toMatchObject([ + { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBeUndefined() }) - it('does not freeze deployment defaults into an unswitched fork child either', async () => { + it('pins approval after the fork prefix of an unswitched fork child', async () => { const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent work' }], @@ -137,7 +153,10 @@ describe('continuable policy inheritance', () => { const loaded = await ctx.sessionPersistence.load(started.childId) expect(loaded.meta.seedLength).toBeGreaterThan(0) - expect(policyEvents(loaded.events)).toEqual([]) + expect(policyEvents(loaded.events)).toMatchObject([ + { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBeUndefined() }) it('lets a later child-side switch win over the delegation snapshot', async () => { @@ -180,6 +199,10 @@ describe('continuable policy inheritance', () => { { data: { mode: 'read-only', source: 'delegation' } }, ]) expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + // The approval pin is seeded once at creation, never re-appended on resume. + expect(loaded.events.filter(event => event.type === 'approval/policy')).toMatchObject([ + { data: { policy: 'never', source: 'delegation' } }, + ]) }) it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => { diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9370676f76..6e23f6ccae 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -103,9 +103,9 @@ function hasUserText(events: readonly SessionEvent[], text: string): boolean { && event.data.content.some(block => block.type === 'text' && block.text === text)) } -/** Every user-role message text in log order, for FIFO assertions. */ +/** Every caller-supplied user-role message text in log order, for FIFO assertions (framework runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { - return events.flatMap(event => event.type === 'user/message' + return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) } diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index c5ea8fd1b8..462b769e94 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -158,7 +158,7 @@ describe('dsh-tool-subagent-control', () => { await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - const prompts = loaded.events.flatMap(event => event.type === 'user/message' + const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) // A follow-up is its own later turn, never steering inside the first one. @@ -274,7 +274,7 @@ describe('dsh-tool-subagent-control interrupt_agent', () => { expect(waking.isError).toBe(false) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - const prompts = loaded.events.flatMap(event => event.type === 'user/message' + const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up']) diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 23757c2b54..c74fb94646 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -411,9 +411,9 @@ describe('dsh-tool-subagent-report', () => { }) }) -/** Prove report delivery uses ordinary logged user messages. */ +/** Prove report delivery uses ordinary logged user messages (framework runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { - return events.flatMap(event => event.type === 'user/message' + return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0a202e6142..2b962ca471 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -133,7 +133,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, - 'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' }, 'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' }, 'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' }, 'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, From 2f481fa352dd6c776781f46f1c4bdc0579dc2098 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:28:40 +0800 Subject: [PATCH 018/105] fix(apiproxy): echo the preset a created session runs, not its header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session.create` also adopts an already-live session, and the preceding commit newly allows adopting one under the preset it switched to while blank. Its response still echoed `header.agentPreset`, so that adoption answered with the preset the session had just left — contradicting the request it had accepted and the row `session.list` serves for the same session from `resolveSessionPreset()`. The echo now resolves the same way. The `assertPresetUnchanged` parameter doc said `existing` was the preset the session was created under; both callers now pass what it runs. `composeFrom()` was documented as "infallible" and "cannot fail" beside two `@throws`. It has no composition failure mode — no roster read, no mount, no file — but it does reject a caller error, and the wording now says which. The package-level "switched preset" test re-linked to the same preset id, so it could not tell reading the parent's live scope chain from reading its creation header. A second fixture preset makes the switch real. The Web browser lane's subagent goldens gain the preset badge a child now shows, which is the visible consequence of recording its composition. That lane runs only under DSH_EXAMPLE_MODE=lib and was missed before. The Agent Note records two limits found in review: a cold-resumed continuable child joins its parent's current composition rather than the one its header names, and `toolFilter` does not constrain a joined child. The latter is a regression from the agent-plane move rather than anything this change introduces — with the same tools in the global layer the filter applies normally — and is tracked in #2185. Refs #2185 --- ...d-agents-join-their-parent-preset.i18n.yaml | 4 ++-- ...10-child-agents-join-their-parent-preset.md | 10 ++++++++-- ...child-agents-join-their-parent-preset.zh.md | 10 ++++++++-- .../subagent-conversation/ui.expected.md | 2 ++ .../offline-composer.expected.md | 2 ++ docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 6 ++++-- docs/subsystems/core.zh.md | 6 ++++-- packages/host/apiproxy/src/api-proxy.ts | 18 ++++++++++++------ .../tests/api-proxy-agent-preset.spec.ts | 5 +++++ packages/preset/agent-presets/README.i18n.yaml | 4 ++-- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- packages/preset/agent-presets/src/index.ts | 6 ++++-- .../tool-cordis/src/api-catalog.ts | 2 +- .../presets/reviewing/agent.cordis.yml | 6 ++++++ .../tests/preset-inheritance.spec.ts | 8 ++++++-- 17 files changed, 70 insertions(+), 27 deletions(-) create mode 100644 packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml index 9afec2a879..34697cd123 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.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/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md -2026-08-10-child-agents-join-their-parent-preset.md: c9917c48d10c2b2515284405ea52aed8b476f8b1 -2026-08-10-child-agents-join-their-parent-preset.zh.md: 09e4de5292b65e50bb3973704fd803c819be9f1c +2026-08-10-child-agents-join-their-parent-preset.md: d9aa0dc43c1338d3198f5335da6ad238730d58a1 +2026-08-10-child-agents-join-their-parent-preset.zh.md: dd85c642ff7e6e2934e805c2efaccdc6dda63f15 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md index c9917c48d1..d9aa0dc43c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -38,10 +38,16 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge `packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank. +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. + +The assembled-transcript layer is the shipped Web composition's e2e rather than a keyless snapshot. Every runnable example this repo ships composes no preset roster, so the defect is not observable in the snapshot harness at all: a snapshot scenario would first need an example that mounts a roster AND delegates. The Web e2e boots the real `base` + `web-app` patch layers with both shipped presets, which is the assembled evidence the testing policy asks for; the Web browser lane's subagent goldens carry the visible consequence, since a child that records its preset now shows the preset badge its parent shows. ## Consequences -Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. +Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's — the per-child `toolFilter` does not narrow them, for the separately tracked reason below; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. `applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error. + +A cold-resumed continuable child joins its parent's CURRENT composition rather than the one its own header records. The window is narrow — the parent must create the child, stay blank, switch preset, and only then wake it, since a resident child never re-joins and a one-shot child never resumes — and the alternative is worse: resolving the child's own recorded id would re-read the roster and hand back the preset-deleted failure mode this join exists to avoid. The child's header still records what it started under, so the divergence is observable rather than silent. + +`toolFilter` does not constrain a joined child, because `ToolRegistry` compiles restrictions against global-layer names only and overlays chain-layer tools unfiltered. That is not new here — with the roster composed, `tools.restrict()` already rejected every name as an unknown global tool, so a child carrying a filter failed to start both before and after this change — but it is a regression from the agent-plane move rather than a standing limitation: with the same tools registered in the global layer, the filter admits and applies normally. It matters more now that the child has its parent's full tool set to be restricted from. It is tracked separately; this change neither introduces nor repairs it. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md index 09e4de5292..dd85c642ff 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -38,10 +38,16 @@ Status: implemented `packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方。 +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 + +组装记录这一层用的是真实 shipped Web 组装的 e2e,而不是无密钥快照。本仓库所有可运行 example 都不组装 preset roster,因此该缺陷在快照 harness 里根本不可观察:要做快照场景,得先有一个既挂载 roster 又发起委派的 example。Web e2e 启动的是真实的 `base` + `web-app` 补丁层与两个 shipped preset,这正是测试政策要求的组装证据;Web 浏览器 lane 的 subagent golden 承载了可见后果——记录了 preset 的子 agent 现在会显示与其父方相同的 preset 徽标。 ## Consequences -委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己的 `toolFilter` 所移除的部分;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 +委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力——逐子 agent 的 `toolFilter` 并不能收窄它,原因见下方另行跟踪的那条;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 `applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。 + +冷恢复的可继续子 agent 加入的是父方**当前**的组装,而不是它自己 header 所记录的那份。窗口很窄——父方必须先建子、保持空白、切换 preset,之后才唤醒它;驻留中的子 agent 不会重新加入,一次性子 agent 也不会恢复——而替代方案更糟:按子 agent 自己记录的 id 解析会重读 roster,把这次认父刻意规避掉的"preset 已删除"失败模式又请回来。子 agent 的 header 仍记录它启动时的那份,因此这处分歧是可观察的而非静默的。 + +`toolFilter` 约束不住已加入组装的子 agent,因为 `ToolRegistry` 只按全局层的名字编译限制,随后把 scope 链上的工具无过滤地叠加进来。这不是本次改动带来的——在组装了 roster 的部署里,`tools.restrict()` 本就把每个名字都判为未知全局工具,因此带过滤器的子 agent 在本次改动前后同样起不来——但它是搬到 agent 平面所引入的回归,而非长期存在的限制:同样这批工具注册在全局层时,过滤器能正常校验并生效。现在子 agent 有了父方的全套工具需要被限制,它变得更要紧。该问题另行跟踪;本次改动既未引入也未修复它。 diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 27c7ec092e..4b71dfdc9c 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -6,6 +6,8 @@ - button "1 subagent": - text: 1 subagent - img + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index a977afbbea..fbec36baea 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -3,6 +3,8 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 947df5c3ae..a7d26cee1b 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: 59c66fdacb369dac1968c2e4fbd2ad70f907d3e9 -core.zh.md: 3b6fc13d0fb54b4e18d7c1bf9849509b1947208b +core.md: ad00c4da7d77b0e1ab4728173b202ebc17fb56a0 +core.zh.md: 9c606023c85369643e7148f829526b1f75ea3631 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 59c66fdacb..ad00c4da7d 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -431,9 +431,11 @@ async mount(agentCtx: Context, id?: string): Promise * parent's history was produced under (and a preset deleted since would fail * the child outright while its parent keeps running). * - * Synchronous and infallible for that reason, which is what lets a child + * Synchronous, and with no composition failure mode of its own — it reads no + * roster, mounts nothing, and touches no file — which is what lets a child * creation window use it: the two in-process subagent drivers compose their - * children inside a synchronous `setup`. + * children inside a synchronous `setup`. It still rejects a caller error, as + * the `@throws` below record. * * A parent that joined no preset — a rosterless deployment — yields no join * and no error: there, the model-facing rows sit in the host composition and diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 3b6fc13d0f..9c606023c8 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -439,9 +439,11 @@ async mount(agentCtx: Context, id?: string): Promise * parent's history was produced under (and a preset deleted since would fail * the child outright while its parent keeps running). * - * Synchronous and infallible for that reason, which is what lets a child + * Synchronous, and with no composition failure mode of its own — it reads no + * roster, mounts nothing, and touches no file — which is what lets a child * creation window use it: the two in-process subagent drivers compose their - * children inside a synchronous `setup`. + * children inside a synchronous `setup`. It still rejects a caller error, as + * the `@throws` below record. * * A parent that joined no preset — a rosterless deployment — yields no join * and no error: there, the model-facing rows sit in the host composition and diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a1f98d4f28..51a2ca8b34 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1038,7 +1038,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * common paths — reconnecting, resuming, retrying a create — are unaffected. * @param sessionId - the identity being adopted. * @param requested - the preset the request named, if any. - * @param existing - the preset the session was created under, if any. + * @param existing - the preset the session RUNS, if any; both callers resolve + * it from the log, which differs from the creation header once a blank + * session has switched. * @throws when both are present and differ. */ function assertPresetUnchanged( @@ -1989,12 +1991,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } - // Echo the RESOLVED composition so a client can label the session it - // just created without waiting for the next list refresh — the create - // is the commit point that knows it (a caller that named none gets - // the default the header recorded). + // Echo the composition the session RUNS so a client can label it + // without waiting for the next list refresh — the create is the commit + // point that knows it (a caller that named none gets the default). + // Resolved from the log for the same reason `sessionListFields()` is: + // this handler also adopts an already-live session, and one that + // switched while blank runs a preset its header no longer names, so + // echoing the header would contradict both the adoption this call just + // allowed and the row `session.list` serves for the same session. const created = ctx.agents.get(sessionId) - const createdPreset = created?.session.header.agentPreset + const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session) return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } }) }, diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 996af59986..106cb213da 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -199,6 +199,11 @@ describe('session.create with an agent preset', () => { // Comparing against the header would invert both answers: the preset the // session actually runs would be refused, and the one it left would pass. expect(adopted.result.ok).toBe(true) + // The echo has to name the same preset the adoption just accepted, or the + // client labels the session with one it has already left — and disagrees + // with the row `session.list` serves for it. + if (!adopted.result.ok) throw new Error('unreachable') + expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' }) expect(stale.result.ok).toBe(false) if (stale.result.ok) throw new Error('unreachable') expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' }) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 9c7f2c54ad..8751d2a8f6 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: 5ccf1d7b224d0e3a67b3aeb9dc6679d6e802063f -README.zh.md: ed79cf48b96ed927feec8860b6211cedc369cdda +README.md: 250d2a6560e680aee5d3088834d5db220854d1d4 +README.zh.md: bd02327a3cca01bc794d63c8933b61bfa5c1008b diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 5ccf1d7b22..250d2a6560 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -14,7 +14,7 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.list(): Promise` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason. - `ctx.agentPresets.resolve(id?): Promise` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row. - `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. -- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and cannot fail. +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and has no composition failure mode; it still rejects a caller error (an unscoped context, or an agent that already joined). - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index ed79cf48b9..bd02327a3c 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -14,7 +14,7 @@ - `ctx.agentPresets.list(): Promise` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。 - `ctx.agentPresets.resolve(id?): Promise` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。 - `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 -- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步且不会失败。 +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步、且自身没有组装失败模式;调用方用错(上下文无 scope、agent 已加入过)仍会拒绝。 - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 0fb428c425..1dde902234 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -250,9 +250,11 @@ export class AgentPresets extends Service { * parent's history was produced under (and a preset deleted since would fail * the child outright while its parent keeps running). * - * Synchronous and infallible for that reason, which is what lets a child + * Synchronous, and with no composition failure mode of its own — it reads no + * roster, mounts nothing, and touches no file — which is what lets a child * creation window use it: the two in-process subagent drivers compose their - * children inside a synchronous `setup`. + * children inside a synchronous `setup`. It still rejects a caller error, as + * the `@throws` below record. * * A parent that joined no preset — a rosterless deployment — yields no join * and no error: there, the model-facing rows sit in the host composition and diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 4cc39f662b..69fac20609 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -112,7 +112,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'composeFrom(agentCtx: Context, parentCtx: Context): string | undefined', - jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous and infallible for that reason, which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */', + jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous, and with no composition failure mode of its own — it reads no\n * roster, mounts nothing, and touches no file — which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`. It still rejects a caller error, as\n * the `@throws` below record.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */', }, { signature: 'composedPreset(agentCtx: Context): string | undefined', diff --git a/packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml b/packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml new file mode 100644 index 0000000000..9971526c12 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml @@ -0,0 +1,6 @@ +# A second agent-plane composition, so a switch is a real switch: the tool a +# joined child sees has to change with it. +- id: only + name: ../../plugins/preset-tool.js + config: + tool: reviewing_only diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 01c190a833..43061d46db 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -105,12 +105,16 @@ describe('a child agent composed in-process', () => { it('follows a parent that switched preset while blank', async () => { const { ctx, parent } = await setupPresetHost() - await ctx.agentPresets.recompose(parent.ctx, 'coding') + // A DIFFERENT preset, so the assertion below distinguishes reading the + // parent's live scope chain from reading its creation header — re-linking + // to the same id would pass either way. + await ctx.agentPresets.recompose(parent.ctx, 'reviewing') const run = await startInProcessRun(spawnRequest(parent), {}) await run.result - expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only']) + expect(run.localAgent?.session.header.agentPreset).toBe('reviewing') await run.dispose() }) }) From 20139a3fb702623583beae3cf446cdefd7cbaee2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:30:46 +0800 Subject: [PATCH 019/105] docs(sdk): add minimal Python example --- ...nimal-preset-owns-rl-composition.i18n.yaml | 4 +- ...8-10-minimal-preset-owns-rl-composition.md | 6 +- ...0-minimal-preset-owns-rl-composition.zh.md | 6 +- docs/user/guide/python-sdk-minimal.i18n.yaml | 6 ++ docs/user/guide/python-sdk-minimal.md | 95 +++++++++++++++++++ docs/user/guide/python-sdk-minimal.zh.md | 95 +++++++++++++++++++ docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 1 + docs/user/guide/quickstart.zh.md | 1 + examples/jsonrpc-agent/README.i18n.yaml | 4 +- examples/jsonrpc-agent/README.md | 6 +- examples/jsonrpc-agent/README.zh.md | 6 +- examples/jsonrpc-agent/minimal.cordis.yml | 91 ++++++++++++++++++ examples/jsonrpc-agent/minimal.py | 42 ++++++++ ...cordis.yml => minimal.snapshot.cordis.yml} | 10 +- .../jsonrpc-agent/persistent-tools.cordis.yml | 59 ------------ examples/jsonrpc-agent/tests/sdk.snapshot.ts | 55 +++++++++-- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 5 +- python/sdk/README.zh.md | 4 +- website/docs.ts | 10 +- 21 files changed, 421 insertions(+), 93 deletions(-) create mode 100644 docs/user/guide/python-sdk-minimal.i18n.yaml create mode 100644 docs/user/guide/python-sdk-minimal.md create mode 100644 docs/user/guide/python-sdk-minimal.zh.md create mode 100644 examples/jsonrpc-agent/minimal.cordis.yml create mode 100644 examples/jsonrpc-agent/minimal.py rename examples/jsonrpc-agent/{persistent-tools.snapshot.cordis.yml => minimal.snapshot.cordis.yml} (60%) delete mode 100644 examples/jsonrpc-agent/persistent-tools.cordis.yml diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml index 6861aff43a..68f399bab8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.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/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md -2026-08-10-minimal-preset-owns-rl-composition.md: 043f2e45e3fe4fbb92aa6652ce099ebfde09de55 -2026-08-10-minimal-preset-owns-rl-composition.zh.md: 83f243b56b25237f19fa288f87e15eee6a264c94 +2026-08-10-minimal-preset-owns-rl-composition.md: 002cad0827e969b322997821dc978db85e2955f3 +2026-08-10-minimal-preset-owns-rl-composition.zh.md: e957b57395c68b336695bdae07ea15a54ca1ea4e diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md index 043f2e45e3..002cad0827 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -12,7 +12,7 @@ The split also hid other drift. The preset mounted one-shot Bash rather than the ## Decision -The shipped `minimal` preset is the sole RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract. +The shipped Web `minimal` preset is the sole Web owner of the RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract. The preset persona is exactly `You are a helpful software engineer assistant.` and sets `complete: true`. A complete `PromptSection` participates in ordinary assembly so tools, contexts, variables, and cooperative listeners still resolve; after the `system-prompt/assemble` waterfall, the prompt registry restores a detached copy of that section as the sole system-prompt section. Multiple effective complete sections reject assembly. This final registry constraint prevents harness identity, Web orientation, tool guidance, or an assembly listener from appending prompt text. @@ -22,6 +22,8 @@ The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace at System-prompt and persona package tests prove final complete-section enforcement, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web surface text, and a test section are registered, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path. +The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) mirrors the same prompt, tools, timeouts, and compaction policy for the bundled JSON-RPC runtime. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point. + ## Alternatives considered **Keep `core-web.cordis.yml` as a compatibility patch.** Rejected because a process patch and a session preset are two independent owners for one agent contract; precedence makes either one capable of silently undoing the other. @@ -34,4 +36,4 @@ System-prompt and persona package tests prove final complete-section enforcement ## Consequences -The RL prompt is fixed rather than environment-overridable, and `minimal` is the only shipped place that states it. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface. +The RL prompt is fixed rather than environment-overridable. The Web preset and standalone JSON-RPC example state the same contract for their respective launch surfaces. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md index 83f243b56b..e957b57395 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -12,7 +12,7 @@ Web surface 同时由两个位置定义与 Claude SWE 兼容的 RL agent(智 ## 决策 -随附的 `minimal` preset 是 RL agent 组合的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`、`str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。 +随附的 Web `minimal` preset 是 RL agent 组合在 Web 中的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`、`str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。 preset persona 恰好是 `You are a helpful software engineer assistant.`,并设置 `complete: true`。complete `PromptSection` 参与常规组装,因此工具、上下文、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall(瀑布式事件)结束后,提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落。存在多个有效 complete 段时,组装会被拒绝。这项最终注册表约束可防止 harness 身份、Web 定位、工具引导或组装监听器追加提示词文本。 @@ -22,6 +22,8 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,并 系统提示词与 persona 包测试证明了 complete 段的最终约束,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web surface 文本和一个测试段落;随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。 +独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 为内置 JSON-RPC 运行时复现相同的提示词、工具、超时和压缩策略。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。 + ## 考虑过的替代方案 **将 `core-web.cordis.yml` 保留为兼容 patch。** 被拒绝,因为进程 patch 与会话 preset 是同一 agent 约定的两个独立所有者;优先级会使任意一方都能静默撤销另一方的配置。 @@ -34,4 +36,4 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,并 ## 后果 -RL 提示词固定不变,不能通过环境覆盖,且 `minimal` 是交付内容中唯一声明该提示词的位置。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface。 +RL 提示词固定不变,不能通过环境覆盖。Web preset 与独立 JSON-RPC 示例分别在各自的启动界面声明相同的约定。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface。 diff --git a/docs/user/guide/python-sdk-minimal.i18n.yaml b/docs/user/guide/python-sdk-minimal.i18n.yaml new file mode 100644 index 0000000000..3a3b7dd8a7 --- /dev/null +++ b/docs/user/guide/python-sdk-minimal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/user/guide/python-sdk-minimal.md +python-sdk-minimal.md: 9d46278aeec625afdf30678806bc90104be00b66 +python-sdk-minimal.zh.md: ec06a205c680c1d7a83be5f949c7ff4d719defe4 diff --git a/docs/user/guide/python-sdk-minimal.md b/docs/user/guide/python-sdk-minimal.md new file mode 100644 index 0000000000..9d46278aee --- /dev/null +++ b/docs/user/guide/python-sdk-minimal.md @@ -0,0 +1,95 @@ +# Run the minimal agent with the Python SDK + +English | [中文](python-sdk-minimal.zh.md) + +This tutorial runs the minimal agent without the Web UI. The checked-in Cordis composition fixes the system prompt, tool catalog, persistent-shell behavior, and compaction policy so SDK runs use the same model-facing contract as the Web `minimal` preset. + +## Prerequisites + +- Python 3.10 or newer +- Linux x64, Linux arm64, or macOS arm64 +- A DeepSeek-compatible API endpoint and credential +- An isolated workspace that the agent may modify + +Create a virtual environment and install the SDK with its same-version bundled runtime: + +```sh +python -m venv .venv +. .venv/bin/activate +python -m pip install deepseek-harness +``` + +The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so an installed SDK does not need Node.js. + +## Run the checked-in example + +Set the credential in the environment. Set `DEEPSEEK_BASE_URL` as well when the model is served by an OpenAI-compatible proxy rather than the default DeepSeek endpoint. + +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 +``` + +Run one task from the repository checkout: + +```sh +python examples/jsonrpc-agent/minimal.py \ + --workspace /absolute/path/to/workspace \ + --session-root /absolute/path/to/trajectories \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` + +The script prints the final assistant response. The session root receives the JSONL trajectory, including the assembled model request and every tool call. + +## Use the SDK in your own program + +The example is a thin wrapper around this SDK call: + +```python +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + +config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() +workspace = Path("/absolute/path/to/workspace").resolve() +sessions = Path("/absolute/path/to/trajectories").resolve() + +with DeepSeekHarness( + provider="deepseek-official", + model="deepseek-v4-flash", + max_tokens=49_152, + cwd=str(workspace), + session_root=str(sessions), + cordis=str(config), +) as harness: + result = harness.run( + "Inspect the repository and fix the failing tests.", + session_id="example-001", + ) + +print(result.final_response) +``` + +`DeepSeekHarness` starts the bundled JSON-RPC runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id across calls also preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. + +## Contract reproduced by the configuration + +| Surface | Fixed value | +|---|---| +| System prompt | `You are a helpful software engineer assistant.` | +| Model-facing tools | Persistent `bash` and `str_replace_editor` only | +| Bash timeout | 300 seconds | +| Editor output limit | 16,000 characters | +| Compaction | Trigger ratio `0.8`, retain `20,480` tokens, summary cap `8,192` tokens, one retry | +| Session persistence | Uncompressed JSONL under `DSH_SESSION_ROOT` | + +The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, and every other model-facing plugin. Filesystem policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent. + +## Keep runs reproducible + +For comparable trajectories, pin the Harness commit and Python package version together, retain the exact Cordis file, and record the provider, model, endpoint, `max_tokens`, task input, workspace state, and session id for every run. Start independent runs with a clean workspace and a fresh session id; reuse a session only when multi-turn state is intentional. + +The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate and is not a Windows agent surface. + +For the complete SDK lifecycle and result contract, see the [Python SDK reference](../../../python/sdk/README.md). For Cordis composition syntax, see [Configuration](./config.md). diff --git a/docs/user/guide/python-sdk-minimal.zh.md b/docs/user/guide/python-sdk-minimal.zh.md new file mode 100644 index 0000000000..ec06a205c6 --- /dev/null +++ b/docs/user/guide/python-sdk-minimal.zh.md @@ -0,0 +1,95 @@ +# 使用 Python SDK 运行极简 agent(智能体) + +[English](python-sdk-minimal.md) | 中文 + +本教程介绍如何在不使用 Web UI 的情况下运行极简 agent。仓库内置的 Cordis 组合固定了系统提示词、工具目录、持久 shell 行为和压缩(compaction)策略,因此 SDK 运行与 Web `minimal` preset 使用相同的面向模型约定。 + +## 前置要求 + +- Python 3.10 或更高版本 +- Linux x64、Linux arm64 或 macOS arm64 +- DeepSeek 兼容的 API 端点与凭据 +- agent 可以修改的隔离 workspace + +请创建虚拟环境,并安装 SDK 及其同版本内置运行时: + +```sh +python -m venv .venv +. .venv/bin/activate +python -m pip install deepseek-harness +``` + +运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此安装后的 SDK 不需要 Node.js。 + +## 运行仓库内置示例 + +请在环境中设置凭据。如果模型不是由默认 DeepSeek 端点提供,而是通过 OpenAI 兼容代理提供,还需要设置 `DEEPSEEK_BASE_URL`。 + +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 +``` + +从仓库 checkout 运行一个任务: + +```sh +python examples/jsonrpc-agent/minimal.py \ + --workspace /absolute/path/to/workspace \ + --session-root /absolute/path/to/trajectories \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` + +脚本会打印 assistant 的最终回复。会话根目录会收到 JSONL 运行轨迹,其中包含组装后的模型请求与每次工具调用。 + +## 在自己的程序中使用 SDK + +该示例是以下 SDK 调用的轻量包装层: + +```python +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + +config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() +workspace = Path("/absolute/path/to/workspace").resolve() +sessions = Path("/absolute/path/to/trajectories").resolve() + +with DeepSeekHarness( + provider="deepseek-official", + model="deepseek-v4-flash", + max_tokens=49_152, + cwd=str(workspace), + session_root=str(sessions), + cordis=str(config), +) as harness: + result = harness.run( + "Inspect the repository and fix the failing tests.", + session_id="example-001", + ) + +print(result.final_response) +``` + +`DeepSeekHarness` 会延迟启动内置 JSON-RPC 运行时,并持续复用,直至退出上下文管理器。在多次调用中复用同一个 harness 和 session id,还会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。 + +## 配置复现的约定 + +| 方面 | 固定值 | +|---|---| +| 系统提示词 | `You are a helpful software engineer assistant.` | +| 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | +| Bash 超时 | 300 秒 | +| 编辑器输出上限 | 16,000 个字符 | +| 压缩 | 触发比例 `0.8`、保留 `20,480` 个 token、摘要上限 `8,192` 个 token、重试 1 次 | +| 会话持久化 | `DSH_SESSION_ROOT` 下未压缩的 JSONL | + +该配置省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具和其他所有面向模型的插件。文件系统策略事实记录为运行时用户上下文,而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。 + +## 保持运行可复现 + +为了让运行轨迹可复现且便于比较,请配套固定 Harness commit 与 Python 包版本,保留确切的 Cordis 文件,并为每次运行记录提供方、模型、端点、`max_tokens`、任务输入、workspace 状态和 session id。独立运行应使用干净的 workspace 和新的 session id;只有有意保留多轮状态时才复用会话。 + +该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行:Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该模式不适用于 Windows agent。 + +完整的 SDK 生命周期与结果约定见 [Python SDK 参考](../../../python/sdk/README.md)。Cordis 组合语法见[配置](./config.md)。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index dc9b7cb25b..a52f07e5c7 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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 docs/user/guide/quickstart.md -quickstart.md: ce196641f205324334533c025b4ac1dc791f857d -quickstart.zh.md: 3a5d6d0748ec0c7ec83c74570d0fad1e8d66a97c +quickstart.md: 13d5b2196282394619747e36ecddfa1d87f61c8d +quickstart.zh.md: 3d775db9280b43bbee2de37f7327fe8d2bd3a121 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index ce196641f2..13d5b21962 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,6 +57,7 @@ Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, ## Next steps +- [Run the minimal agent with Python](./python-sdk-minimal.md) — use the fixed two-tool composition without the Web UI - [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 3a5d6d0748..3d775db928 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,6 +57,7 @@ pnpm run dsh web ## 下一步 +- [使用 Python 运行极简 agent](./python-sdk-minimal.md) — 无需 Web UI,即可使用固定的双工具组合 - [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的工具或后端 diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index e1c36e959c..8da5cf7ae6 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-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/jsonrpc-agent/README.md -README.md: bcc1027d2edb30ab374dfa2ed13ad8e6360d923b -README.zh.md: ce255e4dd70bf8c5c6edc51afbe03bb4c66560a0 +README.md: 863b39eb9c7c65d36ceca77e945379fdd1d5fe22 +README.zh.md: a8334320896d2b16e166ef0f0ec300ab1cb0ca93 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index bcc1027d2e..863b39eb9c 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -26,11 +26,11 @@ The surrounding runtime also loads JSONL session persistence and automatic conte Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. -## Persistent tools variant +## Minimal variant -[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) is a minimal runnable variant whose model-facing surface is exactly: +[`minimal.cordis.yml`](minimal.cordis.yml) is the complete standalone counterpart of the Web `minimal` preset. It fixes the system prompt and compaction policy, and its model-facing surface is exactly: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the local PTY, filesystem intent policy, and session sandbox policy. +It composes the local PTY, filesystem intent policy, session sandbox policy, and JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK; the [minimal Python SDK tutorial](../../docs/user/guide/python-sdk-minimal.md) covers setup, repeatable runs, and the security boundary. diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index ce255e4dd7..a833432089 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -26,11 +26,11 @@ 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 -## 持久化工具变体 +## 极简变体 -[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) 是一个最小可运行变体,面向模型的能力严格只有: +[`minimal.cordis.yml`](minimal.cordis.yml) 是 Web `minimal` preset 的完整独立版本。它固定系统提示词与压缩策略,面向模型的能力严格只有: - 所有者作用域内持久化的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合了本地 PTY、文件系统意图策略与会话沙箱策略。 +它组合了内置运行时所需的本地 PTY、文件系统意图策略、会话沙箱策略与 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置;[极简 Python SDK 教程](../../docs/user/guide/python-sdk-minimal.md)介绍设置方式、可重复运行与安全边界。 diff --git a/examples/jsonrpc-agent/minimal.cordis.yml b/examples/jsonrpc-agent/minimal.cordis.yml new file mode 100644 index 0000000000..a374d1655a --- /dev/null +++ b/examples/jsonrpc-agent/minimal.cordis.yml @@ -0,0 +1,91 @@ +# Complete unattended minimal-agent composition for the Python SDK. The model +# sees one fixed system prompt and only the owner-scoped persistent Bash and +# string-replace editor tools. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + config: + maxTokensAsSuccess: false + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: pty + name: '@deepseek-ai/dsh-pty' + +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + +# The sandbox-aware filesystem backend applies the same per-session policy as +# Bash. danger-full-access permits unrestricted workspace behavior while +# keeping one policy boundary for both tools. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: You are a helpful software engineer assistant. + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false + +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + compression: none + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationProvider: '' + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/examples/jsonrpc-agent/minimal.py b/examples/jsonrpc-agent/minimal.py new file mode 100644 index 0000000000..c82f97c60a --- /dev/null +++ b/examples/jsonrpc-agent/minimal.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Run one minimal-agent turn through the bundled Python SDK runtime.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + + +CONFIG = Path(__file__).with_name("minimal.cordis.yml") + + +def main() -> None: + """Parse one task and print the agent's final response.""" + parser = argparse.ArgumentParser() + parser.add_argument("prompt", help="Task for the minimal agent") + parser.add_argument("--workspace", type=Path, default=Path.cwd()) + parser.add_argument("--session-root", type=Path, default=Path(".dsh-sessions")) + parser.add_argument("--session-id") + parser.add_argument("--provider", default="deepseek-official") + parser.add_argument("--model", default="deepseek-v4-flash") + parser.add_argument("--max-tokens", type=int) + args = parser.parse_args() + + workspace = args.workspace.resolve() + session_root = args.session_root.resolve() + with DeepSeekHarness( + provider=args.provider, + model=args.model, + max_tokens=args.max_tokens, + cwd=str(workspace), + session_root=str(session_root), + cordis=str(CONFIG.resolve()), + ) as harness: + result = harness.run(args.prompt, session_id=args.session_id) + print(result.final_response) + + +if __name__ == "__main__": + main() diff --git a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml b/examples/jsonrpc-agent/minimal.snapshot.cordis.yml similarity index 60% rename from examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml rename to examples/jsonrpc-agent/minimal.snapshot.cordis.yml index 498d5467f2..f21d7e3654 100644 --- a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml +++ b/examples/jsonrpc-agent/minimal.snapshot.cordis.yml @@ -1,12 +1,10 @@ -# Keyless replay keeps the persistent-tool composition intact and replaces -# only its live DeepSeek adapter with the fixture-backed provider. The catalog -# below claims the same `deepseek-official` route the agent asks for: an -# unowned route makes the SDK server mount the real adapter, which then demands -# a key this keyless lane has no way to supply. +# Keyless replay keeps the complete minimal composition intact and replaces +# only its live DeepSeek adapter with the fixture-backed provider. The replay +# catalog claims the same route initialized by the SDK. - id: base name: '@cordisjs/plugin-include' config: - path: ./persistent-tools.cordis.yml + path: ./minimal.cordis.yml patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml deleted file mode 100644 index ebe0a00e61..0000000000 --- a/examples/jsonrpc-agent/persistent-tools.cordis.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Minimal unattended composition for the persistent Bash and string-replace -# editor. It is runnable through the JSON-RPC example runtime and intentionally -# keeps the model-facing surface to exactly these two tools. - -- id: jsonrpc - name: '@deepseek-ai/dsh-jsonrpc' - -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() - -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: pty - name: '@deepseek-ai/dsh-pty' - -- id: pty-local - name: '@deepseek-ai/dsh-pty-local' - -- id: fs-sandbox - name: '@deepseek-ai/dsh-fs-sandbox' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - persona: 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolTasks: false - -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' - compression: none diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 4a29d6eb6a..a45e12b192 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -33,11 +33,21 @@ const testsDir = dirOf(import.meta.url) const snapshotsDir = join(testsDir, 'snapshots') const liveConfig = join(testsDir, '..', 'cordis.yml') const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml') -const persistentToolsLiveConfig = join(testsDir, '..', 'persistent-tools.cordis.yml') -const persistentToolsReplayConfig = join(testsDir, '..', 'persistent-tools.snapshot.cordis.yml') +const minimalLiveConfig = join(testsDir, '..', 'minimal.cordis.yml') +const minimalReplayConfig = join(testsDir, '..', 'minimal.snapshot.cordis.yml') const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const MINIMAL_SYSTEM_PROMPT = 'You are a helpful software engineer assistant.' +const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.` + const mode = process.env.DSH_SNAPSHOT ?? 'replay' const recording = mode === 'record' const refreshing = mode === 'refresh' @@ -61,6 +71,10 @@ interface SdkScenario { expectedFiles?: Readonly> /** Assembled model-facing tool names and required argument keys. */ expectedTools?: Readonly> + /** Exact assembled system prompt for the root request. */ + expectedSystem?: string + /** Exact model-facing descriptions for selected tools. */ + expectedToolDescriptions?: Readonly> /** Stable policy-context clauses the real assembled request must include or omit. */ policyContext?: { includes: readonly string[]; excludes: readonly string[] } } @@ -89,9 +103,11 @@ const SCENARIOS: SdkScenario[] = [ prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.', sessionId: 'persistent-tools-snapshot', children: 0, - configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, + configs: { live: minimalLiveConfig, replay: minimalReplayConfig }, expectedFiles: { 'note.txt': 'target:\n\tnew\n' }, expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] }, + expectedSystem: MINIMAL_SYSTEM_PROMPT, + expectedToolDescriptions: { bash: MINIMAL_BASH_DESCRIPTION }, policyContext: { includes: ['Current DSH file policy: danger-full-access.', 'file modifications by available operations'], excludes: ['write and edit tools', 'terminal sessions', 'one-shot bash commands'], @@ -125,16 +141,33 @@ async function persistedLogs(sessionsRoot: string): Promise { interface LoggedRequestHeader { type?: string - data?: { header?: { system?: unknown; tools?: Array<{ name: string; parameters: { required?: string[] } }> } } + data?: { header?: { system?: unknown; tools?: LoggedTool[] } } } -function assembledToolRequirements(log: PersistedLog): Record { +interface LoggedTool { + readonly name: string + readonly description?: unknown + readonly parameters: { readonly required?: string[] } +} + +function assembledTools(log: PersistedLog): LoggedTool[] { const event = log.content.trimEnd().split('\n') .map(line => JSON.parse(line) as LoggedRequestHeader) .find(candidate => candidate.type === 'request/header') const tools = event?.data?.header?.tools if (tools === undefined) throw new Error('session log has no request/header tools') - return Object.fromEntries(tools.map(tool => [tool.name, tool.parameters.required ?? []])) + return tools +} + +function assembledToolRequirements(log: PersistedLog): Record { + return Object.fromEntries(assembledTools(log).map(tool => [tool.name, tool.parameters.required ?? []])) +} + +function assembledToolDescriptions(log: PersistedLog): Record { + return Object.fromEntries(assembledTools(log).map((tool) => { + if (typeof tool.description !== 'string') throw new Error(`tool ${tool.name} has no description`) + return [tool.name, tool.description] + })) } function assembledSystem(log: PersistedLog): string { @@ -400,6 +433,16 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) expect(assembledToolRequirements(parent)).toEqual(scenario.expectedTools) } + if (scenario.expectedSystem !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledSystem(parent)).toBe(scenario.expectedSystem) + } + if (scenario.expectedToolDescriptions !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledToolDescriptions(parent)).toMatchObject(scenario.expectedToolDescriptions) + } if (scenario.policyContext !== undefined) { const parent = ordered[0] if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 6467b4ca87..52e788c06d 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: 2d545688c58a2f1b755e647d7cda9555249e41c4 -README.zh.md: b335d75aedc3a145771b23ea5d408315cae9a3e3 +README.md: f2cd6b9f1a978fe5519df3965fbe6679a72d56f5 +README.zh.md: dfa25d1d09d6edf24ebf1df3faa925493aeef7de diff --git a/python/sdk/README.md b/python/sdk/README.md index 2d545688c5..f2cd6b9f1a 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -5,8 +5,7 @@ English | [中文](README.zh.md) Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The runtime inherits normal DeepSeek Harness environment variables such as `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model -endpoints directly or point those variables at a local proxy during -benchmark runs. +endpoints directly or point those variables at a local proxy. Installing `deepseek-harness` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: @@ -35,6 +34,8 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. +The [minimal-agent tutorial](../../docs/user/guide/python-sdk-minimal.md) provides a complete standalone Cordis file and runnable SDK example for using the two-tool minimal mode without the Web UI. + `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, events, notifications, session_root)`. The result has no prompt-level status or turn reason: `final_response` is the last committed root-session assistant text in the interval, not an output causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. `HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index b335d75aed..dfa25d1d09 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接用真实模型端点,也可以在跑基准测试时把它们指向本地代理。 +通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: @@ -31,6 +31,8 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 +[极简 agent 教程](../../docs/user/guide/python-sdk-minimal.md)提供完整的独立 Cordis 文件与可运行的 SDK 示例,用于在不使用 Web UI 的情况下使用双工具极简模式。 + `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, events, notifications, session_root)`。结果不携带提示词级状态或轮次原因:`final_response` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的输出。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 `HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 diff --git a/website/docs.ts b/website/docs.ts index 9fcdc7c1a7..365df21571 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -138,13 +138,21 @@ const homeAndGuide = pairedPages([ section: { root: '入门', en: 'Guide' }, order: 3, }, + { + source: 'docs/user/guide/python-sdk-minimal.md', + route: 'guide/python-sdk-minimal.md', + label: { root: 'Python SDK 极简模式', en: 'Minimal mode with Python' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, + order: 4, + }, { source: 'docs/user/guide/config.md', route: 'guide/config.md', label: { root: '配置文件', en: 'Configuration' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 4, + order: 5, }, ]) From 0a17575040b2830248d68f6bea07c56bec3517bf Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 19:34:01 +0800 Subject: [PATCH 020/105] fix(sandbox): address review: leak FIXME, legal ACL fixture, stronger offset test, prose --- ...08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- ...026-08-08-native-windows-pull-request-ci.md | 2 +- ...-08-08-native-windows-pull-request-ci.zh.md | 2 +- .../sandbox/sandbox-windows-acl/src/index.ts | 10 +++++++--- .../tests/acl-failure-paths.spec.ts | 4 ++-- .../sandbox-windows-acl/tests/ffi.spec.ts | 18 +++++++++++++++--- .../tests/index-failure-paths.spec.ts | 7 ++++--- 7 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index d6e9a87840..dcdbff1208 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 1c6a1c4dcf50ac6fc5d30ea5904fb81249e55dfe -2026-08-08-native-windows-pull-request-ci.zh.md: 4342362815ecf738a4730dc1417c1be0eaddf3af +2026-08-08-native-windows-pull-request-ci.md: 33fbf1ae378112b4fd82633a77afa52056d93d98 +2026-08-08-native-windows-pull-request-ci.zh.md: 552e5cd3129011198fe442ba747cf2fdb7d97365 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 1c6a1c4dcf..33fbf1ae37 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 4342362815..552e5cd312 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,7 +18,7 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 4878d2a183..9a166fd85d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -249,8 +249,12 @@ export class AclSandbox { } catch (error) { // Best-effort close on the failure path (last error already captured in `error`). api.closeHandle(currentToken) - // Fail-closed cleanup: never leave a revocable (temp) grant or SID - // allocation behind a failed init. Standing workspace ACEs are NOT + // FIXME(windows-acl): a failure after createRestrictedToken leaks the restricted + // token handle and the parsed write SID — this.api stays undefined, so dispose() + // early-returns and cannot clean them up. Close the token and free the write SID + // here (the hardening-followup rework already does both). + // Fail-closed cleanup: revoke the revocable (temp) grants and free the init SID + // allocations a failed init left behind. Standing workspace ACEs are NOT // revoked — they are the intended end state (the reuse cache), not an // error artifact. const cleanupFailures: unknown[] = [] @@ -361,7 +365,7 @@ export class AclSandbox { } const token = this.token /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always - has its token; the guard mirrors the write-SID guard's defensive shape. */ + has its token; the guard mirrors the write-SID guard. */ if (token !== undefined) { try { if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index e6d5914bd9..005f522fda 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -72,12 +72,12 @@ function craftSid(revision: number, count: number, authority: number[] = [0, 0, function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) // AclRevision - koffi.encode(acl, 2, 'uint16', 16) // AclSize: header + one 8-byte-SID ACE + koffi.encode(acl, 2, 'uint16', 24) // AclSize: 8-byte header + one 16-byte ACE koffi.encode(acl, 4, 'uint16', 1) // AceCount const ace = 8 koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE) koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) - koffi.encode(acl, ace + 2, 'uint16', 8) + koffi.encode(acl, ace + 2, 'uint16', 16) // AceSize: header + mask + inline 8-byte SID koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK) const inlineSid = ace + 8 for (let offset = 0; offset < 8; offset++) { diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 8388911598..903f56afc3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -182,9 +182,21 @@ describe('sameSidAt bounded comparison', () => { expect(sameSidAt(left, 0, right, 0)).toBe(false) }) - it('accepts identical SIDs at nonzero offsets', () => { - const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) - const right = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + it('accepts identical SIDs at nonzero offsets over differing leading bytes', () => { + const sid = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + // Embed the same SID bytes at offset 4 of two buffers whose first four + // bytes differ: an offset-ignoring comparison reads the differing + // prefixes and must reject. + const left = allocBytes(4 + 12) + const right = allocBytes(4 + 12) + koffi.encode(left, 0, 'uint32', 0x11111111) + koffi.encode(right, 0, 'uint32', 0x22222222) + for (let offset = 0; offset < 12; offset++) { + const byte = koffi.decode(sid, offset, 'uint8') as number + koffi.encode(left, 4 + offset, 'uint8', byte) + koffi.encode(right, 4 + offset, 'uint8', byte) + } expect(sameSidAt(left, 4, right, 4)).toBe(true) + expect(sameSidAt(left, 0, right, 0)).toBe(false) // the differing prefixes are not a matching SID }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 87fc23ea9f..77e931499d 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -270,10 +270,11 @@ describe('AclSandbox init', () => { // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the // token-layer close of 1n succeeds and init's close of 2n fails. closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1)) + // The failure lands after this.token is stored but before this.api is + // assigned; the catch drains the SID allocations and rethrows the + // original error. (The stored restricted token and parsed write SID leak + // until process exit — see the FIXME in init's catch.) await expect(sandbox.init()).rejects.toMatchObject({ api: 'CloseHandle' }) - // The failed init never stored a restricted token: dispose skips the - // token close and the already-drained allocations. - expect(() => { sandbox.dispose() }).not.toThrow() }) it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => { From 8ebf02e0ac53f6b01fa6d938c650eaf245084f66 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 19:34:17 +0800 Subject: [PATCH 021/105] docs(subagent): cite issue 1723 as plain text in the approvals-pinned note A leaked host TSX_TSCONFIG_PATH redirected the tsx gate scripts to a staging checkout and masked the verify-public-repository-links rejection of the internal issue URL; all tsx-driven gates re-verified clean with the variable unset. --- .../2026-08-10-subagent-approval-pinned-never.i18n.yaml | 4 ++-- .../feature/2026-08-10-subagent-approval-pinned-never.md | 2 +- .../feature/2026-08-10-subagent-approval-pinned-never.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml index cde23b2552..322d645a70 100644 --- a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.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-08-10-subagent-approval-pinned-never.md -2026-08-10-subagent-approval-pinned-never.md: 578dbe58cd4e0a3c97552e20f27a2818ee9c9f40 -2026-08-10-subagent-approval-pinned-never.zh.md: 45bc461505b07a4c10e063a122e8003f52afd259 +2026-08-10-subagent-approval-pinned-never.md: a21c6b966b1ad00ed63e0fe87b0ce982f0daf490 +2026-08-10-subagent-approval-pinned-never.zh.md: db44ae134d34904a53691cfe78eaa5a899cf64e0 diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md index 578dbe58cd..a21c6b966b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md @@ -6,7 +6,7 @@ English | [中文](2026-08-10-subagent-approval-pinned-never.zh.md) ## Problem -A delegated child that asked for approval had no one to ask. Under an interactive parent (`'ask'`), a background child's escalation became a pending question no product surface showed — subagent sessions are omitted from the Web sidebar, the parent's `list_agents` reports plain `running`/`idle`, and the catalog rows show only activity — so a permission-blocked child was indistinguishable from a working one; headless and unanswered compositions failed the same ask closed as `'unavailable'`. The rejection audit landed only in the child's own log, and no tool parameter or Web control can adjust a running child session's sandbox mode or approval policy ([deepseek-harness#1723](https://github.com/deepseek-harness/deepseek-harness/issues/1723)). The mechanism-heavy fix — a durable blocked-state projection, parent notices, catalog badges, and a permission write path through the subagent ownership fence — was disproportionate directly before release. +A delegated child that asked for approval had no one to ask. Under an interactive parent (`'ask'`), a background child's escalation became a pending question no product surface showed — subagent sessions are omitted from the Web sidebar, the parent's `list_agents` reports plain `running`/`idle`, and the catalog rows show only activity — so a permission-blocked child was indistinguishable from a working one; headless and unanswered compositions failed the same ask closed as `'unavailable'`. The rejection audit landed only in the child's own log, and no tool parameter or Web control can adjust a running child session's sandbox mode or approval policy (Issue #1723). The mechanism-heavy fix — a durable blocked-state projection, parent notices, catalog badges, and a permission write path through the subagent ownership fence — was disproportionate directly before release. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md index 45bc461505..db44ae134d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -被委派的子 agent 发起审批请求时无人可问。在交互式父级(`'ask'`)之下,后台子 agent 的升级请求会变成一个任何产品界面都不展示的挂起问题——subagent 会话不进入 Web 侧边栏,父级的 `list_agents` 只报告普通的 `running`/`idle`,目录树的行也只显示活动状态——因此被权限拦住的子 agent 与正常干活的子 agent 无法区分;headless 与无应答者的组合则让同一次 ask 以 `'unavailable'` 失败关闭。拒绝的审计记录只落在子 agent 自己的日志里,而且没有任何工具参数或 Web 控件能调整一个正在运行的子会话的沙箱模式或审批策略([deepseek-harness#1723](https://github.com/deepseek-harness/deepseek-harness/issues/1723))。机制繁重的修复方案——持久化的受阻状态投影、父级通知、目录树徽标,以及穿过 subagent 所有权围栏的权限写入路径——在临近发布时代价不成比例。 +被委派的子 agent 发起审批请求时无人可问。在交互式父级(`'ask'`)之下,后台子 agent 的升级请求会变成一个任何产品界面都不展示的挂起问题——subagent 会话不进入 Web 侧边栏,父级的 `list_agents` 只报告普通的 `running`/`idle`,目录树的行也只显示活动状态——因此被权限拦住的子 agent 与正常干活的子 agent 无法区分;headless 与无应答者的组合则让同一次 ask 以 `'unavailable'` 失败关闭。拒绝的审计记录只落在子 agent 自己的日志里,而且没有任何工具参数或 Web 控件能调整一个正在运行的子会话的沙箱模式或审批策略(Issue #1723)。机制繁重的修复方案——持久化的受阻状态投影、父级通知、目录树徽标,以及穿过 subagent 所有权围栏的权限写入路径——在临近发布时代价不成比例。 ## 决策 From 299cafad0106396dc5053065371657555652e45d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:45:54 +0800 Subject: [PATCH 022/105] fix(python): rename SDK distribution --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 4 +- .../workflows/build-exe-for-python-sdk.yml | 12 +++--- .gitlab-ci.yml | 6 +-- THIRD_PARTY_NOTICES.md | 2 +- docs/user/guide/python-sdk-minimal.i18n.yaml | 4 +- docs/user/guide/python-sdk-minimal.md | 39 ++++++++++++++++++- docs/user/guide/python-sdk-minimal.zh.md | 39 ++++++++++++++++++- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/development.i18n.yaml | 4 +- python/development.md | 2 +- python/development.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 8 +++- python/sdk/README.zh.md | 8 +++- python/sdk/pyproject.toml | 2 +- python/sdk/tests/test_release_version.py | 12 ++++++ python/sdk/uv.lock | 14 +++---- scripts/build-python-release.py | 11 +++++- scripts/gen-third-party-notices.spec.ts | 2 +- scripts/gen-third-party-notices.ts | 2 +- 27 files changed, 151 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 8bef07d042..c1ab35fa0b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: a45678c9bb5fcae340ff7134687890879f56c630 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f1fccc508471356dd6434da0e126ed38f15ed3ba +2026-07-10-single-file-executable-sdk-runtime-distribution.md: fd232e8893b7beebe2e279cb5532daf8ef73a8a3 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: bb0b6f8f660a42495da651a581236a7ce2a50773 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index a45678c9bb..fd232e8893 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -48,13 +48,13 @@ CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workf The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with `deepseek-harness-sdk` depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. ### Naming lineage -`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`. +`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`. ## Disposition of worker-style plugins diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index f1fccc5084..bb0b6f8f66 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -48,13 +48,13 @@ CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 `deepseek-harness-sdk` 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 ### 命名血统 -`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 +`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。 ## 工作线程插件 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 017b77ee75..0924da5b35 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -92,7 +92,7 @@ jobs: sdk-wheel: needs: plan - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -113,8 +113,8 @@ jobs: - uses: actions/upload-artifact@v7 with: - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl - path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl + path: dist-python/deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl if-no-files-found: error build: @@ -197,7 +197,7 @@ jobs: - uses: actions/download-artifact@v8 with: - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python - name: Install only the SDK into a clean venv and run zero-config @@ -208,7 +208,7 @@ jobs: python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ --find-links dist-python \ - deepseek-harness=="$VERSION" + deepseek-harness-sdk=="$VERSION" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default @@ -238,7 +238,7 @@ jobs: esac docker run --rm -e VERSION -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk - /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness=="$VERSION" + /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default ' diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b4af9e3ecb..fd56278195 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -42,7 +42,7 @@ sdk-wheel: - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" - python -m venv .wheel-smoke - - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness=="$DSH_VERSION" + - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_VERSION" - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default - | if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then @@ -55,7 +55,7 @@ sdk-wheel: linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" fi artifacts: paths: [release/$PLATFORM/*.whl] @@ -112,7 +112,7 @@ publish-python: - python -m pip install twine==6.2.0 script: - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 - - test -f "release/sdk/deepseek_harness-${DSH_VERSION}-py3-none-any.whl" + - test -f "release/sdk/deepseek_harness_sdk-${DSH_VERSION}-py3-none-any.whl" - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_x86_64.whl" - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_aarch64.whl" - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-macosx_11_0_arm64.whl" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fec5de6128..d094c5938e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -181,7 +181,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | Package | License | Role | | --- | --- | --- | | [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | -| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness` | +| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` | | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | diff --git a/docs/user/guide/python-sdk-minimal.i18n.yaml b/docs/user/guide/python-sdk-minimal.i18n.yaml index 3a3b7dd8a7..975a035c74 100644 --- a/docs/user/guide/python-sdk-minimal.i18n.yaml +++ b/docs/user/guide/python-sdk-minimal.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 docs/user/guide/python-sdk-minimal.md -python-sdk-minimal.md: 9d46278aeec625afdf30678806bc90104be00b66 -python-sdk-minimal.zh.md: ec06a205c680c1d7a83be5f949c7ff4d719defe4 +python-sdk-minimal.md: e658fadae9575fd8bbc2aab2c622df7079c7addf +python-sdk-minimal.zh.md: ef37e1e801512bf60f212e69b61eb423ce4ab1d1 diff --git a/docs/user/guide/python-sdk-minimal.md b/docs/user/guide/python-sdk-minimal.md index 9d46278aee..e658fadae9 100644 --- a/docs/user/guide/python-sdk-minimal.md +++ b/docs/user/guide/python-sdk-minimal.md @@ -11,15 +11,50 @@ This tutorial runs the minimal agent without the Web UI. The checked-in Cordis c - A DeepSeek-compatible API endpoint and credential - An isolated workspace that the agent may modify +## Install the SDK + +Choose either the public package or a source build. Both install the `deepseek-harness-sdk` distribution and expose the `deepseek_harness` Python module. + +### Install from PyPI + Create a virtual environment and install the SDK with its same-version bundled runtime: ```sh python -m venv .venv . .venv/bin/activate -python -m pip install deepseek-harness +python -m pip install deepseek-harness-sdk ``` -The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so an installed SDK does not need Node.js. +### Build from source + +A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment: + +```sh +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +cd deepseek-harness +python -m pip install uv==0.11.23 +corepack enable +pnpm install + +case "$(uname -s):$(uname -m)" in + Linux:x86_64) runtime_platform=linux-x64 ;; + Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; + Darwin:arm64) runtime_platform=macos-arm64 ;; + *) echo "unsupported platform" >&2; exit 1 ;; +esac + +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py \ + --package runtime \ + --platform "$runtime_platform" \ + --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ + --output-dir dist-python +python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" +``` + +The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so neither installation path needs Node.js after installation. ## Run the checked-in example diff --git a/docs/user/guide/python-sdk-minimal.zh.md b/docs/user/guide/python-sdk-minimal.zh.md index ec06a205c6..ef37e1e801 100644 --- a/docs/user/guide/python-sdk-minimal.zh.md +++ b/docs/user/guide/python-sdk-minimal.zh.md @@ -11,15 +11,50 @@ - DeepSeek 兼容的 API 端点与凭据 - agent 可以修改的隔离 workspace +## 安装 SDK + +可以选择安装公开包或从源码构建。两种方式都会安装 `deepseek-harness-sdk` 分发包,并提供 `deepseek_harness` Python 模块。 + +### 从 PyPI 安装 + 请创建虚拟环境,并安装 SDK 及其同版本内置运行时: ```sh python -m venv .venv . .venv/bin/activate -python -m pip install deepseek-harness +python -m pip install deepseek-harness-sdk ``` -运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此安装后的 SDK 不需要 Node.js。 +### 从源码构建 + +从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11,以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境: + +```sh +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +cd deepseek-harness +python -m pip install uv==0.11.23 +corepack enable +pnpm install + +case "$(uname -s):$(uname -m)" in + Linux:x86_64) runtime_platform=linux-x64 ;; + Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; + Darwin:arm64) runtime_platform=macos-arm64 ;; + *) echo "unsupported platform" >&2; exit 1 ;; +esac + +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py \ + --package runtime \ + --platform "$runtime_platform" \ + --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ + --output-dir dist-python +python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" +``` + +运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此两种安装方式完成后都不再需要 Node.js。 ## 运行仓库内置示例 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 0086d8519f..ab8ad1f4f8 100644 --- a/python/README.i18n.yaml +++ b/python/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 python/README.md -README.md: 6ab9de681471c4be3bff72ddbf6ce8f118224d4d -README.zh.md: 82fca597791f19caa21a7a433e533a4d47c64ccb +README.md: 75276a915eb4b63f84e0876de46e6d8d63540b59 +README.zh.md: 7791231f9899bd1cca0d62ad35e388db608294c2 diff --git a/python/README.md b/python/README.md index 6ab9de6814..75276a915e 100644 --- a/python/README.md +++ b/python/README.md @@ -8,7 +8,7 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com | Directory | Dist / module | Role | |---|---|---| -| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | +| [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | | [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Bundled runtime binaries and default agent configuration | ## Behavior diff --git a/python/README.zh.md b/python/README.zh.md index 82fca59779..7791231f98 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -8,7 +8,7 @@ | 目录 | 分发名 / 模块 | 职责 | |---|---|---| -| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | +| [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | | [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置运行时二进制与默认 agent(智能体)配置 | ## 行为 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 1a7b57f86d..c341c32cea 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: b0d4875f0d5b7c8fd2b4b480ac67793741640710 -development.zh.md: 053cd1d022ef5fc7d8cff98b9fc3234df6ff4cd1 +development.md: 9614c06436ab6863a5e1b2ff83fbe605552dc13b +development.zh.md: 1c646ca39735b85a5d380768fe215c92532be7e7 diff --git a/python/development.md b/python/development.md index b0d4875f0d..9614c06436 100644 --- a/python/development.md +++ b/python/development.md @@ -55,7 +55,7 @@ Build the pure SDK wheel once and one runtime wheel on each native platform: version="$(node -p "require('./package.json').version")" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" +pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS arm64. A `python-vX.Y.Z` tag is accepted only when it matches the repository version. diff --git a/python/development.zh.md b/python/development.zh.md index 053cd1d022..1c646ca397 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -55,7 +55,7 @@ with DeepSeekHarness() as harness: version="$(node -p "require('./package.json').version")" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" +pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` 运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS arm64。只有与仓库版本匹配时,才接受 `python-vX.Y.Z` 标签。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index bc52b5ee6c..d06131b7e7 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/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 python/sdk-runtime/README.md -README.md: 07bb3c574b3cd49f1dc74f0e9d9bd1bb7ca9b216 -README.zh.md: 0613b6faf68ea3bdb6c9b673677fc483b79dab72 +README.md: 5c7c6f66083a1b56cc6b4aed9565e8b1be014ccc +README.zh.md: cef1478710d20d7faa612e50d0c2f8ec19e8716a diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 07bb3c574b..5c7c6f6608 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness` client spawns, and ships the default configuration behind zero-config runs. +Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs. ## Runtime carriers diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0613b6faf6..cef1478710 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 +Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 ## 运行时载体 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 52e788c06d..08da23d879 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: f2cd6b9f1a978fe5519df3965fbe6679a72d56f5 -README.zh.md: dfa25d1d09d6edf24ebf1df3faa925493aeef7de +README.md: 2350fbfd5dd0094bfd7a11f81d8226e960879923 +README.zh.md: 5120a8c5f65360485d450614186d5cb2702fda17 diff --git a/python/sdk/README.md b/python/sdk/README.md index f2cd6b9f1a..2350fbfd5d 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -7,7 +7,13 @@ runtime inherits normal DeepSeek Harness environment variables such as `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model endpoints directly or point those variables at a local proxy. -Installing `deepseek-harness` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: +Install the `deepseek-harness-sdk` distribution from PyPI; the import module remains `deepseek_harness`: + +```sh +python -m pip install deepseek-harness-sdk +``` + +Installing `deepseek-harness-sdk` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: ```py from deepseek_harness import DeepSeekHarness diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index dfa25d1d09..5120a8c5f6 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -4,7 +4,13 @@ 通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 -安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: +请从 PyPI 安装 `deepseek-harness-sdk` 分发包;导入模块仍为 `deepseek_harness`: + +```sh +python -m pip install deepseek-harness-sdk +``` + +安装 `deepseek-harness-sdk` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: ```py from deepseek_harness import DeepSeekHarness diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index eeef355e90..48ffbf2499 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling>=1.30.1"] build-backend = "hatchling.build" [project] -name = "deepseek-harness" +name = "deepseek-harness-sdk" version = "0.0.0.dev0" description = "Python SDK for DeepSeek Harness" readme = "README.md" diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7e5f660070..cf5a6cf57b 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -39,6 +39,18 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: build_python_release.repository_version(tmp_path) +def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: Path) -> None: + destination = tmp_path / "staging" + + build_python_release.stage_sdk(destination, "1.2.3") + + pyproject = (destination / "pyproject.toml").read_text() + assert 'name = "deepseek-harness-sdk"' in pyproject + assert 'version = "1.2.3"' in pyproject + assert '"deepseek-harness-runtime-bin==1.2.3"' in pyproject + assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file() + + @pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool diff --git a/python/sdk/uv.lock b/python/sdk/uv.lock index 94219b95ad..e2a62a9fe0 100644 --- a/python/sdk/uv.lock +++ b/python/sdk/uv.lock @@ -21,7 +21,12 @@ wheels = [ ] [[package]] -name = "deepseek-harness" +name = "deepseek-harness-runtime-bin" +version = "0.0.0.dev0" +source = { editable = "../sdk-runtime" } + +[[package]] +name = "deepseek-harness-sdk" version = "0.0.0.dev0" source = { editable = "." } dependencies = [ @@ -43,17 +48,12 @@ requires-dist = [ [package.metadata.requires-dev] test = [{ name = "pytest", specifier = ">=8.0" }] -[[package]] -name = "deepseek-harness-runtime-bin" -version = "0.0.0.dev0" -source = { editable = "../sdk-runtime" } - [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index ec049cdd4f..c9ee5e31c0 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -17,6 +17,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +SDK_DISTRIBUTION = "deepseek-harness-sdk" +RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin" PLATFORMS = { "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), @@ -53,7 +55,7 @@ def main() -> None: if args.package == "sdk": stage_sdk(staging, version) environment = None - expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl" + expected = output_dir / f"deepseek_harness_sdk-{version}-py3-none-any.whl" else: platform_tag, executable_name = PLATFORMS[args.platform] stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name) @@ -160,6 +162,11 @@ def verify_wheel( raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}") if metadata.get("Version") != version: raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}") + expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION + if metadata.get("Name") != expected_distribution: + raise RuntimeError( + f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}" + ) runtime_files = [ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name ] @@ -177,7 +184,7 @@ def verify_wheel( raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": requirements = metadata.get_all("Requires-Dist") or [] - expected_requirement = f"deepseek-harness-runtime-bin=={version}" + expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}" if expected_requirement not in requirements: raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}") diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index aa3198057b..5801f1b2f3 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -227,7 +227,7 @@ describe('collectPythonDependencies', () => { it('excludes normalized local project names without exempting a third-party prefix', () => { const pyprojects = [ '[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n', - '[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n', + '[project]\nname = "deepseek-harness-sdk"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n', ] expect(() => collectPythonDependencies(pyprojects)).toThrow( 'python dependency deepseek-unrelated is missing from PYTHON_METADATA', diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index c2ab21688a..23d41313d8 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -83,7 +83,7 @@ const OVERRIDES: Record = { * the generator fails when a manifest names a package this map misses. */ const PYTHON_METADATA: Record = { - pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' }, + pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' }, hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' }, pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' }, } From 5ba4055ed44f74d5fc4cb4f414f2f7edfdd6adc0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 19:46:07 +0800 Subject: [PATCH 023/105] docs(subagent): trim comments in the delegated-policy additions --- .../tests/inheritance.spec.ts | 10 ++---- .../tests/structured.spec.ts | 3 +- packages/subagent/subagent/src/child-agent.ts | 34 +++++++------------ .../tests/continuation-inheritance.spec.ts | 6 ++-- .../subagent/tests/continuation.spec.ts | 2 +- .../tests/tool-subagent-report.spec.ts | 2 +- 6 files changed, 20 insertions(+), 37 deletions(-) diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 17620f7774..899772b964 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -84,8 +84,7 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'spawn-blocked.txt') setSandboxMode(parent.session, 'read-only') - // The parent keeps the interactive deployment default: the child pin must - // not depend on any parent approval override. + // No parent approval override: the child pin must not depend on one. expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() const parentLogLength = parent.session.events.length script.push( @@ -125,8 +124,7 @@ describe('in-process policy inheritance', () => { .join('\n') expect(contextText).toContain('Current DSH file policy: read-only') expect(contextText).toContain('Approval prompts are disabled') - // The delegation-scope statement is a runtime-context fact, so the - // deployment system prompt stays uniform across parents and children. + // The statement rides runtime context; the system prompt stays uniform. expect(contextText).toContain('You are a delegated subagent') expect(request.data.header.system).not.toContain('Approval prompts are disabled') expect(request.data.header.system).not.toContain('You are a delegated subagent') @@ -215,8 +213,7 @@ describe('in-process policy inheritance', () => { it('rejects a child escalation deterministically even when an answerer would allow it', async () => { const script: Script = [] const { ctx, parent } = await setupWalled(script) - // A root answerer that would GRANT: the pinned 'never' must resolve - // before any answerer is consulted, so this never runs for the child. + // A granting answerer proves the pin resolves before any answerer runs. let consulted = false ctx.on('approval/request', () => { consulted = true @@ -243,7 +240,6 @@ describe('in-process policy inheritance', () => { expect(consulted).toBe(false) expect(toolResultTexts(child).join('\n')) .toContain('the user rejected escalating this operation to "workspace-write"') - // The deterministic rejection still leaves the full audit pair on the child log. const asked = child.session.events.find( (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', ) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 36e63283da..eb9815b56b 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -247,8 +247,7 @@ describe('in-process structured output', () => { const result = await run.result expect(result.stopReason).toBe('error') expect(result.structured).toBeUndefined() - // Exactly one model request and one caller-supplied user message (the - // delegation runtime-context snapshot aside): no nudge turn exists. + // Exactly one model request and one caller-supplied user message: no nudge turn exists. expect(adapter.requests.length).toBe(1) const child = ctx.agents.get(run.id)! expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index bc0cf949b9..d1728ad74a 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -113,13 +113,9 @@ export interface ChildComposition { } /** - * Model-facing statement every in-process child receives: the permission - * scope is fixed at delegation and approval prompts are unavailable, so the - * child reports a scope limitation instead of retrying denied operations. - * A runtime-context contribution (not a system-prompt section) because it is - * a per-session fact: the deployment's system prompt stays uniform across - * parents and children, and the statement joins the same durable snapshot - * that carries the sandbox-policy and approval-policy sentences. + * Model-facing delegation-scope statement for every in-process child. A + * runtime-context contribution rather than a system-prompt section, so the + * deployment's system prompt stays uniform across parents and children. */ export const SUBAGENT_DELEGATION_CONTEXT = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be ' @@ -131,14 +127,12 @@ export const SUBAGENT_DELEGATION_CONTEXT * Apply one child's scoped composition inside its creation window: the fixed * delegation-scope statement, a shadowing persona section, and a tool * restriction, all owned by the child's scope and therefore invisible to its - * parent and siblings. Both creation and cold resume pass through here, so a - * resumed child keeps the same statement. + * parent and siblings. Creation and cold resume both pass through here. * @param childCtx - the child agent's scoped creation context. * @param composition - the persona and tool filter to install. */ export function applyChildComposition(childCtx: Context, composition: ChildComposition): void { - // After sandbox:policy (110) and approval:policy (115): scope, then policy, - // then what a delegated child does about a denial. + // Order 120: after the sandbox:policy (110) and approval:policy (115) sentences. childCtx.systemPrompt.context({ name: 'subagent:delegation', order: 120, text: SUBAGENT_DELEGATION_CONTEXT }) if (composition.persona !== undefined) { childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) @@ -151,11 +145,9 @@ export interface DelegatedPolicyOverrides { /** The parent session's explicit sandbox-mode override, or `undefined` without one. */ readonly sandboxMode: SandboxMode | undefined /** - * The child's pinned approval policy, or `undefined` when no approval - * capability is composed. Always `'never'` with one composed: a delegated - * child acts only within the sandbox scope fixed at delegation, so the - * composed `ApprovalService` rejects every child ask deterministically - * instead of waiting on a prompt no one is watching. + * `'never'` whenever the approval capability is composed, `undefined` + * otherwise: a delegated child acts only within the sandbox scope fixed at + * delegation, so its asks are rejected deterministically. */ readonly approvalPolicy: 'never' | undefined } @@ -163,12 +155,10 @@ export interface DelegatedPolicyOverrides { /** * Capture the policy to seed into one delegation. Call synchronously before * the child start's first await: a later parent switch belongs to the - * parent's future, not to this child. The sandbox scope is the parent - * session's explicit override — deployment defaults and one-shot grants are - * never captured, so an unswitched parent leaves the child following the - * deployment default dynamically. The approval policy is never inherited: it - * is pinned to `'never'` whenever the approval capability is composed, - * regardless of the parent's own policy. + * parent's future, not to this child. Only the parent session's explicit + * sandbox override is captured — never deployment defaults or one-shot + * grants — and the approval policy is pinned to `'never'` regardless of the + * parent's own policy. * @param parent - the delegating parent agent. * @returns the sandbox override (or `undefined` without one) and the approval pin. */ diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 1e539c28c2..2bc63888ae 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -74,8 +74,7 @@ describe('continuable policy inheritance', () => { it('seeds the parent sandbox override and pins approval to never', async () => { const { ctx, parent } = await setup([textResponse('child done')]) setSandboxMode(parent.session, 'danger-full-access') - // The parent keeps the interactive deployment default: the child pin must - // not depend on any parent approval override. + // No parent approval override: the child pin must not depend on one. expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() let child: Agent | undefined ctx.on('agent/created', ({ agent }) => { @@ -95,11 +94,10 @@ describe('continuable policy inheritance', () => { { type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } }, { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) - // Durable: a reload folds the same effective policy; the parent keeps its own. + // Durable: a reload folds the same effective policy. expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access') expect(effectiveApprovalPolicy(loaded.events)).toBe('never') expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() - // The child's runtime-context snapshot states the fixed delegation scope. const runtimeContext = loaded.events.find( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin' diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 6e23f6ccae..da3c474aec 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -103,7 +103,7 @@ function hasUserText(events: readonly SessionEvent[], text: string): boolean { && event.data.content.some(block => block.type === 'text' && block.text === text)) } -/** Every caller-supplied user-role message text in log order, for FIFO assertions (framework runtime-context snapshots excluded). */ +/** Caller-supplied user message texts in log order (runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index c74fb94646..47d0fb3269 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -411,7 +411,7 @@ describe('dsh-tool-subagent-report', () => { }) }) -/** Prove report delivery uses ordinary logged user messages (framework runtime-context snapshots excluded). */ +/** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) From 6dabf0ea996f218a9c6178cfa666b408821aa9e8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:53:51 +0800 Subject: [PATCH 024/105] docs(python): generalize SDK guide --- ...minimal.i18n.yaml => python-sdk.i18n.yaml} | 6 +++--- .../{python-sdk-minimal.md => python-sdk.md} | 20 +++++++++---------- ...hon-sdk-minimal.zh.md => python-sdk.zh.md} | 20 +++++++++---------- docs/user/guide/quickstart.i18n.yaml | 4 ++-- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- examples/jsonrpc-agent/README.i18n.yaml | 4 ++-- examples/jsonrpc-agent/README.md | 4 ++-- examples/jsonrpc-agent/README.zh.md | 4 ++-- python/sdk/README.i18n.yaml | 4 ++-- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- website/docs.ts | 6 +++--- 13 files changed, 40 insertions(+), 40 deletions(-) rename docs/user/guide/{python-sdk-minimal.i18n.yaml => python-sdk.i18n.yaml} (66%) rename docs/user/guide/{python-sdk-minimal.md => python-sdk.md} (82%) rename docs/user/guide/{python-sdk-minimal.zh.md => python-sdk.zh.md} (82%) diff --git a/docs/user/guide/python-sdk-minimal.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml similarity index 66% rename from docs/user/guide/python-sdk-minimal.i18n.yaml rename to docs/user/guide/python-sdk.i18n.yaml index 975a035c74..04cfa163e7 100644 --- a/docs/user/guide/python-sdk-minimal.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -1,6 +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 docs/user/guide/python-sdk-minimal.md -python-sdk-minimal.md: e658fadae9575fd8bbc2aab2c622df7079c7addf -python-sdk-minimal.zh.md: ef37e1e801512bf60f212e69b61eb423ce4ab1d1 +# pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md +python-sdk.md: c48bc95c9334cfd16a925d12726c20b2f968c753 +python-sdk.zh.md: dc31c391a180a742c7dc10807f6ed2ef8d11927d diff --git a/docs/user/guide/python-sdk-minimal.md b/docs/user/guide/python-sdk.md similarity index 82% rename from docs/user/guide/python-sdk-minimal.md rename to docs/user/guide/python-sdk.md index e658fadae9..c48bc95c93 100644 --- a/docs/user/guide/python-sdk-minimal.md +++ b/docs/user/guide/python-sdk.md @@ -1,8 +1,8 @@ -# Run the minimal agent with the Python SDK +# Get started with the Python SDK -English | [中文](python-sdk-minimal.zh.md) +English | [中文](python-sdk.zh.md) -This tutorial runs the minimal agent without the Web UI. The checked-in Cordis composition fixes the system prompt, tool catalog, persistent-shell behavior, and compaction policy so SDK runs use the same model-facing contract as the Web `minimal` preset. +This tutorial installs the Python SDK, runs a checked-in Cordis composition without the Web UI, and uses the same API in your own program. It uses the compact [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) configuration as a complete example with a fixed system prompt, tool catalog, persistent-shell behavior, and compaction policy. ## Prerequisites @@ -30,7 +30,7 @@ python -m pip install deepseek-harness-sdk A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness cd deepseek-harness python -m pip install uv==0.11.23 corepack enable @@ -70,12 +70,12 @@ Run one task from the repository checkout: ```sh python examples/jsonrpc-agent/minimal.py \ --workspace /absolute/path/to/workspace \ - --session-root /absolute/path/to/trajectories \ + --session-root /absolute/path/to/sessions \ --session-id example-001 \ "Inspect the repository and fix the failing tests." ``` -The script prints the final assistant response. The session root receives the JSONL trajectory, including the assembled model request and every tool call. +The script prints the final assistant response. The session root receives a JSONL session log containing the assembled model request and every tool call. ## Use the SDK in your own program @@ -88,7 +88,7 @@ from deepseek_harness import DeepSeekHarness config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() workspace = Path("/absolute/path/to/workspace").resolve() -sessions = Path("/absolute/path/to/trajectories").resolve() +sessions = Path("/absolute/path/to/sessions").resolve() with DeepSeekHarness( provider="deepseek-official", @@ -108,7 +108,7 @@ print(result.final_response) `DeepSeekHarness` starts the bundled JSON-RPC runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id across calls also preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. -## Contract reproduced by the configuration +## Understand the example configuration | Surface | Fixed value | |---|---| @@ -121,9 +121,9 @@ print(result.final_response) The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, and every other model-facing plugin. Filesystem policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent. -## Keep runs reproducible +## Choose workspace and session IDs -For comparable trajectories, pin the Harness commit and Python package version together, retain the exact Cordis file, and record the provider, model, endpoint, `max_tokens`, task input, workspace state, and session id for every run. Start independent runs with a clean workspace and a fresh session id; reuse a session only when multi-turn state is intentional. +`cwd` selects the workspace available to the agent, while `session_root` stores session logs and state. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same conversation and persistent shell state. The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate and is not a Windows agent surface. diff --git a/docs/user/guide/python-sdk-minimal.zh.md b/docs/user/guide/python-sdk.zh.md similarity index 82% rename from docs/user/guide/python-sdk-minimal.zh.md rename to docs/user/guide/python-sdk.zh.md index ef37e1e801..dc31c391a1 100644 --- a/docs/user/guide/python-sdk-minimal.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -1,8 +1,8 @@ -# 使用 Python SDK 运行极简 agent(智能体) +# Python SDK 快速上手 -[English](python-sdk-minimal.md) | 中文 +[English](python-sdk.md) | 中文 -本教程介绍如何在不使用 Web UI 的情况下运行极简 agent。仓库内置的 Cordis 组合固定了系统提示词、工具目录、持久 shell 行为和压缩(compaction)策略,因此 SDK 运行与 Web `minimal` preset 使用相同的面向模型约定。 +本教程介绍如何安装 Python SDK、在不使用 Web UI 的情况下运行仓库内置 Cordis 组合,以及如何在自己的程序中调用同一套 API。教程使用精简且完整的 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 作为示例,其中固定了系统提示词、工具目录、持久 shell 行为和压缩(compaction)策略。 ## 前置要求 @@ -30,7 +30,7 @@ python -m pip install deepseek-harness-sdk 从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11,以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness cd deepseek-harness python -m pip install uv==0.11.23 corepack enable @@ -70,12 +70,12 @@ export DEEPSEEK_API_KEY=sk-your-key-here ```sh python examples/jsonrpc-agent/minimal.py \ --workspace /absolute/path/to/workspace \ - --session-root /absolute/path/to/trajectories \ + --session-root /absolute/path/to/sessions \ --session-id example-001 \ "Inspect the repository and fix the failing tests." ``` -脚本会打印 assistant 的最终回复。会话根目录会收到 JSONL 运行轨迹,其中包含组装后的模型请求与每次工具调用。 +脚本会打印 assistant 的最终回复。会话根目录会收到 JSONL 会话日志,其中包含组装后的模型请求与每次工具调用。 ## 在自己的程序中使用 SDK @@ -88,7 +88,7 @@ from deepseek_harness import DeepSeekHarness config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() workspace = Path("/absolute/path/to/workspace").resolve() -sessions = Path("/absolute/path/to/trajectories").resolve() +sessions = Path("/absolute/path/to/sessions").resolve() with DeepSeekHarness( provider="deepseek-official", @@ -108,7 +108,7 @@ print(result.final_response) `DeepSeekHarness` 会延迟启动内置 JSON-RPC 运行时,并持续复用,直至退出上下文管理器。在多次调用中复用同一个 harness 和 session id,还会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。 -## 配置复现的约定 +## 了解示例配置 | 方面 | 固定值 | |---|---| @@ -121,9 +121,9 @@ print(result.final_response) 该配置省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具和其他所有面向模型的插件。文件系统策略事实记录为运行时用户上下文,而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。 -## 保持运行可复现 +## 选择 workspace 与 session id -为了让运行轨迹可复现且便于比较,请配套固定 Harness commit 与 Python 包版本,保留确切的 Cordis 文件,并为每次运行记录提供方、模型、端点、`max_tokens`、任务输入、workspace 状态和 session id。独立运行应使用干净的 workspace 和新的 session id;只有有意保留多轮状态时才复用会话。 +`cwd` 用于选择 agent 可访问的 workspace,`session_root` 用于保存会话日志和状态。独立任务应使用新的 session id;只有下一次调用需要延续同一段对话和持久 shell 状态时,才复用原有 id。 该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行:Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该模式不适用于 Windows agent。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index a52f07e5c7..5aa765be30 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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 docs/user/guide/quickstart.md -quickstart.md: 13d5b2196282394619747e36ecddfa1d87f61c8d -quickstart.zh.md: 3d775db9280b43bbee2de37f7327fe8d2bd3a121 +quickstart.md: 6a0b292ce12b32b7993b7de56b35f1df2e7a7153 +quickstart.zh.md: 008245f136e28630c7e8368eeec536e11112a885 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 13d5b21962..6a0b292ce1 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,7 +57,7 @@ Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, ## Next steps -- [Run the minimal agent with Python](./python-sdk-minimal.md) — use the fixed two-tool composition without the Web UI +- [Get started with the Python SDK](./python-sdk.md) — install the SDK and run a complete Cordis configuration without the Web UI - [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 3d775db928..008245f136 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,7 +57,7 @@ pnpm run dsh web ## 下一步 -- [使用 Python 运行极简 agent](./python-sdk-minimal.md) — 无需 Web UI,即可使用固定的双工具组合 +- [Python SDK 快速上手](./python-sdk.md) — 安装 SDK,并在不使用 Web UI 的情况下运行完整 Cordis 配置 - [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的工具或后端 diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 8da5cf7ae6..04ee95bfab 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-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/jsonrpc-agent/README.md -README.md: 863b39eb9c7c65d36ceca77e945379fdd1d5fe22 -README.zh.md: a8334320896d2b16e166ef0f0ec300ab1cb0ca93 +README.md: 5f60a64a4888c64e4fd68835f78e4a334ffed263 +README.zh.md: 8d2f9807ff259000b5a6823357f8c41b43bfa434 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 863b39eb9c..5f60a64a48 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -21,7 +21,7 @@ The surrounding runtime also loads JSONL session persistence and automatic conte | `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` | | `DSH_CWD` | Agent workspace for bash and filesystem tools | | `DSH_MAX_TOKENS_AS_SUCCESS` | `true` (default) accepts token-limited results; `false` reports them as errors | -| `DSH_SESSION_ROOT` | JSONL trajectory directory | +| `DSH_SESSION_ROOT` | JSONL session directory | | `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. @@ -33,4 +33,4 @@ Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CON - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the local PTY, filesystem intent policy, session sandbox policy, and JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK; the [minimal Python SDK tutorial](../../docs/user/guide/python-sdk-minimal.md) covers setup, repeatable runs, and the security boundary. +It composes the local PTY, filesystem intent policy, session sandbox policy, and JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK; the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses this configuration to cover setup, session management, and the security boundary. diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index a833432089..8d2f9807ff 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -21,7 +21,7 @@ | `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 | | `DSH_CWD` | bash 和文件系统工具使用的 agent workspace | | `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 | -| `DSH_SESSION_ROOT` | JSONL 轨迹目录 | +| `DSH_SESSION_ROOT` | JSONL 会话目录 | | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 @@ -33,4 +33,4 @@ - 所有者作用域内持久化的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合了内置运行时所需的本地 PTY、文件系统意图策略、会话沙箱策略与 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置;[极简 Python SDK 教程](../../docs/user/guide/python-sdk-minimal.md)介绍设置方式、可重复运行与安全边界。 +它组合了内置运行时所需的本地 PTY、文件系统意图策略、会话沙箱策略与 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置;[Python SDK 教程](../../docs/user/guide/python-sdk.md)以此配置介绍设置方式、会话管理与安全边界。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 08da23d879..895fea6cfc 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: 2350fbfd5dd0094bfd7a11f81d8226e960879923 -README.zh.md: 5120a8c5f65360485d450614186d5cb2702fda17 +README.md: 9640c7e8dfd011b94acdc781ae0e4fdc8ad87378 +README.zh.md: 47ac04f9083ef41e23fda8ec527c1da160fe4769 diff --git a/python/sdk/README.md b/python/sdk/README.md index 2350fbfd5d..9640c7e8df 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -40,7 +40,7 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -The [minimal-agent tutorial](../../docs/user/guide/python-sdk-minimal.md) provides a complete standalone Cordis file and runnable SDK example for using the two-tool minimal mode without the Web UI. +The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses a complete standalone Cordis file to demonstrate installation, direct SDK usage, and runs without the Web UI. `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, events, notifications, session_root)`. The result has no prompt-level status or turn reason: `final_response` is the last committed root-session assistant text in the interval, not an output causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 5120a8c5f6..47ac04f908 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -37,7 +37,7 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -[极简 agent 教程](../../docs/user/guide/python-sdk-minimal.md)提供完整的独立 Cordis 文件与可运行的 SDK 示例,用于在不使用 Web UI 的情况下使用双工具极简模式。 +[Python SDK 教程](../../docs/user/guide/python-sdk.md)使用完整的独立 Cordis 文件演示安装方式、直接调用 SDK,以及在不使用 Web UI 的情况下运行 agent。 `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, events, notifications, session_root)`。结果不携带提示词级状态或轮次原因:`final_response` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的输出。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 diff --git a/website/docs.ts b/website/docs.ts index 365df21571..0019fd3a28 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -139,9 +139,9 @@ const homeAndGuide = pairedPages([ order: 3, }, { - source: 'docs/user/guide/python-sdk-minimal.md', - route: 'guide/python-sdk-minimal.md', - label: { root: 'Python SDK 极简模式', en: 'Minimal mode with Python' }, + source: 'docs/user/guide/python-sdk.md', + route: 'guide/python-sdk.md', + label: { root: 'Python SDK', en: 'Python SDK' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 4, From eacd5e216798f0e69eec82226e28a50a1c83de24 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 20:09:06 +0800 Subject: [PATCH 025/105] fix(prompt): remove unreachable complete branches --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 ++-- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- .../agent-presets/minimal/agent.cordis.yml | 7 +++--- packages/core/system-prompt/src/index.ts | 24 +++++++++---------- packages/preset/persona/src/index.ts | 2 +- 6 files changed, 19 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index fbe84849b4..8753c066cf 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.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-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 2375ad7e40afb096d7e1bbec4de023433de1e012 -2026-07-29-persistent-bash-str-replace-editor.zh.md: fcabc4bd342224a8b2d024a48901af284b4c6d2e +2026-07-29-persistent-bash-str-replace-editor.md: 2c077a08e6027245779a0db364c83d17a9c74fce +2026-07-29-persistent-bash-str-replace-editor.zh.md: f642f2100cbc40ddf688400c5e6124ca9a6ff72d diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 2375ad7e40..2c077a08e6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. -The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes native presentation and the complete system prompt, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. +The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index fcabc4bd34..f642f2100c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,7 +18,7 @@ Status: implemented 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 -随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定原生呈现和完整系统提示词,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)负责说明。 +随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)负责说明。 ## 考虑过的替代方案 diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 44d1bb45df..b03b689445 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -41,15 +41,14 @@ * Please avoid commands that may produce a very large amount of output. * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. -# Absolute paths are unconditional in the current editor; the legacy -# `requireAbsolutePath` switch is no longer a configuration field. +# The editor requires absolute paths unconditionally. - id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: maxOutputChars: 16000 -# RL core's fixed 128K window now comes from the routed model metadata rather -# than compact-basic config. Its remaining policy is preserved explicitly. +# Model capacity comes from routed model metadata; this block states the +# compaction policy explicitly. - id: compaction name: cordis:group group: true diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 22bcd1aa9d..afede84e50 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -478,23 +478,21 @@ export class SystemPrompt extends Service { collected.push(...schemas) for (const name of acceptedKnownNames) knownNames.add(name) } - const completeSections = [...sectionByName.values()].filter(section => section.complete === true) + const sectionDefinitions = [...sectionByName.values()].sort((a, b) => a.order - b.order) + const completeSections = sectionDefinitions.filter(section => section.complete === true) if (completeSections.length > 1) { throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`) } - const sections = [...sectionByName.values()] - .sort((a, b) => a.order - b.order) - .map(section => ({ - name: section.name, - text: typeof section.text === 'function' ? section.text(context) : section.text, - })) - const completeName = completeSections[0]?.name let completeSection: AssembledSection | undefined - if (completeName !== undefined) { - const assembled = sections.find(section => section.name === completeName) - if (assembled === undefined) throw new Error(`complete prompt section ${JSON.stringify(completeName)} did not assemble`) - completeSection = { ...assembled } - } + const sections = sectionDefinitions + .map((section) => { + const assembled = { + name: section.name, + text: typeof section.text === 'function' ? section.text(context) : section.text, + } + if (section.complete === true) completeSection = { ...assembled } + return assembled + }) const assembly: PromptAssembly = { sections, contexts: [...contextByName.values()] diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts index a76238033d..027aa89d66 100644 --- a/packages/preset/persona/src/index.ts +++ b/packages/preset/persona/src/index.ts @@ -59,6 +59,6 @@ export function apply(ctx: Context, config: Config): void { name: PERSONA_SECTION, order: PERSONA_ORDER, text: config.text, - complete: config.complete ?? false, + ...(config.complete ? { complete: true } : {}), }), 'persona.section()') } From 1478d3f806856b47f39b9e49c20f913a4c119605 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 20:14:16 +0800 Subject: [PATCH 026/105] test(web): refresh subagent conversation goldens for the pinned-approval Custom chip The delegation-pinned approval/policy: never makes a child session's knobs match no preset, so the child conversation's Access chip truthfully reads Custom. --- apps/web/tests/snapshots/subagent-conversation/ui.expected.md | 2 +- .../snapshots/subagent-interrupt/offline-composer.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 27c7ec092e..d581a7ab1f 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -41,7 +41,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Workspace Write"': Workspace Write +- 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] - text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index a977afbbea..529e6bb43d 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -18,6 +18,6 @@ - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] - button "Commands" [disabled]: - img -- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write +- 'button "Access mode, current: Custom" [disabled]': Custom - button "Stop generating" - button "Send message" [disabled] From 62f4da95f50ade285de19dcb009b21fbbb48b129 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 20:14:39 +0800 Subject: [PATCH 027/105] test(preset): assert minimal compaction policy --- apps/cli/tests/web-agent-presets.e2e.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 4a91016bf7..375ccfb3e1 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -12,6 +12,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' import { CallId } from '@deepseek-ai/dsh-llm' +import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' @@ -166,6 +167,16 @@ describe('the shipped Web composition', () => { expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters)) .toContain('Absolute path') + const compact = ctx.agentPresets.serviceFor(handle.agent, 'compact') + expect(compact).toBeDefined() + expect((compact as BasicCompactService).config).toMatchObject({ + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationProvider: '', + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, + }) } finally { await handle.dispose() } @@ -355,18 +366,6 @@ describe('the shipped Web composition', () => { expect(await readFile(path, 'utf8')).toBe(before) }) - it('gives each session its own complete persona', async () => { - const handle = await ctx.agents.create({ - sessionId: SessionId('preset-persona'), - setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), - }) - try { - const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) - expect(assembly.sections).toEqual([{ name: 'deployment:persona', text: MINIMAL_PROMPT }]) - } finally { - await handle.dispose() - } - }) }) describe('a switch survives the session', () => { From 43f3324a7beaa7ef3de8e7fa86fdb3ff3841febc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 20:34:45 +0800 Subject: [PATCH 028/105] fix(tools): restrict what a scope inherits, not just the global layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restriction was compiled against the global tool layer alone: only global-layer tools were tested against `admits()`, and every chain-layer tool was overlaid unfiltered afterward. That read the exempt set as "the global layer" when what it means is "what this scope registers itself" — two descriptions of the same set only while every model-facing tool sat in the host composition. Moving those rows onto the agent plane separated them. A preset's tools are an ANCESTOR contribution to a joined agent, so a subagent's `toolFilter` stopped constraining anything it was given; and with the global layer empty `restrict()` rejected every name it received as unknown, failing the child outright. With the same tools in the global layer the filter still admits and applies normally, which is what makes this a regression of the move rather than a standing limitation. `view()` now filters everything a scope inherits — the global layer and every ancestor layer on its chain — and exempts only the layer the scope owns. That exemption is load-bearing rather than incidental: the delegation runtime registers a child's `report` and structured-output tools into the child's own layer, and a filter naming the capabilities the child may use must not strip the machinery it answers through. Tool order, and with it prefix-cache reuse, is unchanged: inherited names keep their global-then- ancestor position and own-layer names still come last. The diagnostic said "unknown global tool" while listing what is really the inherited surface; it now names the surface it checks and says why an own-layer name is not restrictable. Fixes #2185 --- ...-agents-join-their-parent-preset.i18n.yaml | 4 +- ...0-child-agents-join-their-parent-preset.md | 12 ++- ...hild-agents-join-their-parent-preset.zh.md | 12 ++- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 15 ++-- docs/subsystems/tools.zh.md | 15 ++-- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/index.ts | 59 ++++++++++----- packages/core/tools/tests/scoped.spec.ts | 75 +++++++++++++++++-- .../tests/preset-inheritance.spec.ts | 15 ++++ .../tests/subagent-inprocess.spec.ts | 2 +- .../tests/subagent-spawn.spec.ts | 2 +- 14 files changed, 170 insertions(+), 53 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml index 34697cd123..351632fcc5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.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/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md -2026-08-10-child-agents-join-their-parent-preset.md: d9aa0dc43c1338d3198f5335da6ad238730d58a1 -2026-08-10-child-agents-join-their-parent-preset.zh.md: dd85c642ff7e6e2934e805c2efaccdc6dda63f15 +2026-08-10-child-agents-join-their-parent-preset.md: 4534004ad54df69822872b9595a29443fc3a990b +2026-08-10-child-agents-join-their-parent-preset.zh.md: bdf9928bea4b75e2915c8adf5c15f8a01c6583e4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md index d9aa0dc43c..4534004ad5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -22,6 +22,8 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge `dsh-subagent` reaches the roster through `ctx.get('agentPresets')` with a type-only import and an optional peer dependency — the documented opportunistic-consumption pattern it already uses for `sandboxPolicy` and `approval`. +Giving the child its parent's tools exposed a second defect the same agent-plane move introduced: `ToolRegistry` exempted SCOPED registrations from a restriction and filtered only the global layer, so once every model-facing row became an ancestor contribution, a child's `toolFilter` stopped constraining anything — and, with the global layer empty, `restrict()` rejected every name it was given as unknown, failing the child outright. The exempt set is the tools a scope registers ITSELF, not the tools that happen to live in the global layer; reading it the second way held only while those two sets coincided. `view()` now filters everything a scope inherits — the global layer and every ancestor layer — and exempts only its own. The own-layer exemption is load-bearing rather than incidental: the delegation runtime registers a child's `report` and structured-output tools into the child's own layer, and a filter naming the capabilities the child may use must not strip the machinery it answers through. + ## Alternatives considered **Re-mount the parent's preset by id in the child's setup.** Rejected on both semantics and mechanics. It re-reads the roster and re-stats the composition file, so an edit since the parent started forks the child onto a different generation, and a preset deleted since fails the child while its parent runs on. `mount()` is also asynchronous, which the synchronous creation windows cannot accept without restructuring both drivers. @@ -32,22 +34,26 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge **Let `dsh-subagent` import `resolveSessionPreset` and mount by the resolved id.** Rejected because it makes the preset roster a hard module edge for a package that must work without one, and it lands back on the remount semantics above. +**Filter every layer on the chain, including the scope's own.** Rejected because it makes a per-child capability filter delete that child's reporting and structured-output tools, which the delegation runtime registers into the child's own layer — an `allow` naming the capabilities a child may use would leave it unable to answer at all. + **Leave the durable header alone and fix only the live join.** Rejected because the live child and the same child read cold would then disagree about which composition produced its history — the same class of defect, moved rather than fixed. ## Testing `packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. +`packages/core/tools/tests/scoped.spec.ts` covers the restriction rule directly: a child's filter removes a tool it inherited from an ancestor scope, the child's own registrations survive its own filter, and an ancestor's restriction still reaches every scope nested inside it. + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, a `toolFilter` applied over the inherited preset tools, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. The assembled-transcript layer is the shipped Web composition's e2e rather than a keyless snapshot. Every runnable example this repo ships composes no preset roster, so the defect is not observable in the snapshot harness at all: a snapshot scenario would first need an example that mounts a roster AND delegates. The Web e2e boots the real `base` + `web-app` patch layers with both shipped presets, which is the assembled evidence the testing policy asks for; the Web browser lane's subagent goldens carry the visible consequence, since a child that records its preset now shows the preset badge its parent shows. ## Consequences -Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's — the per-child `toolFilter` does not narrow them, for the separately tracked reason below; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. +Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. `applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error. A cold-resumed continuable child joins its parent's CURRENT composition rather than the one its own header records. The window is narrow — the parent must create the child, stay blank, switch preset, and only then wake it, since a resident child never re-joins and a one-shot child never resumes — and the alternative is worse: resolving the child's own recorded id would re-read the roster and hand back the preset-deleted failure mode this join exists to avoid. The child's header still records what it started under, so the divergence is observable rather than silent. -`toolFilter` does not constrain a joined child, because `ToolRegistry` compiles restrictions against global-layer names only and overlays chain-layer tools unfiltered. That is not new here — with the roster composed, `tools.restrict()` already rejected every name as an unknown global tool, so a child carrying a filter failed to start both before and after this change — but it is a regression from the agent-plane move rather than a standing limitation: with the same tools registered in the global layer, the filter admits and applies normally. It matters more now that the child has its parent's full tool set to be restricted from. It is tracked separately; this change neither introduces nor repairs it. +`ToolRegistry` now reads a restriction's exempt set as "what this scope registers itself" rather than "the global layer", which changes one documented behavior beyond delegation: a tool an ANCESTOR scope contributes is now subject to a descendant's filter, where before only global-layer tools were. Nothing else on the chain loses its exemption — a scope's own registrations stay outside its own filter, which is the property the delegation runtime depends on. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md index dd85c642ff..bdf9928bea 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -22,6 +22,8 @@ Status: implemented `dsh-subagent` 以类型级导入加可选 peer 依赖的方式,通过 `ctx.get('agentPresets')` 触达 roster——这正是它对 `sandboxPolicy` 与 `approval` 已在使用的、有明确文档的机会性消费模式。 +把父方的工具交给子 agent 之后,暴露出同一次 agent 平面搬迁引入的第二个缺陷:`ToolRegistry` 把**作用域级**注册排除在限制之外、只过滤全局层,因此当所有面向模型的行都变成祖先贡献之后,子 agent 的 `toolFilter` 就不再约束任何东西——而且全局层为空时,`restrict()` 会把收到的每个名字都判为未知并直接让子 agent 创建失败。豁免集合应当是作用域**自己注册**的工具,而不是恰好位于全局层的工具;后一种读法只在这两个集合重合时才成立。`view()` 现在过滤作用域继承来的一切——全局层与每个祖先层——只豁免它自己那层。这条自身层豁免是承重的而非顺带的:委派运行时把子 agent 的 `report` 与结构化输出工具注册进子 agent 自己那层,而一个只点名子 agent 可用能力的过滤器绝不能把它回报所依赖的机制一并剥掉。 + ## Alternatives considered **在子 agent 的 setup 里按 id 重新挂载父方的 preset。** 语义与机制两方面都不成立而被否决。它会重读 roster 并重新 stat 组装文件,因此父方启动后的一次编辑就会把子 agent 分叉到另一个代际,而此后被删除的 preset 会让子 agent 失败、父方却照常运行。`mount()` 还是异步的,同步的创建窗口无法在不重构两个驱动的前提下接受它。 @@ -32,22 +34,26 @@ Status: implemented **让 `dsh-subagent` 导入 `resolveSessionPreset` 并按解析出的 id 挂载。** 否决,因为这会给一个必须在没有 roster 时也能工作的包引入硬模块边,而且最终仍落回上述的重新挂载语义。 +**过滤链上的每一层,包括作用域自身那层。** 否决,因为那会让逐子 agent 的能力过滤器把该子 agent 的回报与结构化输出工具一并删掉——它们由委派运行时注册进子 agent 自己那层——于是一个点名"子 agent 可用哪些能力"的 `allow` 会让它彻底无法回报。 + **只修活着的加入,不动持久化 header。** 否决,因为那样活着的子 agent 与冷读同一个子 agent 会对"哪份组装产出了这段历史"给出不同答案——同一类缺陷,只是被搬了个地方而不是被修掉。 ## Testing `packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 +`packages/core/tools/tests/scoped.spec.ts` 直接覆盖该限制规则:子 agent 的过滤器能移除它从祖先作用域继承来的工具、子 agent 自身的注册在自己的过滤器下存活、祖先的限制仍作用于其内嵌套的每个作用域。 + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset、施加在继承来的 preset 工具之上的 `toolFilter`,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 组装记录这一层用的是真实 shipped Web 组装的 e2e,而不是无密钥快照。本仓库所有可运行 example 都不组装 preset roster,因此该缺陷在快照 harness 里根本不可观察:要做快照场景,得先有一个既挂载 roster 又发起委派的 example。Web e2e 启动的是真实的 `base` + `web-app` 补丁层与两个 shipped preset,这正是测试政策要求的组装证据;Web 浏览器 lane 的 subagent golden 承载了可见后果——记录了 preset 的子 agent 现在会显示与其父方相同的 preset 徽标。 ## Consequences -委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力——逐子 agent 的 `toolFilter` 并不能收窄它,原因见下方另行跟踪的那条;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 +委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己的 `toolFilter` 所移除的部分;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 `applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。 冷恢复的可继续子 agent 加入的是父方**当前**的组装,而不是它自己 header 所记录的那份。窗口很窄——父方必须先建子、保持空白、切换 preset,之后才唤醒它;驻留中的子 agent 不会重新加入,一次性子 agent 也不会恢复——而替代方案更糟:按子 agent 自己记录的 id 解析会重读 roster,把这次认父刻意规避掉的"preset 已删除"失败模式又请回来。子 agent 的 header 仍记录它启动时的那份,因此这处分歧是可观察的而非静默的。 -`toolFilter` 约束不住已加入组装的子 agent,因为 `ToolRegistry` 只按全局层的名字编译限制,随后把 scope 链上的工具无过滤地叠加进来。这不是本次改动带来的——在组装了 roster 的部署里,`tools.restrict()` 本就把每个名字都判为未知全局工具,因此带过滤器的子 agent 在本次改动前后同样起不来——但它是搬到 agent 平面所引入的回归,而非长期存在的限制:同样这批工具注册在全局层时,过滤器能正常校验并生效。现在子 agent 有了父方的全套工具需要被限制,它变得更要紧。该问题另行跟踪;本次改动既未引入也未修复它。 +`ToolRegistry` 现在把限制的豁免集合读作"该作用域自己注册的东西"而不是"全局层",这在委派之外改变了一处既有行为:**祖先**作用域贡献的工具现在会受后代过滤器约束,而此前只有全局层的工具会。链上其余部分的豁免不变——作用域自身的注册仍在自己的过滤器之外,这正是委派运行时所依赖的性质。 diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 3f7e33a795..fbf617f20a 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.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 docs/subsystems/tools.md -tools.md: f8d86704a2237219530c8c23b46a68383458e1cf -tools.zh.md: 87269e5532b0cdfb0a38501d7b98c9986665df1d +tools.md: 6ff2d967c5631d096dd78236ffd0383ddb2b0493 +tools.zh.md: 82ade5d8d4117387138296cf54fbc8e88ad335e7 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index f8d86704a2..6ff2d967c5 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -150,19 +150,20 @@ type InferArgs = InferProperties Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` constructs the model-facing projection when building a request, so execution and presentation share one resolved definition without leaking callbacks onto the wire. -## `ToolRestriction` — one scope's live global filter +## `ToolRestriction` — one scope's live filter over what it inherits -`ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them. +`ToolRestriction` applies to the tools a scope inherits: the deployment-global layer plus every ancestor scope on its chain. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays the scope's OWN registrations, which stay exempt so a delegated child keeps the tools it answers through. A deny-only filter admits later unlisted inherited tools, while an allow-list excludes them. ```ts type-equiv /** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * Per-scope filter over the tools a scope INHERITS — the global layer and + * every ancestor layer on its chain. Restrictions intersect, and do not affect + * the scope's own registrations or the reserved Code Mode transport. */ interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ + /** Inherited tool names that stay visible; every other inherited one is removed. */ readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ + /** Inherited tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -565,7 +566,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 87269e5532..82ade5d8d4 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -150,19 +150,20 @@ type InferArgs = InferProperties 注册是一项受信任的同进程约定。注册表以 readonly 输入借用已类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在构建请求时生成面向模型的投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 -## `ToolRestriction` — 单个作用域的实时全局过滤器 +## `ToolRestriction` — 单个作用域对其继承内容的实时过滤器 -`ToolRestriction` 仅作用于实时的部署全局工具层。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加作用域本地工具。仅 deny 的过滤器允许后续未列出的全局工具通过,而 allow 列表则排除它们。 +`ToolRestriction` 作用于该作用域继承来的工具:部署全局层,加上其链上的每个祖先作用域。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加该作用域**自身**的注册——后者不受约束,因此被委派的子 agent 会保留其回报所依赖的工具。仅 deny 的过滤器允许后续未列出的继承工具通过,而 allow 列表则排除它们。 ```ts type-equiv /** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * Per-scope filter over the tools a scope INHERITS — the global layer and + * every ancestor layer on its chain. Restrictions intersect, and do not affect + * the scope's own registrations or the reserved Code Mode transport. */ interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ + /** Inherited tool names that stay visible; every other inherited one is removed. */ readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ + /** Inherited tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -565,7 +566,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 0c5e1fee44..e3a9b36a95 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: 2c9833c3505c765283559590c8bc28b3c2077e2e -README.zh.md: d7766b432c5a319d214da80e3df438489519be92 +README.md: 21851ca887147364c76612bae2e6a00ebdccec39 +README.zh.md: aec3b434e52f473001505bbea5212d5e247eb46f diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 2c9833c350..21851ca887 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -19,7 +19,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber. - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber. -- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). +- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d7766b432c..aec3b434e5 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -19,7 +19,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。 - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。 -- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 +- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 - `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index a1653e7d16..86c69d9307 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -647,13 +647,14 @@ export interface Config { } /** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * Per-scope filter over the tools a scope INHERITS — the global layer and + * every ancestor layer on its chain. Restrictions intersect, and do not affect + * the scope's own registrations or the reserved Code Mode transport. */ export interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ + /** Inherited tool names that stay visible; every other inherited one is removed. */ readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ + /** Inherited tool names removed from visibility. */ readonly deny?: readonly string[] } @@ -669,7 +670,7 @@ interface ToolView { readonly visible: ReadonlyMap /** Pre-restriction capability names used by prompt-order validation. */ readonly knownNames: ReadonlySet - /** Current global names that a scoped restriction may name. */ + /** Current inherited names a scoped restriction may name; its own are exempt. */ readonly restrictableNames: ReadonlySet } @@ -707,7 +708,7 @@ class ToolLayer implements ScopeLayer { && this.mode === undefined } - /** Whether every compiled restriction in this layer admits a global tool name. */ + /** Whether every compiled restriction in this layer admits an inherited tool name. */ admits(name: string): boolean { for (const filter of this.restrictions.values()) { if ((filter.allow !== undefined && !filter.allow.has(name)) @@ -1029,7 +1030,7 @@ export class ToolRegistry extends Service { const known = this.view(scope).restrictableNames const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name)) if (unknown.length > 0) { - throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`) + throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`) } return this.layers.effect( this.ctx, @@ -1070,30 +1071,54 @@ export class ToolRegistry extends Service { /** * Resolve every registry fact one scope needs in one layer traversal. The - * visible map applies global restrictions, scoped shadowing, and the reserved - * presentation transport; the other sets retain the pre-restriction facts - * needed by restriction and prompt-order validation. + * visible map applies restrictions to the INHERITED surface, then the + * scope's own registrations and the reserved presentation transport; the + * other sets retain the pre-restriction facts needed by restriction and + * prompt-order validation. + * + * A restriction filters what a scope inherits — the global layer and every + * ancestor layer on its chain — and never what its OWN layer registers. + * That exemption is what a per-child capability filter has to keep intact: + * the delegation runtime registers a child's reporting and structured-output + * tools into the child's own layer, and a filter naming the capabilities the + * child may use must not strip the machinery it answers through. + * + * Reading the exempt set as "the global layer" instead of "not mine" held + * only while every model-facing tool sat in the host composition. Once + * presets moved them onto the agent plane they became an ANCESTOR + * contribution, so a child's filter silently stopped constraining anything + * it was given. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { // Scope-chain layers, farthest ancestor first, the exact scope last. const layers = this.layers.chainLayers(scope) + // Chain-blind on purpose: this is the ONE layer whose registrations the + // scope owns rather than inherits, and it is absent until the scope + // contributes something. + const own = this.layers.peek(scope) + // Inherited surface, nearest ancestor last: a nearer scope's same-name + // entry shadows a farther one, and the global layer is the farthest. + const inherited = new Map(this.layers.global.tools.entries()) + for (const layer of layers) { + if (layer === own) continue + for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition) + } const visible = new Map() const knownNames = new Set() const restrictableNames = new Set() - for (const [name, definition] of this.layers.global.tools.entries()) { + for (const [name, definition] of inherited) { knownNames.add(name) restrictableNames.add(name) // Restrictions intersect across the whole chain: any scope on it may - // mask a global-surface name for everything nested inside it. + // mask an inherited name for everything nested inside it. if (layers.every(layer => layer.admits(name))) visible.set(name, definition) } - // Chain layers second, nearest last: same-name entries REPLACE (shadow) - // the global and farther-scope ones, and scope-local registrations are - // never part of the global filter above. - for (const layer of layers) { - for (const [name, definition] of layer.tools.entries()) { + // The scope's own registrations last, shadowing an inherited name and + // outside the filter above. + if (own !== undefined) { + for (const [name, definition] of own.tools.entries()) { knownNames.add(name) visible.set(name, definition) } diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 922173653f..8f7be45b19 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import type { Events } from 'cordis' -import { createScope } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -181,21 +181,84 @@ describe('restrict()', () => { expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b']) }) - it('fails loud on an unscoped call, an empty filter, and non-global names', async () => { + it('fails loud on an unscoped call, an empty filter, and names it does not inherit', async () => { const ctx = await mount() const { scope } = await mintAgentScope(ctx, 'a') ctx.tools.register(tool('real')) scope.ctx.tools.register(tool('local')) expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/) expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/) - expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/) - expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/) - expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/) + // A scope's own registration is exempt from its own filter, so naming it + // is a caller error rather than a silent no-op. + expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/) + expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s) + expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/) const emptyCtx = await mount() const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty') expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] })) - .toThrow(/known global tools: \(none\)/) + .toThrow(/Restrictable tools: \(none\)/) + }) +}) + +describe('restrict() over an inherited scope layer', () => { + /** Mint a child scope parented to `parent`, as a subagent's creation window does. */ + async function mintChild(ctx: Context, parentKey: Agent, name: string): Promise<{ scope: Scope; key: Agent }> { + const key = { id: name as SessionId } as Agent + bindScopeParent(key, parentKey) + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) }, + { inject: ['tools', 'systemPrompt'] })) + return { scope, key } + } + + it('filters tools the child inherits from an ancestor scope, not only global ones', async () => { + // The shape every preset deployment has: no model-facing row in the global + // layer, all of them contributed by an ancestor scope the child joined. + const ctx = await mount() + const parent = await mintAgentScope(ctx, 'parent') + parent.scope.ctx.tools.register(tool('bash')) + parent.scope.ctx.tools.register(tool('read')) + const child = await mintChild(ctx, parent.key, 'child') + + expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['bash', 'read']) + child.scope.ctx.tools.restrict({ deny: ['bash'] }) + + // Reading the exempt set as "the global layer" left this unfiltered, and + // the name unrestrictable in the first place. + expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['read']) + expect(await run(ctx, 'bash', child.key)).toBe('Error: unknown tool "bash"') + // The ancestor keeps its whole surface: a child's filter is its own. + expect(ctx.tools.schemas(parent.key).map(t => t.name).sort()).toEqual(['bash', 'read']) + }) + + it('keeps the child\'s own registrations outside its own filter', async () => { + // The delegation runtime registers a child's reporting and structured + // output tools into the child's own layer; an `allow` naming only the + // capabilities the child may use must not strip them. + const ctx = await mount() + const parent = await mintAgentScope(ctx, 'parent') + parent.scope.ctx.tools.register(tool('bash')) + parent.scope.ctx.tools.register(tool('read')) + const child = await mintChild(ctx, parent.key, 'child') + child.scope.ctx.tools.register(tool('report')) + + child.scope.ctx.tools.restrict({ allow: ['read'] }) + + expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['read', 'report']) + expect(await run(ctx, 'report', child.key)).toBe('ran:report') + }) + + it('lets an ancestor\'s restriction reach every scope nested inside it', async () => { + const ctx = await mount() + ctx.tools.register(tool('web')) + const parent = await mintAgentScope(ctx, 'parent') + parent.scope.ctx.tools.register(tool('bash')) + const child = await mintChild(ctx, parent.key, 'child') + parent.scope.ctx.tools.restrict({ deny: ['web'] }) + + expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['bash']) + expect(ctx.tools.schemas(parent.key).map(t => t.name)).toEqual(['bash']) }) }) diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 43061d46db..af199cd867 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -103,6 +103,21 @@ describe('a child agent composed in-process', () => { await run.dispose() }) + it('honours a tool filter over the preset tools it inherited', async () => { + const { ctx, parent } = await setupPresetHost() + + const run = await startInProcessRun( + { ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } }, + {}, + ) + await run.result + + // The capability filter is the only thing bounding a delegated child, and + // every tool it can name now arrives from the preset rather than the host. + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([]) + await run.dispose() + }) + it('follows a parent that switched preset while blank', async () => { const { ctx, parent } = await setupPresetHost() // A DIFFERENT preset, so the assertion below distinguishes reading the diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index d175322381..46b94f9f5a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -298,7 +298,7 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), toolFilter: { deny: ['unknown-tool'] }, - }, {})).rejects.toThrow('unknown global tool') + }, {})).rejects.toThrow('unknown inherited tool') expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 0310c0b0b3..508d5132fb 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => { prompt: [{ type: 'text', text: 'do X' }], parent, toolFilter: { deny: ['no_such_tool'] }, - })).rejects.toThrow(/unknown global tool "no_such_tool"/) + })).rejects.toThrow(/unknown inherited tool "no_such_tool"/) expect(ctx.agents.list().length).toBe(before) }) }) From 44816376847aed2a57b79f544a14f28b84ab6216 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 20:55:02 +0800 Subject: [PATCH 029/105] fix(python): package the minimal runtime closure --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 6 +- ...-executable-sdk-runtime-distribution.zh.md | 6 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 6 +- packages/boot/app-boot/README.zh.md | 6 +- packages/boot/app-boot/src/index.ts | 49 +- packages/boot/app-boot/tests/app-boot.spec.ts | 47 + packages/examples/jsonrpc-demo/src/bin.ts | 4 +- .../sandbox/sandbox-windows-acl/package.json | 1 + pnpm-lock.yaml | 3 + pnpm-workspace.yaml | 2 +- python/sdk-runtime/package.json | 1 + scripts/build-exe-for-python-sdk.ts | 49 +- scripts/check-workspace-constraints.ts | 5 +- scripts/smoke-python-runtime.py | 179 ++- .../advanced/result.json | 1016 +++++++++++------ .../advanced/session.1.jsonl | 32 +- .../advanced/session.2.jsonl | 32 +- .../advanced/session.jsonl | 135 +-- 20 files changed, 1008 insertions(+), 579 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index c1ab35fa0b..ceb0eae37a 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: fd232e8893b7beebe2e279cb5532daf8ef73a8a3 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: bb0b6f8f660a42495da651a581236a7ce2a50773 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 826194e0d5bd1f0260400c036f8affaf1549629f +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: e4b17a1f3951f36af88564d5365ab7952d6281a5 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index fd232e8893..826194e0d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -34,13 +34,13 @@ Config discovery has two channels and fails loudly when both are missing: the `D ### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root -Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. +Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The JSON-RPC bin supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. Bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local symlink tree and rejecting any remaining manifest gap → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink package payload (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. @@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index bb0b6f8f66..e4b17a1f39 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -34,13 +34,13 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 ### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录 -exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 +exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。JSON-RPC bin 会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内符号链接树,并拒绝剩余的 manifest 缺口 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的包载荷(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 @@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index cec63092de..a55e250b6d 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: be03bceb39935fafb7acc7d3a99c1fe3af686f94 -README.zh.md: 10165486712fc078cdf1f4147522397a15c88955 +README.md: f3ffdae3846edba6f1a1a4821adade7b6c7fce76 +README.zh.md: 4f31fd743f1ddc57edc9c215a42e79a16afcdecb diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index be03bceb39..f3ffdae384 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -15,10 +15,10 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | +| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR; an optional module base anchors bare package names to the installed host while relative names stay config-relative | | `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | +| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error; the optional module base has the same resolution semantics as `mountRootInclude` | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | @@ -29,7 +29,7 @@ The Loader mounts entries concurrently, so a surface can already own the termina `cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 1016548671..4f31fd743f 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -15,10 +15,10 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项;可选模块基准会把裸包名锚定到已安装宿主,而相对名称仍以配置目录为基准 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | +| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject;可选模块基准与 `mountRootInclude` 的解析语义相同 | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`)离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML,并原样保留 `!!js` 表达式;每段来源于同一文件且由相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取、解析或字段验证失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | @@ -29,7 +29,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 `cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index fa23e6f8da..40e80ce504 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -9,7 +9,7 @@ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { parseEnv } from 'node:util' -import { basename, dirname, resolve } from 'node:path' +import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' @@ -476,6 +476,8 @@ function groupedDump( * @param ctx - context carrying an initialized Loader service. * @param absoluteConfigPath - absolute YAML or JSON configuration path. * @param patches - initial app and user patches, applied in order. + * @param bareModuleBaseUrl - optional installed-host base for bare package + * names; relative names continue to resolve beside the configuration file. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the * transactional create was still settling entry lifecycle. @@ -484,8 +486,21 @@ export async function mountRootInclude( ctx: Context, absoluteConfigPath: string, patches: readonly PatchOptions[] = [], + bareModuleBaseUrl?: string, ): Promise { - ctx.loader.builtins.include = Include + ctx.loader.builtins.include = bareModuleBaseUrl === undefined + ? Include + : class HostResolvedRootInclude extends Include { + override import(name: string, getOuterStack?: () => string[]): unknown { + const specifier = isAbsolute(name) ? pathToFileURL(name).href : name + if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(specifier, getOuterStack) + const internal = this.ctx.loader.internal + /* v8 ignore next -- Node supplies the internal loader; this preserves the + original diagnostic for hypothetical embedders without it. */ + if (internal === undefined) return super.import(specifier, getOuterStack) + return internal.import(specifier, bareModuleBaseUrl, {}) + } + } // `cordis:group` alongside it: a group row is how a composition gives one // `isolate` realm to a provider and its consumers together, and an agent // preset living outside this workspace cannot resolve `@cordisjs/plugin-group` @@ -495,13 +510,14 @@ export async function mountRootInclude( // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). + const includeConfig: Include.Config = { + path: pathToFileURL(absoluteConfigPath).href, + ...patches.length > 0 ? { patches: [...patches] } : {}, + } const rootInclude: EntryOptions = { id: 'include', name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches.length > 0 ? { patches: [...patches] } : {}, - }, + config: includeConfig, } const includeId = await ctx.loader.create(rootInclude) const loader = ctx.get('loader') @@ -709,14 +725,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro /** * Boot the Loader against `absoluteConfigPath` and return only after the whole - * tree settles. Entry names load through the Loader's internal module loader - * against `baseUrl` (the config directory), which may live outside - * `node_modules` reach and, unbuilt, cannot load vendored source; the - * bootstrap include is therefore statically imported and mounted as the - * `cordis:include` builtin, loading through the ambient module pipeline - * (vite/tsx/plain ESM) while the included tree's own specifiers stay - * config-relative. The package build embeds Include while leaving Loader - * external, so the built include tree and host share one Loader peer. Loader + * tree settles. Relative entry names resolve against the config directory; + * bare package names resolve there by default or against an explicit + * `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include + * is statically imported and mounted as the `cordis:include` builtin, loading + * through the ambient module pipeline (vite/tsx/plain ESM). The package build + * embeds Include while leaving Loader external, so the built include tree and + * host share one Loader peer. Loader * settlement rejects startup failures, which `boot` wraps after disposing the * partial context; a missing fiber or never-activating entry is rejected by * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's @@ -729,6 +744,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param patches - optional overlay patches applied over the included tree * (see {@link loadOptionalPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. + * @param bareModuleBaseUrl - optional installed-host base for bare package + * names; use it when the host, rather than the configuration project, owns the + * complete plugin set. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. * @throws a labelled error after disposing the partial context — `host @@ -740,6 +758,7 @@ export async function boot( absoluteConfigPath: string, patches?: PatchOptions[], prepare?: (ctx: Context) => Promise | void, + bareModuleBaseUrl?: string, ): Promise { const ctx = new Context() // Two failure labels: `prepare` runs before any config-tree entry mounts, @@ -751,7 +770,7 @@ export async function boot( await ctx.plugin(Loader) await prepare?.(ctx) stage = 'plugin tree failed to load' - await mountRootInclude(ctx, absoluteConfigPath, patches) + await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl) // A surface can finish and dispose the whole tree while startup is still // in flight, before the last entry settles. The Loader service goes with // it, and the activation audit describes a live tree — reading `ctx.loader` diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index baeb98fe77..ab0089fd2c 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -557,6 +557,53 @@ describe('boot', () => { } }) + it('can resolve bare plugins from the harness when the config project shadows their package name', async () => { + const dir = tmp() + const absolutePlugin = join(dir, 'absolute.mjs') + const shadow = join(dir, 'node_modules', '@deepseek-ai', 'dsh-system-prompt') + mkdirSync(shadow, { recursive: true }) + writeFileSync(join(shadow, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-system-prompt', + type: 'module', + exports: './index.mjs', + })) + writeFileSync(join(shadow, 'index.mjs'), [ + 'export function apply(ctx) {', + ' ctx.provide("shadowPluginLoaded", true)', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n') + writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n') + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: prompt', + " name: '@deepseek-ai/dsh-system-prompt'", + '- id: relative', + " name: './relative.mjs'", + '- id: absolute', + ` name: ${JSON.stringify(absolutePlugin)}`, + '', + ].join('\n')) + const configOwned = await boot(NAME, join(dir, 'cordis.yml')) + try { + expect(configOwned.get('shadowPluginLoaded')).toBe(true) + expect(configOwned.get('systemPrompt')).toBeUndefined() + expect(configOwned.get('relativePluginLoaded')).toBe(true) + expect(configOwned.get('absolutePluginLoaded')).toBe(true) + } finally { + await configOwned.fiber.dispose() + } + const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, undefined, import.meta.url) + try { + expect(ctx.get('systemPrompt')).toBeDefined() + expect(ctx.get('shadowPluginLoaded')).toBeUndefined() + expect(ctx.get('relativePluginLoaded')).toBe(true) + expect(ctx.get('absolutePluginLoaded')).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + it('runs host preparation before the Loader tree mounts', async () => { const dir = tmp() writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') diff --git a/packages/examples/jsonrpc-demo/src/bin.ts b/packages/examples/jsonrpc-demo/src/bin.ts index ad17709efc..cecc93dac4 100644 --- a/packages/examples/jsonrpc-demo/src/bin.ts +++ b/packages/examples/jsonrpc-demo/src/bin.ts @@ -33,7 +33,9 @@ if (configPath === undefined || !existsSync(configPath)) { process.exit(1) } -const ctx = await boot(NAME, configPath) +// The executable owns a closed plugin set; config-adjacent node_modules must +// not shadow the packages embedded beside this bin in the VFS. +const ctx = await boot(NAME, configPath, undefined, undefined, import.meta.url) let exiting = false async function disposeAndExit(code: number): Promise { diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 2f13b71296..b5e66b1f33 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -26,6 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/runner.js", + "lib/types-*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ff65ff8cf..b58ed88a9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7642,6 +7642,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66510d89ec..2541e949b8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -48,7 +48,7 @@ allowBuilds: koffi: true # The Python runtime deploy includes the reviewed workspace postinstall that # restores the executable bit on node-pty's macOS spawn helper. - '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true + '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true minimumReleaseAgeExclude: # Cordis release candidates are source-vendored and pinned in vendor/README.md diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5e90fb1b9..0ff390dd33 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 536342dc89..5f525ec358 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process' import { existsSync, statSync } from 'node:fs' -import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -28,6 +28,8 @@ const OUT_DIR = 'dist-exe' const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' /** The deployed closure doubles as the node-mode carrier. */ const PYTHON_NODE_SUBDIR = 'node' +/** Legacy deploy may hoist peer-specialized workspace packages back here. */ +const DEPLOY_SOURCE_NODE_MODULES = 'python/sdk-runtime/node_modules' /** Documentation excluded from the generated runtime directory. */ const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] @@ -256,6 +258,7 @@ class SingleExeBuild { '--config.link-workspace-packages=true', this.staging, ]) + await this.restoreLegacyHoists() if (this.cli.dryRun) { for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) } else { @@ -263,6 +266,50 @@ class SingleExeBuild { } } + /** + * Restore direct packages that pnpm's legacy hoister places beside the deploy + * source instead of in the target. The runtime manifest supplies every peer, + * so package-local node_modules trees are omitted to preserve one flat Cordis + * instance and a symlink-free packaged payload. + */ + private async restoreLegacyHoists(): Promise { + if (this.cli.dryRun) { + console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy') + return + } + const manifestPath = join(this.staging, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + dependencies?: Record + } + const sourceNodeModules = resolve(root, DEPLOY_SOURCE_NODE_MODULES) + const restored: string[] = [] + for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) { + const destination = join(this.staging, 'node_modules', dependency) + if (existsSync(destination)) continue + const source = join(sourceNodeModules, dependency) + if (!existsSync(source)) { + throw new Error( + `build-exe-for-python-sdk: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`, + ) + } + await mkdir(dirname(destination), { recursive: true }) + const nestedNodeModules = join(source, 'node_modules') + await cp(source, destination, { + recursive: true, + filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), + }) + restored.push(dependency) + } + const stillMissing = Object.keys(manifest.dependencies ?? {}) + .filter(dependency => !existsSync(join(this.staging, 'node_modules', dependency))) + if (stillMissing.length > 0) { + throw new Error(`build-exe-for-python-sdk: staged dependencies remain missing: ${stillMissing.join(', ')}.`) + } + if (restored.length > 0) { + console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`) + } + } + /** Add the executable entry and pkg assets to the staged manifest. */ async injectPkgConfig(): Promise { const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index c38197c1e3..5f6770b2e1 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -127,8 +127,9 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], // The argv-prefix runner entry ships beside the lib as its own bundle; - // sandbox-local resolves it through the package's ./runner export. - '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'], + // sandbox-local resolves it through the package's ./runner export. tsdown + // also shares its generated FFI code through a hashed runtime chunk. + '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], '@deepseek-ai/dsh-scripts': [ diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 415784cd7a..910b4ffc0a 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: - from deepseek_harness import TurnResult + from deepseek_harness import RunResult EXPECTED_TEXT = "runtime smoke ok" @@ -25,10 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" -PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." -PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" -PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: " -PERSISTENT_BASH_COMMAND = ( +MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor." +MINIMAL_TEXT = "minimal agent smoke ok" +MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " +MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." +MINIMAL_CORDIS = ( + Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" +) +MINIMAL_BASH_COMMAND = ( "counter=$(( ${counter:-0} + 1 )); export counter; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" @@ -103,51 +107,6 @@ CUSTOM_CORDIS = """\ - id: cordis-tool name: '@deepseek-ai/dsh-tool-cordis' """ -PERSISTENT_TOOLS_CORDIS = """\ -- id: jsonrpc - name: '@deepseek-ai/dsh-jsonrpc' -- id: llm - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD -- id: pty - name: '@deepseek-ai/dsh-pty' -- id: pty-local - name: '@deepseek-ai/dsh-pty-local' -- id: fs - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - persona: 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolTasks: false -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT - compression: 'none' -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' -""" - - class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -182,9 +141,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) - persistent = persistent_tool_followup(body, call_id, tool_name, tool_text) - if persistent is not None: - return persistent + minimal = minimal_tool_followup(body, call_id, tool_name, tool_text) + if minimal is not None: + return minimal advanced = advanced_tool_followup(body, call_id, tool_name, tool_text) if advanced is not None: return advanced @@ -196,16 +155,35 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(WORKFLOW_WORKER_TEXT) raise AssertionError(f"unexpected tool follow-up: {tool_name}") - prompt = message_text(latest.get("content")) - if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"): + minimal_prompt = next( + ( + message_text(message.get("content")) + for message in reversed(messages) + if isinstance(message, dict) + and message.get("role") == "user" + and message_text(message.get("content")).startswith( + f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}" + ) + ), + None, + ) + if minimal_prompt is not None: names = advertised_tool_names(body) if names != {"bash", "str_replace_editor"}: - raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") + raise AssertionError(f"minimal agent smoke advertised unexpected tools: {names}") + system_prompts = [ + message_text(message.get("content")) + for message in messages + if isinstance(message, dict) and message.get("role") == "system" + ] + if system_prompts != [MINIMAL_SYSTEM_PROMPT]: + raise AssertionError(f"minimal agent smoke assembled unexpected system prompts: {system_prompts}") return tool_call_chunks( - "persistent-bash-1", + "minimal-bash-1", "bash", - {"command": PERSISTENT_BASH_COMMAND}, + {"command": MINIMAL_BASH_COMMAND}, ) + prompt = message_text(latest.get("content")) if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: return text_chunks("DIRECT_CHILD_OK") if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: @@ -240,24 +218,24 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(EXPECTED_TEXT) -def persistent_tool_followup( +def minimal_tool_followup( body: dict[str, object], call_id: str, tool_name: str, tool_text: str, ) -> list[dict[str, object]] | None: - """Verify packaged PTY persistence, then invoke the packaged editor.""" - if not call_id.startswith("persistent-"): + """Verify the checked-in minimal composition's PTY and editor.""" + if not call_id.startswith("minimal-"): return None - if call_id == "persistent-bash-1" and tool_name == "bash": + if call_id == "minimal-bash-1" and tool_name == "bash": if "COUNT=1" not in tool_text: raise AssertionError(f"first persistent bash call lost its output: {tool_text}") return tool_call_chunks( - "persistent-bash-2", + "minimal-bash-2", "bash", - {"command": PERSISTENT_BASH_COMMAND}, + {"command": MINIMAL_BASH_COMMAND}, ) - if call_id == "persistent-bash-2" and tool_name == "bash": + if call_id == "minimal-bash-2" and tool_name == "bash": if "COUNT=2 CWD=/tmp" not in tool_text: raise AssertionError(f"persistent bash did not retain state: {tool_text}") messages = body.get("messages") @@ -265,18 +243,18 @@ def persistent_tool_followup( raise AssertionError("persistent editor smoke request has no messages") editor_path = next( ( - text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip() + text.split(MINIMAL_EDITOR_PATH_PREFIX, 1)[1].strip() for message in messages if isinstance(message, dict) and message.get("role") == "user" for text in [message_text(message.get("content"))] - if PERSISTENT_EDITOR_PATH_PREFIX in text + if MINIMAL_EDITOR_PATH_PREFIX in text ), None, ) if editor_path is None: raise AssertionError("persistent editor smoke prompt has no editor path") return tool_call_chunks( - "persistent-editor", + "minimal-editor", "str_replace_editor", { "command": "create", @@ -284,11 +262,11 @@ def persistent_tool_followup( "file_text": "created by packaged editor\n", }, ) - if call_id == "persistent-editor" and tool_name == "str_replace_editor": + if call_id == "minimal-editor" and tool_name == "str_replace_editor": if "New file created successfully" not in tool_text: raise AssertionError(f"packaged editor did not create its file: {tool_text}") - return text_chunks(PERSISTENT_TOOLS_TEXT) - raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}") + return text_chunks(MINIMAL_TEXT) + raise AssertionError(f"unexpected minimal-agent follow-up: {call_id} {tool_name}: {tool_text}") def advanced_tool_followup( @@ -470,14 +448,14 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None: - parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None: + parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-snapshot or all") if args.exe is not None and not args.exe.is_file(): @@ -489,9 +467,9 @@ def main() -> None: if args.scenario in {"all", "sdk-custom"}: assert args.exe is not None smoke_sdk_custom(model.url, args.exe.resolve()) - if args.scenario in {"all", "sdk-persistent"}: + if args.scenario in {"all", "sdk-minimal"}: assert args.exe is not None - smoke_sdk_persistent_tools(model.url, args.exe.resolve()) + smoke_sdk_minimal(model.url, args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -519,7 +497,6 @@ def smoke_sdk_default(base_url: str) -> None: request_timeout_seconds=60, ) as harness: result = harness.run("reply with the smoke text", session_id="default-smoke") - assert result.status == "ok", result assert result.final_response == EXPECTED_TEXT, result.final_response assert_zstd_session_log(sessions) @@ -546,46 +523,40 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: text_result = harness.run("reply with the smoke text", session_id="custom-smoke") code_result = harness.run(CODE_PROMPT, session_id="custom-smoke") workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke") - assert text_result.status == "ok", text_result assert text_result.final_response == EXPECTED_TEXT, text_result.final_response - assert code_result.status == "ok", code_result assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response - assert workflow_result.status == "ok", workflow_result assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) -def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: - """Exercise native PTY state and the editor through the packaged executable.""" +def smoke_sdk_minimal(base_url: str, executable: Path) -> None: + """Exercise the checked-in minimal composition through the packaged executable.""" from deepseek_harness import DeepSeekHarness - with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: + with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary: root = Path(temporary).resolve() editor_path = root / "created.txt" - prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}" + prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}" sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(PERSISTENT_TOOLS_CORDIS) with DeepSeekHarness( - provider="deepseek", + provider="deepseek-official", model="smoke-model", cwd=str(root), session_root=str(sessions), - cordis=str(cordis), + cordis=str(MINIMAL_CORDIS), runtime_bin=str(executable), api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, ) as harness: - result = harness.run(prompt, session_id="persistent-tools-smoke") + result = harness.run(prompt, session_id="minimal-agent-smoke") - assert result.status == "ok", result event_text = json.dumps(result.events) - if PERSISTENT_TOOLS_TEXT not in event_text: - raise AssertionError(f"packaged tools run emitted no final response: {result.events}") + if MINIMAL_TEXT not in event_text: + raise AssertionError(f"minimal agent run emitted no final response: {result.events}") if editor_path.read_text() != "created by packaged editor\n": raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") - assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: @@ -610,7 +581,6 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) ) as harness: result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID) - assert result.status == "ok", result assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response methods = [notification.method for notification in result.notifications] if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2: @@ -657,8 +627,8 @@ def smoke_direct(base_url: str, executable: Path) -> None: "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]}, }) messages = peer.read_until(lambda message: message.get("id") == "prompt") - if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages): - messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished")) + if not any(is_idle_notification(message) for message in messages): + messages.extend(peer.read_until(is_idle_notification)) event_text = json.dumps(messages) if EXPECTED_TEXT not in event_text: raise AssertionError(f"direct runtime emitted no final response: {messages}") @@ -669,6 +639,16 @@ def smoke_direct(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT) +def is_idle_notification(message: dict[str, object]) -> bool: + """Return whether a JSON-RPC notification marks a session idle.""" + params = message.get("params") + return ( + message.get("method") == "session.status" + and isinstance(params, dict) + and params.get("status") == "idle" + ) + + class RuntimePeer: def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None: self.process = subprocess.Popen( @@ -776,7 +756,7 @@ def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]: return logs -def snapshot_child_ids(result: "TurnResult") -> list[str]: +def snapshot_child_ids(result: "RunResult") -> list[str]: """Return the two child session ids in their SDK notification order.""" child_ids: list[str] = [] for notification in result.notifications: @@ -794,7 +774,7 @@ def snapshot_child_ids(result: "TurnResult") -> list[str]: def build_snapshot_files( - result: "TurnResult", + result: "RunResult", logs: dict[str, list[dict[str, object]]], child_ids: list[str], cwd: Path, @@ -809,7 +789,6 @@ def build_snapshot_files( result_value = { "session_id": result.session_id, - "status": result.status, "final_response": result.final_response, "events": result.events, "notifications": [ @@ -834,7 +813,7 @@ def build_snapshot_files( return files -def snapshot_agent_id(result: "TurnResult", child_id: str) -> str: +def snapshot_agent_id(result: "RunResult", child_id: str) -> str: """Find the successful subagent id paired with one child session.""" for notification in result.notifications: if notification.method != "subagent.finished": diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 79daa0570c..dff04d578e 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -1,25 +1,62 @@ { "session_id": "{{parent}}", - "status": "ok", "final_response": "ADVANCED_EXECUTABLE_OK", "events": [ { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + }, + { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + }, + { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + }, + { + "type": "step/start", + "seq": 3, + "time": 0, + "data": { + "turn": 1, + "step": 1 } }, { "type": "user/message", - "seq": 1, + "seq": 4, "time": 0, "data": { "content": [ @@ -38,38 +75,34 @@ }, { "type": "session/title", - "seq": 2, + "seq": 5, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", "messageSeqs": [ - 1 + 4 ], "source": { "kind": "fallback" } } }, - { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - }, { "type": "request/header", - "seq": 4, + "seq": 6, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -86,9 +119,19 @@ "reason": "initial" } }, + { + "type": "request/context", + "seq": 7, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + }, { "type": "assistant/chunk", - "seq": 5, + "seq": 8, "time": 0, "data": { "turn": 1, @@ -102,7 +145,7 @@ }, { "type": "assistant/chunk", - "seq": 6, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -118,7 +161,7 @@ }, { "type": "assistant/chunk", - "seq": 7, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -137,7 +180,7 @@ }, { "type": "assistant/chunk", - "seq": 8, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -153,7 +196,7 @@ }, { "type": "assistant/chunk", - "seq": 9, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -168,7 +211,7 @@ }, { "type": "assistant/message", - "seq": 10, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -185,7 +228,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -196,17 +239,17 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, 8, - 9 + 9, + 10, + 11, + 12 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -218,7 +261,7 @@ }, { "type": "tool/result", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -246,13 +289,13 @@ } }, "sourceEventSeqs": [ - 11 + 14 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -261,7 +304,7 @@ }, { "type": "step/start", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -270,15 +313,20 @@ }, { "type": "request/header", - "seq": 15, + "seq": 18, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -298,7 +346,7 @@ }, { "type": "assistant/chunk", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -312,7 +360,7 @@ }, { "type": "assistant/chunk", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -328,7 +376,7 @@ }, { "type": "assistant/chunk", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -347,7 +395,7 @@ }, { "type": "assistant/chunk", - "seq": 19, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -363,7 +411,7 @@ }, { "type": "assistant/chunk", - "seq": 20, + "seq": 23, "time": 0, "data": { "turn": 1, @@ -378,7 +426,7 @@ }, { "type": "assistant/message", - "seq": 21, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -395,7 +443,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -406,17 +454,17 @@ } }, "sourceEventSeqs": [ - 16, - 17, - 18, 19, - 20 + 20, + 21, + 22, + 23 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 22, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -428,9 +476,10 @@ }, { "type": "tool/code-dispatch-start", - "seq": 23, + "seq": 26, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -441,9 +490,10 @@ }, { "type": "tool/code-dispatch", - "seq": 24, + "seq": 27, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -461,7 +511,7 @@ }, { "type": "tool/result", - "seq": 25, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -489,13 +539,13 @@ } }, "sourceEventSeqs": [ - 22 + 25 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 26, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -504,7 +554,7 @@ }, { "type": "step/start", - "seq": 27, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -513,7 +563,7 @@ }, { "type": "assistant/chunk", - "seq": 28, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -527,7 +577,7 @@ }, { "type": "assistant/chunk", - "seq": 29, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -543,7 +593,7 @@ }, { "type": "assistant/chunk", - "seq": 30, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -562,7 +612,7 @@ }, { "type": "assistant/chunk", - "seq": 31, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -578,7 +628,7 @@ }, { "type": "assistant/chunk", - "seq": 32, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -593,7 +643,7 @@ }, { "type": "assistant/message", - "seq": 33, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -610,7 +660,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -621,17 +671,17 @@ } }, "sourceEventSeqs": [ - 28, - 29, - 30, 31, - 32 + 32, + 33, + 34, + 35 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 34, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -643,7 +693,7 @@ }, { "type": "tool/result", - "seq": 35, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -671,13 +721,13 @@ } }, "sourceEventSeqs": [ - 34 + 37 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 36, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -686,7 +736,7 @@ }, { "type": "step/start", - "seq": 37, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -695,7 +745,7 @@ }, { "type": "assistant/chunk", - "seq": 38, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -709,7 +759,7 @@ }, { "type": "assistant/chunk", - "seq": 39, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -725,7 +775,7 @@ }, { "type": "assistant/chunk", - "seq": 40, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -744,7 +794,7 @@ }, { "type": "assistant/chunk", - "seq": 41, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -760,7 +810,7 @@ }, { "type": "assistant/chunk", - "seq": 42, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -775,7 +825,7 @@ }, { "type": "assistant/message", - "seq": 43, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -792,7 +842,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -803,17 +853,17 @@ } }, "sourceEventSeqs": [ - 38, - 39, - 40, 41, - 42 + 42, + 43, + 44, + 45 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 44, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -825,7 +875,7 @@ }, { "type": "tool/result", - "seq": 45, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -853,13 +903,13 @@ } }, "sourceEventSeqs": [ - 44 + 47 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 46, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -868,7 +918,7 @@ }, { "type": "step/start", - "seq": 47, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -877,7 +927,7 @@ }, { "type": "assistant/chunk", - "seq": 48, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -891,7 +941,7 @@ }, { "type": "assistant/chunk", - "seq": 49, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -907,7 +957,7 @@ }, { "type": "assistant/chunk", - "seq": 50, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -926,7 +976,7 @@ }, { "type": "assistant/chunk", - "seq": 51, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -942,7 +992,7 @@ }, { "type": "assistant/chunk", - "seq": 52, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -957,7 +1007,7 @@ }, { "type": "assistant/message", - "seq": 53, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -974,7 +1024,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -985,17 +1035,17 @@ } }, "sourceEventSeqs": [ - 48, - 49, - 50, 51, - 52 + 52, + 53, + 54, + 55 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 54, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -1007,7 +1057,7 @@ }, { "type": "tool/result", - "seq": 55, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -1035,13 +1085,13 @@ } }, "sourceEventSeqs": [ - 54 + 57 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 56, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -1050,7 +1100,7 @@ }, { "type": "step/start", - "seq": 57, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -1059,15 +1109,20 @@ }, { "type": "request/header", - "seq": 58, + "seq": 61, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1086,7 +1141,7 @@ }, { "type": "assistant/chunk", - "seq": 59, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1100,7 +1155,7 @@ }, { "type": "assistant/chunk", - "seq": 60, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -1114,7 +1169,7 @@ }, { "type": "assistant/chunk", - "seq": 61, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -1131,7 +1186,7 @@ }, { "type": "assistant/chunk", - "seq": 62, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -1147,7 +1202,7 @@ }, { "type": "assistant/chunk", - "seq": 63, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -1162,7 +1217,7 @@ }, { "type": "assistant/message", - "seq": 64, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -1177,7 +1232,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1188,17 +1243,17 @@ } }, "sourceEventSeqs": [ - 59, - 60, - 61, 62, - 63 + 63, + 64, + 65, + 66 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 65, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -1207,7 +1262,7 @@ }, { "type": "turn/end", - "seq": 66, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -1223,17 +1278,48 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{parent}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 } } } @@ -1243,42 +1329,14 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "user/message", - "seq": 1, - "time": 0, - "data": { - "content": [ - { - "type": "text", - "text": "Run the advanced packaged-runtime snapshot scenario." - } - ], - "source": { - "kind": "user" - }, - "role": "user", - "id": "{{messageId}}" - }, - "surfaceOp": "append" - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "session/title", + "type": "agent/inbox/spliced", "seq": 2, "time": 0, "data": { - "title": "Run the advanced packaged-runtime snapsh", - "messageSeqs": [ - 1 - ], - "source": { - "kind": "fallback" - } + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] } } } @@ -1303,16 +1361,66 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "request/header", + "type": "user/message", "seq": 4, "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "session/title", + "seq": 5, + "time": 0, + "data": { + "title": "Run the advanced packaged-runtime snapsh", + "messageSeqs": [ + 4 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 6, + "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1331,13 +1439,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/context", + "seq": 7, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 8, "time": 0, "data": { "turn": 1, @@ -1357,7 +1481,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -1379,7 +1503,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -1404,7 +1528,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -1426,7 +1550,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -1447,7 +1571,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -1464,7 +1588,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1475,11 +1599,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, 8, - 9 + 9, + 10, + 11, + 12 ], "surfaceOp": "append" } @@ -1491,7 +1615,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -1509,7 +1633,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -1537,7 +1661,7 @@ } }, "sourceEventSeqs": [ - 11 + 14 ], "surfaceOp": "append" } @@ -1549,7 +1673,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -1564,7 +1688,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -1579,15 +1703,20 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 15, + "seq": 18, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1613,7 +1742,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -1633,7 +1762,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -1655,7 +1784,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -1680,7 +1809,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 19, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -1702,7 +1831,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 20, + "seq": 23, "time": 0, "data": { "turn": 1, @@ -1723,7 +1852,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 21, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -1740,7 +1869,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1751,11 +1880,11 @@ } }, "sourceEventSeqs": [ - 16, - 17, - 18, 19, - 20 + 20, + 21, + 22, + 23 ], "surfaceOp": "append" } @@ -1767,7 +1896,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 22, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -1785,9 +1914,10 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch-start", - "seq": 23, + "seq": 26, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -1804,9 +1934,10 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch", - "seq": 24, + "seq": 27, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -1830,7 +1961,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 25, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -1858,7 +1989,7 @@ } }, "sourceEventSeqs": [ - 22 + 25 ], "surfaceOp": "append" } @@ -1870,7 +2001,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 26, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -1885,7 +2016,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 27, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -1900,7 +2031,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 28, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -1920,7 +2051,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 29, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -1942,7 +2073,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 30, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -1967,7 +2098,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 31, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -1989,7 +2120,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 32, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -2010,7 +2141,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 33, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -2027,7 +2158,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2038,11 +2169,11 @@ } }, "sourceEventSeqs": [ - 28, - 29, - 30, 31, - 32 + 32, + 33, + 34, + 35 ], "surfaceOp": "append" } @@ -2054,7 +2185,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 34, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -2078,17 +2209,97 @@ "payload": { "sessionId": "{{child-1}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly DIRECT_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "subagent/descriptor", + "seq": 3, + "time": 0, + "data": { + "version": 2, + "mode": "one-shot", + "provider": "spawn", + "label": "Check direct child" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "step/start", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1 } } } @@ -2099,7 +2310,7 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 1, + "seq": 5, "time": 0, "data": { "content": [ @@ -2124,12 +2335,12 @@ "sessionId": "{{child-1}}", "event": { "type": "session/title", - "seq": 2, + "seq": 6, "time": 0, "data": { "title": "Reply with exactly DIRECT_CHILD_OK and", "messageSeqs": [ - 1 + 5 ], "source": { "kind": "fallback" @@ -2138,36 +2349,26 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{child-1}}", - "event": { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "request/header", - "seq": 4, + "seq": 7, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -2187,13 +2388,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "request/context", + "seq": 8, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -2213,7 +2430,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2233,7 +2450,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2256,7 +2473,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2278,7 +2495,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2299,7 +2516,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2314,7 +2531,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2325,11 +2542,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, - 8, - 9 + 9, + 10, + 11, + 12, + 13 ], "surfaceOp": "append" } @@ -2341,7 +2558,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/end", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2356,7 +2573,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/end", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2367,6 +2584,13 @@ } } }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "idle" + } + }, { "method": "subagent.finished", "payload": { @@ -2390,7 +2614,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 35, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -2418,7 +2642,7 @@ } }, "sourceEventSeqs": [ - 34 + 37 ], "surfaceOp": "append" } @@ -2430,7 +2654,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 36, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -2445,7 +2669,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 37, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -2460,7 +2684,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 38, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -2480,7 +2704,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 39, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -2502,7 +2726,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 40, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -2527,7 +2751,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 41, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -2549,7 +2773,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 42, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -2570,7 +2794,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 43, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -2587,7 +2811,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2598,11 +2822,11 @@ } }, "sourceEventSeqs": [ - 38, - 39, - 40, 41, - 42 + 42, + 43, + 44, + 45 ], "surfaceOp": "append" } @@ -2614,7 +2838,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 44, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -2638,17 +2862,96 @@ "payload": { "sessionId": "{{child-2}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly WORKFLOW_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "subagent/descriptor", + "seq": 3, + "time": 0, + "data": { + "version": 2, + "mode": "one-shot", + "provider": "spawn" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "step/start", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1 } } } @@ -2659,7 +2962,7 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 1, + "seq": 5, "time": 0, "data": { "content": [ @@ -2684,12 +2987,12 @@ "sessionId": "{{child-2}}", "event": { "type": "session/title", - "seq": 2, + "seq": 6, "time": 0, "data": { "title": "Reply with exactly WORKFLOW_CHILD_OK and", "messageSeqs": [ - 1 + 5 ], "source": { "kind": "fallback" @@ -2698,36 +3001,26 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{child-2}}", - "event": { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "request/header", - "seq": 4, + "seq": 7, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -2747,13 +3040,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "request/context", + "seq": 8, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -2773,7 +3082,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2793,7 +3102,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2816,7 +3125,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2838,7 +3147,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2859,7 +3168,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2874,7 +3183,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2885,11 +3194,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, - 8, - 9 + 9, + 10, + 11, + 12, + 13 ], "surfaceOp": "append" } @@ -2901,7 +3210,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/end", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2916,7 +3225,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/end", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2927,6 +3236,13 @@ } } }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "idle" + } + }, { "method": "subagent.finished", "payload": { @@ -2950,7 +3266,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 45, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -2978,7 +3294,7 @@ } }, "sourceEventSeqs": [ - 44 + 47 ], "surfaceOp": "append" } @@ -2990,7 +3306,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 46, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -3005,7 +3321,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 47, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -3020,7 +3336,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 48, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -3040,7 +3356,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 49, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -3062,7 +3378,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 50, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -3087,7 +3403,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 51, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -3109,7 +3425,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 52, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -3130,7 +3446,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 53, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -3147,7 +3463,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -3158,11 +3474,11 @@ } }, "sourceEventSeqs": [ - 48, - 49, - 50, 51, - 52 + 52, + 53, + 54, + 55 ], "surfaceOp": "append" } @@ -3174,7 +3490,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 54, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -3192,7 +3508,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 55, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -3220,7 +3536,7 @@ } }, "sourceEventSeqs": [ - 54 + 57 ], "surfaceOp": "append" } @@ -3232,7 +3548,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 56, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -3247,7 +3563,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 57, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -3262,15 +3578,20 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 58, + "seq": 61, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -3295,7 +3616,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 59, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -3315,7 +3636,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 60, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -3335,7 +3656,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 61, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -3358,7 +3679,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 62, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -3380,7 +3701,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 63, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -3401,7 +3722,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 64, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -3416,7 +3737,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -3427,11 +3748,11 @@ } }, "sourceEventSeqs": [ - 59, - 60, - 61, 62, - 63 + 63, + 64, + 65, + 66 ], "surfaceOp": "append" } @@ -3443,7 +3764,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 65, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -3458,7 +3779,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 66, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -3470,13 +3791,10 @@ } }, { - "method": "session.finished", + "method": "session.status", "payload": { "sessionId": "{{parent}}", - "status": "ok", - "reason": { - "kind": "completed" - } + "status": "idle" } } ], diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 2929f8664c..3cfcda4d28 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,14 +1,18 @@ -{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index a5da33d006..926acbcecc 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,14 +1,18 @@ -{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 1f2f890b3c..65f31b21b6 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,68 +1,71 @@ {"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} -{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} -{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} -{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} -{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} -{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} -{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} -{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} -{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} -{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} -{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} +{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":18,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} +{"type":"tool/code-dispatch-start","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} +{"type":"tool/code-dispatch","seq":27,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} +{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[31,32,33,34,35],"surfaceOp":"append"} +{"type":"tool/call","seq":37,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":38,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[37],"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":40,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":46,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"tool/call","seq":47,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} +{"type":"tool/result","seq":48,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[47],"surfaceOp":"append"} +{"type":"step/end","seq":49,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":50,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":56,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"} +{"type":"tool/call","seq":57,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":60,"time":0,"data":{"turn":1,"step":6}} +{"type":"request/header","seq":61,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} +{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} +{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} +{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":67,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[62,63,64,65,66],"surfaceOp":"append"} +{"type":"step/end","seq":68,"time":0,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":69,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} From b90cb1b0b0b5dd7c01c6af0eafd73fae78d6f4ca Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 20:55:32 +0800 Subject: [PATCH 030/105] test(web): re-record the subagent goldens against a rebuilt client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The child session now shows the preset badge its parent shows, which is the visible consequence of recording the composition it runs. The first recording of these two goldens was taken against a dist built before the master merge, so it captured the fallback Chinese label instead of the English one `newEnglishPage` pins — the web lane replays the BUILT client, and a stale build reads as a product difference. Re-recorded after `pnpm run build`. --- apps/web/tests/snapshots/subagent-conversation/ui.expected.md | 4 ++-- .../snapshots/subagent-interrupt/offline-composer.expected.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 4b71dfdc9c..cf22f5b566 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -3,11 +3,11 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] + - img + - text: Standard mode - button "1 subagent": - text: 1 subagent - img - - img - - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index fbec36baea..7fe41a532d 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -4,7 +4,7 @@ - text: / - button "event-sourcing researcher" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" From 854f6623bb0334c6175df179d71d19fe345924f6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:38:57 +0800 Subject: [PATCH 031/105] docs: rename client manifest field references --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 ++-- ...026-07-19-gui-layering-and-rpc-protocol.md | 8 ++++---- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 8 ++++---- ...7-19-gui-web-client-architecture.i18n.yaml | 4 ++-- .../2026-07-19-gui-web-client-architecture.md | 4 ++-- ...26-07-19-gui-web-client-architecture.zh.md | 4 ++-- ...7-23-client-plugin-loading-model.i18n.yaml | 4 ++-- .../2026-07-23-client-plugin-loading-model.md | 20 +++++++++---------- ...26-07-23-client-plugin-loading-model.zh.md | 20 +++++++++---------- ...tree-boot-and-transport-layering.i18n.yaml | 4 ++-- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- ...07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../2026-07-24-web-session-model-selector.md | 2 +- ...026-07-24-web-session-model-selector.zh.md | 2 +- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/capability-seams.md | 2 +- docs/cookbook/adding-a-package.i18n.yaml | 4 ++-- docs/cookbook/adding-a-package.md | 2 +- docs/cookbook/adding-a-package.zh.md | 2 +- packages/boot/app-boot/src/profile.ts | 9 ++++----- packages/bundle/web-app/cordis.patch.yml | 6 +++--- packages/client/AGENTS.md | 6 +++--- packages/client/modules/README.i18n.yaml | 4 ++-- packages/client/modules/README.md | 2 +- packages/client/modules/README.zh.md | 2 +- .../client/modules/src/client/manifest.ts | 2 +- .../client/runtime/tests/node-half.spec.ts | 2 +- packages/client/test-runtime/README.i18n.yaml | 4 ++-- packages/client/test-runtime/README.md | 2 +- packages/client/test-runtime/README.zh.md | 2 +- packages/client/test-runtime/src/index.ts | 2 +- packages/client/ui-command/src/index.ts | 2 +- packages/client/ui-deliverables/src/index.ts | 2 +- packages/client/ui-goal/src/index.ts | 2 +- packages/client/ui-model/src/index.ts | 2 +- packages/client/ui-permission/src/index.ts | 2 +- packages/client/ui-plan/src/index.ts | 2 +- .../client/ui-settings/src/client/index.ts | 2 +- packages/client/ui-skill/src/index.ts | 2 +- packages/client/ui-slash/src/index.ts | 2 +- packages/client/ui-subagent/src/index.ts | 2 +- .../client/ui-workspace/src/client/index.ts | 2 +- packages/client/ui-workspace/src/index.ts | 2 +- scripts/gen-doc-graphs.ts | 2 +- 47 files changed, 89 insertions(+), 90 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index c7be4bbdf1..bc2d26325d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: f9c95176321496e965a95b6358d6feaa8466fe89 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 7d20c5a2662c9036382b30a96bc9973c8f0349bd +2026-07-19-gui-layering-and-rpc-protocol.md: 514deb890d4e08d465db869669078473d32fb215 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: f6fa71e3dac25f48b2ad4744a0cc695417528b34 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index f9c9517632..514deb890d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -27,8 +27,8 @@ Directories layer as follows: - the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below - `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md)): - **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table. - - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. - - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. + - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dsh.client` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. + - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. @@ -40,7 +40,7 @@ apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives - runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply, webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) @@ -63,7 +63,7 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod | Layer | Package | Responsibility | Key discipline | |---|---|---|---| | Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | -| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | +| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dsh.client packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | | Carrier layer | `dsh-host-webserver` | Web HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | | Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | | Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 7d20c5a266..f6fa71e3da 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -25,8 +25,8 @@ Status: implemented - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 - `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有): - **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。 - - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 + - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 + - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 @@ -38,7 +38,7 @@ apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives - runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply, webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) @@ -61,7 +61,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. | 层 | 包 | 职责 | 关键纪律 | |---|---|---|---| | 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | -| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | +| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dsh.client 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | | 承载层 | `dsh-host-webserver` | Web HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | | client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构笔记 | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 269933c1a7..1712fa8304 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 82b2f85708c423748954644d4991e2d54d42874a -2026-07-19-gui-web-client-architecture.zh.md: c37252d1db291cae11db2a615c9e4005ece717da +2026-07-19-gui-web-client-architecture.md: bc61aab894d587820ef4cb568b6439993a27d30d +2026-07-19-gui-web-client-architecture.zh.md: 1f5bafe1dff878b5ca5ffcbdb9ed8ca38a863c9f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 82b2f85708..bc61aab894 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -30,7 +30,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon ## The client cordis tree and the loading chain -The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dshClient` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `