From 08a2c234c0b3f336d222e1575c1414faa06d4e99 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:46:12 +0800 Subject: [PATCH 01/95] refactor: remove codex/claude dep from direct builtin dep --- examples/package.json | 2 -- packages/bundle/base/package.json | 2 -- pnpm-lock.yaml | 12 ------------ 3 files changed, 16 deletions(-) diff --git a/examples/package.json b/examples/package.json index 29818133ef..761dd0851f 100644 --- a/examples/package.json +++ b/examples/package.json @@ -72,8 +72,6 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-claude-code": "workspace:*", - "@deepseek-ai/dsh-subagent-codex": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index e4f9167fd1..80859a038b 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -85,8 +85,6 @@ "@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/pnpm-lock.yaml b/pnpm-lock.yaml index ab7b3dae65..306cd99243 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -608,12 +608,6 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp - '@deepseek-ai/dsh-subagent-claude-code': - specifier: workspace:* - version: link:../packages/subagent/subagent-claude-code - '@deepseek-ai/dsh-subagent-codex': - specifier: workspace:* - version: link:../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -1390,12 +1384,6 @@ 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 86c51704cb1c5044400fcae25e8e11a87934ec01 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:09:04 +0800 Subject: [PATCH 02/95] fix(bundle): exclude product subagents from base --- ...ludes-product-subagent-providers.i18n.yaml | 6 +++++ ...dsh-excludes-product-subagent-providers.md | 25 +++++++++++++++++++ ...-excludes-product-subagent-providers.zh.md | 25 +++++++++++++++++++ apps/cli/composition.md | 6 ----- examples/package.json | 2 ++ packages/bundle/base/cordis.patch.yml | 9 ------- packages/bundle/base/tests/base.spec.ts | 10 +++----- pnpm-lock.yaml | 6 +++++ 8 files changed, 68 insertions(+), 21 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md create mode 100644 .agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml new file mode 100644 index 0000000000..44f197e39d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md +2026-08-12-production-dsh-excludes-product-subagent-providers.md: 3e3e4fbefb31932a637bfe05ff0d90916e202a79 +2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: 166964675bf7084b62f5500969e5756c9bd9f644 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md new file mode 100644 index 0000000000..3e3e4fbefb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md @@ -0,0 +1,25 @@ +# Agent Note: Production dsh excludes product subagent providers + +Status: implemented + +English | [中文](2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md) + +## Problem + +`@deepseek-ai/dsh` receives the `@deepseek-ai/dsh-base` dependency closure. Including the Codex and Claude Code subagent providers there makes every production install download optional product integration code, including the Claude Agent SDK, even when neither integration is used. + +## Decision + +This decision supersedes the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. Their packages remain available for Profiles that install and mount them explicitly. Repository examples keep direct development dependencies so their explicit provider configurations continue to resolve. + +## Verification + +The base bundle test rejects both provider dependencies and configuration rows. Cordis configuration validation requires explicit examples to declare the provider packages they name. + +## Alternatives considered + +**Keep dormant providers in the base bundle.** Dormant providers start no product processes, but their packages still enter every production npm install. + +## Consequences + +Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. Using either integration requires explicit Profile configuration. diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md new file mode 100644 index 0000000000..166964675b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 生产 dsh 排除产品 subagent 提供方 + +Status: implemented + +[English](2026-08-12-production-dsh-excludes-product-subagent-providers.md) | 中文 + +## 问题 + +`@deepseek-ai/dsh` 会获得 `@deepseek-ai/dsh-base` 的依赖闭包。如果 base 包含 Codex 与 Claude Code subagent 提供方,每次生产安装都会下载可选的产品集成代码,包括 Claude Agent SDK,即使用户并未使用任一集成。 + +## 决策 + +本决策取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md):`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。需要这些集成的 Profile 仍可显式安装并挂载对应包。仓库 examples 保留直接开发依赖,使其显式提供方配置可以继续解析。 + +## 验证 + +base 组合包测试会拒绝这两个提供方依赖与配置行。Cordis 配置验证要求显式 examples 声明其引用的提供方包。 + +## 考虑过的替代方案 + +**在 base 组合包中保留休眠提供方。** 休眠提供方不会启动产品进程,但其包仍会进入每次生产 NPM 安装。 + +## 后果 + +安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。使用任一集成都需要显式 Profile 配置。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 3e08e97b37..dbac0d8770 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -114,10 +114,6 @@ 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"] @@ -225,8 +221,6 @@ 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/examples/package.json b/examples/package.json index 761dd0851f..29818133ef 100644 --- a/examples/package.json +++ b/examples/package.json @@ -72,6 +72,8 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", + "@deepseek-ai/dsh-subagent-claude-code": "workspace:*", + "@deepseek-ai/dsh-subagent-codex": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c276c886dd..093c4026ea 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -296,15 +296,6 @@ 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/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 81ceb340d5..23a564fbcf 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -35,12 +35,10 @@ describe('dsh-base bundle', () => { expect(rows.find(row => row.id === 'telemetry-otel')?.config?.['mode']).toEqual({ __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'", }) - 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:^', - }) + expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0) + expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0) + expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') + expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code') }) it('gates each shell stack by platform with a symmetric disabled expression', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 306cd99243..4a2d4a5838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -608,6 +608,12 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:* + version: link:../packages/subagent/subagent-claude-code + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:* + version: link:../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk From d1629eed45062dd7b1de73e1f0dd51452733d6a8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 12 Aug 2026 19:28:31 +0800 Subject: [PATCH 03/95] feat(subagent): make product providers directly installable --- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 20 ++- ...ct-subagent-providers-in-shared-host.zh.md | 20 ++- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 6 +- ...ude-code-and-codex-subagent-backends.zh.md | 6 +- ...ludes-product-subagent-providers.i18n.yaml | 4 +- ...dsh-excludes-product-subagent-providers.md | 10 +- ...-excludes-product-subagent-providers.zh.md | 10 +- .../agent-presets/code/agent.cordis.yml | 2 + .../agent-presets/cordis/agent.cordis.yml | 2 + .../editing-cordis-compositions/SKILL.md | 11 +- .../agent-presets/standard/agent.cordis.yml | 2 + apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 12 ++ apps/cli/reference/README.zh.md | 12 ++ apps/cli/tests/built-bin.e2e.ts | 15 +++ apps/cli/tests/web-agent-presets.e2e.ts | 126 ++++++++++++------ docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 15 +-- docs/module-graph.zh.md | 15 +-- .../subagent/subagent-claude-code/cordis.yml | 18 +-- .../subagent/subagent-claude-code/driver.ts | 57 +++----- .../subagent/subagent-codex/cordis.yml | 7 +- .../subagent/subagent-codex/driver.ts | 9 +- packages/bundle/README.i18n.yaml | 4 +- packages/bundle/README.md | 2 + packages/bundle/README.zh.md | 2 + packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 3 +- packages/bundle/base/README.zh.md | 3 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 2 + packages/subagent/README.zh.md | 2 + .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 15 ++- .../subagent-claude-code/README.zh.md | 15 ++- .../subagent-claude-code/cordis.patch.yml | 6 + .../subagent-claude-code/package.json | 7 + .../tests/loader-composition.e2e.ts | 64 ++++----- .../tests/subagent-claude-code.spec.ts | 29 ++++ .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 15 ++- packages/subagent/subagent-codex/README.zh.md | 15 ++- .../subagent/subagent-codex/cordis.patch.yml | 6 + packages/subagent/subagent-codex/package.json | 10 +- .../tests/loader-composition.e2e.ts | 9 ++ .../tests/subagent-codex.spec.ts | 31 +++++ pnpm-lock.yaml | 6 +- scripts/check-workspace-constraints.ts | 2 + .../verify-config-source-ownership.spec.ts | 4 +- scripts/verify-config-source-ownership.ts | 3 +- scripts/verify-cordis-config.spec.ts | 98 +++++++++++++- scripts/verify-cordis-config.ts | 60 +++++++-- 57 files changed, 583 insertions(+), 249 deletions(-) create mode 100644 packages/subagent/subagent-claude-code/cordis.patch.yml create mode 100644 packages/subagent/subagent-codex/cordis.patch.yml diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index 28d5fad868..46c178ec65 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: ffef7b67a11617599674e0515e16bfd5283d8445 -2026-08-05-profile-plugin-bundles.zh.md: b190d5e834ba3ce8b719a4a3e61f256eb2c0d82b +2026-08-05-profile-plugin-bundles.md: fdc92eec39b22a9d02258003fef2af9762c90eda +2026-08-05-profile-plugin-bundles.zh.md: 51bb84d9b39e8aa51b6cc84f798bf512f8cca9a4 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index ffef7b67a1..fdc92eec39 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md). -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. +The default Profile templates use `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index b190d5e834..51bb84d9b3 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 +默认 Profile 模板使用的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 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 index b696b46749..ba797746c5 100644 --- 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 @@ -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-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 +2026-08-10-product-subagent-providers-in-shared-host.md: 6db6ca665532ec2b457859243fee5cdc750e954b +2026-08-10-product-subagent-providers-in-shared-host.zh.md: e6c221299d80e6e66858db607c6b4696942b8a61 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 index 33b6eb6cf7..6db6ca6655 100644 --- 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 @@ -6,27 +6,25 @@ 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 [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) are independently installable packages loaded beside the common subagent tool. Agent Presets are 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. Bundle installation and Preset tool grant are therefore separate deployment and agent-authoring decisions. -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. +The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while granting 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. +When installed in a Profile, each product Bundle loads its fixed `codex` or `claude-code` provider exactly once in the shared 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 grant neither tool, either one, or both without changing the provider registry. A tool whose provider Bundle is not installed remains unavailable rather than mounting another provider in the Agent plane. -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 [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) partially supersedes only this note's former default-inclusion choice: the base bundle excludes both providers, and each provider package owns its directly installable Bundle patch. This note continues to own process-wide Host placement whenever a product Bundle is installed. The provider-contract 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. +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. Bundle 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. ## 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. +Real composition loads the selected set of no product Bundle, Codex only, Claude Code only, or both, and crosses it with Agent Presets that grant none, either, or both tools. It proves the Host registry equals the installed Bundle set, model-visible tools equal the installed-and-granted intersection, and no product process starts during composition. Preset edit coverage retains generation isolation. Keyless ACP snapshots pin the model-visible tool schemas, 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. +**Keep both dormant providers in every base Profile.** This makes every matching Preset row immediately usable, but forces every production installation to carry both provider packages and the Claude Agent SDK even when neither integration is wanted. **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. @@ -36,6 +34,6 @@ The base Loader test proves both provider names register exactly once and no pro ## 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. +A user installs only the product Bundles available to a Profile and manages model-visible grants through the same Agent Preset authoring path as other plugins. Each new session receives the intersection of its preset's tool rows and the Profile's installed providers. An installed but ungranted product remains dormant and consumes its package and module-loading footprint but no product process, login, model call, or product home; an uninstalled product contributes no provider or SDK closure. -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. +The Host registry remains the single provider authority, each Bundle remains the deployment availability authority, and each Preset remains the model-tool authority. This explicit two-gate lifecycle avoids a global enable switch and keeps package removal independent from per-session authoring. 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 index fd78c7a3fe..e6c221299d 100644 --- 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 @@ -6,27 +6,25 @@ Status: implemented ## 问题 -[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 行本身不完整。 +[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)是可独立安装的包,由部署环境在通用 subagent 工具旁加载。Agent Preset 是单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。因此,Bundle 安装与 Preset 工具授权分别属于部署决策和 agent 创作决策。 -归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 +归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具授权仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 ## 决策 -每个随发行版交付的 Profile 都会通过 base 组合包的宿主平面,把固定的 `codex` 与 `claude-code` 提供方各加载一次。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不暴露任何工具、只暴露其中一个或同时暴露两者,而无需更改提供方注册表。 +产品 Bundle 安装到 Profile 后,会在共享 Host 平面中恰好加载一次其固定的 `codex` 或 `claude-code` 提供方。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不授权任何工具、只授权其中一个或同时授权两者,而无需更改提供方注册表。若工具对应的提供方 Bundle 尚未安装,该工具仍不可用,而不会在 Agent 平面中另行挂载提供方。 -本决策仅取代提供方约定说明所记录的、原先由用户选择启用的组装位置。该说明仍负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 +[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只部分取代本说明先前关于默认包含提供方的选择:base 组合包排除两个提供方,每个提供方包负责其可直接安装的 Bundle patch。本说明继续负责产品 Bundle 安装后进程级的 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[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`。移除这份未使用载荷属于独立的产品安装闭包后续项;本归属决策既不会动态安装它,也不会将它当作生产可执行文件。 +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Bundle 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。 ## 验证 -base Loader 测试证明两个提供方名称都恰好注册一次,而且 Profile 启动期间不会启动产品进程。真实 Agent Preset 组装覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。无密钥 ACP(Agent Client Protocol)快照固定单个产品与两个产品同时启用时的模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。 +真实组装会加载未安装产品 Bundle、仅安装 Codex、仅安装 Claude Code 或两者都安装这四种集合,并与不授权工具、仅授权其中一个或同时授权两者的 Agent Preset 完整交叉。测试证明 Host 注册表等于已安装 Bundle 集合,模型可见工具等于已安装且已授权集合的交集,并且组装期间不会启动产品进程。Preset 编辑覆盖继续证明代际隔离。无密钥 ACP(Agent Client Protocol)快照固定模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。 ## 考虑过的替代方案 -**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但复制或由 agent 创作的 Preset 行无法直接使用,除非用户还发现并编辑第二个组装层。对于这些本来与其他工具无异的工具,通用 Preset 入口仍不完整。 +**在每个 base Profile 中保留两个休眠提供方。** 这样每条匹配的 Preset 行都能立即使用,但即使用户不需要任一集成,每次生产安装仍会携带两个提供方包和 Claude Agent SDK。 **存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 Preset 争夺模型可见工具的责任归属,也无法表示两个会话使用不同组合。可用性与身份验证属于部署事实,并非另一份需要持久化的产品状态。 @@ -36,6 +34,6 @@ base Loader 测试证明两个提供方名称都恰好注册一次,而且 Prof ## 后果 -用户通过与其他插件相同的 Agent Preset 创作路径管理两个产品,每个新会话只会获得其所选 preset 所贡献的工具。每个 Profile 都携带两个休眠的提供方注册,因此未使用的产品会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录。 +用户只安装 Profile 可用的产品 Bundle,并通过与其他插件相同的 Agent Preset 创作路径管理模型可见授权。每个新会话会获得其 preset 工具行与 Profile 已安装提供方的交集。已安装但未授权的产品保持休眠,会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录;未安装的产品不会进入提供方或 SDK 依赖闭包。 -宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是当前 Claude SDK 可选载荷的安装成本继续被明确延期处理,而不会隐藏在另一种启用状态或安装程序生命周期之后。 +Host 注册表仍是提供方的唯一权威,每个 Bundle 仍是部署可用性的权威,每个 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 84cb091651..ed38bf3472 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: ccc96d6c998c4ab958a7eea1e502d036d16ec90d -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 740eeb633e336d5b01cb0b84fb656612e690959d +2026-08-04-claude-code-and-codex-subagent-backends.md: 80dc7ad8488b3ed557361ab3e88948e56763c860 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c6b5f890677ea85f68f5e1b388b1294cea6659a7 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 ccc96d6c99..80dc7ad848 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 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. +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) owns process-wide placement when a provider is installed, while the [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their optional direct Bundle installation and exclusion from the default distribution. 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 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 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. Loader and optional Bundle-composition evidence resolve the selected 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 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. +Users delegate through two stable foreground tools backed by the official product integrations. Installed providers remain in the process-wide Host and tools remain per Preset under the [shared-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md); optional package availability and default exclusion are owned by the [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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 740eeb633e..c6b5f89067 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 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)取代原先由用户选择启用的组装位置。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责提供方安装后的进程级放置,[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责其可选直接 Bundle 安装与默认发行排除。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 这两个提供方都报告 `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,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。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 与可选 Bundle 组装证据会按名称解析已选择的产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -87,7 +87,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl ## 后果 -用户通过官方产品集成支持的两个稳定前台工具进行委派。它们在 Profile 中的归属和按 Preset 暴露方式由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 +用户通过官方产品集成支持的两个稳定前台工具进行委派。已安装提供方位于进程级 Host、工具按 Preset 暴露,这些规则由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;可选包可用性与默认排除由[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml index 44f197e39d..d021cfbab7 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md -2026-08-12-production-dsh-excludes-product-subagent-providers.md: 3e3e4fbefb31932a637bfe05ff0d90916e202a79 -2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: 166964675bf7084b62f5500969e5756c9bd9f644 +2026-08-12-production-dsh-excludes-product-subagent-providers.md: 94cfe82d0aa42076f3c0723ed99a83a1e53e3724 +2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: a9dbdcff748ea46dc84c1480e6e50a945219a5c7 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md index 3e3e4fbefb..94cfe82d0a 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md @@ -10,16 +10,20 @@ English | [中文](2026-08-12-production-dsh-excludes-product-subagent-providers ## Decision -This decision supersedes the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. Their packages remain available for Profiles that install and mount them explicitly. Repository examples keep direct development dependencies so their explicit provider configurations continue to resolve. +This decision partially supersedes only the default-inclusion part of the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. Each existing provider package is instead a directly installable Profile Bundle whose `dsh.bundle.patch` points to one package-owned `cordis.patch.yml`. That patch contributes exactly one self-provider Host row and no Agent tool row. + +The two Bundles remain independent. The Codex Bundle owns its `@deepseek-ai/dsh-sdk-protocol` runtime dependency; the Claude Code Bundle owns its Agent SDK runtime dependency. Installing one does not pull in the other, and the default `@deepseek-ai/dsh` production closure contains neither provider nor the Claude Agent SDK. An installed Bundle registers a dormant provider on the next Profile start, while an Agent Preset independently decides whether a new Session receives the corresponding tool. Installation does not start, authenticate, configure, or grant model access to either product. ## Verification -The base bundle test rejects both provider dependencies and configuration rows. Cordis configuration validation requires explicit examples to declare the provider packages they name. +Package tests pin each Bundle manifest, exported patch, exact self-provider row, and product-specific runtime dependency. Workspace validation discovers Bundle manifests by declaration rather than directory. Production-closure tests prove the default, Codex-only, and Claude-only dependency boundaries, while real Bundle-patch and Agent-Preset composition covers all four installed sets against all four tool sets and proves composition starts no product process. The base bundle test continues to reject both provider dependencies and configuration rows. ## Alternatives considered **Keep dormant providers in the base bundle.** Dormant providers start no product processes, but their packages still enter every production npm install. +**Add a wrapper or meta Bundle.** A third package would duplicate installation ownership and make independent removal less direct without contributing another runtime capability. + ## Consequences -Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. Using either integration requires explicit Profile configuration. +Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. A Profile can add or remove either provider package, or both, directly; the changed Host availability takes effect on the next Profile start. A separately authored Agent Preset still grants the model-visible tool only to newly composed Sessions. No wrapper package, meta Bundle, dynamic installer, or persisted product-enable state is introduced. diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md index 166964675b..a9dbdcff74 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md @@ -10,16 +10,20 @@ Status: implemented ## 决策 -本决策取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md):`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。需要这些集成的 Profile 仍可显式安装并挂载对应包。仓库 examples 保留直接开发依赖,使其显式提供方配置可以继续解析。 +本决策只部分取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)中关于默认包含提供方的部分:`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。现有的每个提供方包改为可直接安装的 Profile Bundle,其 `dsh.bundle.patch` 指向包自身拥有的 `cordis.patch.yml`。该 patch 恰好贡献一条挂载自身提供方的 Host 行,不包含 Agent 工具行。 + +两个 Bundle 彼此独立。Codex Bundle 自己负责运行时依赖 `@deepseek-ai/dsh-sdk-protocol`;Claude Code Bundle 自己负责 Agent SDK 运行时依赖。安装其中一个不会带入另一个,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一提供方,也不包含 Claude Agent SDK。已安装的 Bundle 会在下次 Profile 启动时注册一个休眠提供方,而 Agent Preset 独立决定新 Session 是否获得对应工具。安装不会启动产品、验证身份、配置产品或向模型授予任一产品的访问权。 ## 验证 -base 组合包测试会拒绝这两个提供方依赖与配置行。Cordis 配置验证要求显式 examples 声明其引用的提供方包。 +包测试会固定每个 Bundle 的 manifest、导出的 patch、准确的自身提供方行以及产品专属运行时依赖。工作区验证会按 Bundle 声明发现 manifest,而非按目录发现。生产闭包测试证明默认、仅 Codex 与仅 Claude 三种依赖边界;真实 Bundle patch 与 Agent Preset 的组装会覆盖四种安装集合与四种工具集合的完整矩阵,并证明组装不会启动产品进程。base 组合包测试仍会拒绝这两个提供方依赖与配置行。 ## 考虑过的替代方案 **在 base 组合包中保留休眠提供方。** 休眠提供方不会启动产品进程,但其包仍会进入每次生产 NPM 安装。 +**新增 wrapper 或 meta Bundle。** 第三个包会重复安装责任,使独立移除变得更间接,却不会贡献新的运行时能力。 + ## 后果 -安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。使用任一集成都需要显式 Profile 配置。 +安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。Profile 可以直接添加或移除任一提供方包,也可以同时操作两者;Host 可用性的变化会在下次 Profile 启动时生效。单独创作的 Agent Preset 仍只会向新组装的 Session 授予模型可见工具。本决策不引入 wrapper 包、meta Bundle、动态安装程序或持久化的产品启用状态。 diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 992b1a3eb5..ae2e0b2653 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -201,6 +201,8 @@ # 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. + # Install the matching optional Provider Bundle in this Profile and restart + # the Host before enabling either template. Installation alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index d05063846f..03fde2c612 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -188,6 +188,8 @@ # 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. + # Install the matching optional Provider Bundle in this Profile and restart + # the Host before enabling either template. Installation alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true 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 d897cafb7a..c3ee75b143 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 @@ -123,7 +123,14 @@ After a clean mount-validation, ask the user to start a session on the new prese ## 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. +Codex and Claude Code providers are optional Profile Bundles. Install only the products the Profile needs, then restart the Profile so its Host registers those providers: + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +``` + +The Bundle owns Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing either package with `dsh plugin --profile remove ` withdraws only that provider on the next Profile start. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: @@ -147,7 +154,7 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o 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 two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must already provide `codex` or `claude` on `PATH`; neither the Bundle nor the preset installs, authenticates, selects a model for, starts, or probes either product. ## What not to move into a preset diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index b57b18eef7..ded4a41ab5 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -200,6 +200,8 @@ # 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. + # Install the matching optional Provider Bundle in this Profile and restart + # the Host before enabling either template. Installation alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 309d184da5..2cb82986a3 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: 17d63fe73ea2b3bda74e9c4da91555b34c21f4ab -README.zh.md: ee99592fbba07b52b29db534e12c1d0ef23e5ef0 +README.md: ed6a645c8587044f5dfd3f222d9700058380196b +README.zh.md: a75ef3426fc3794c0968d08d0cb8603347edfa06 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 17d63fe73e..ed6a645c85 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -42,6 +42,18 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. +The Codex and Claude Code subagent providers are separate optional Bundles. Add either package, both in one command, or remove either package independently: + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +``` + +The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` retain their existing hot-reload behavior. On the next start, each installed product Bundle registers only its dormant Host provider and does not start, install, authenticate, or configure the native product. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the matching row before a new Agent can see that tool. Installing one provider never installs the other product package; the default dsh dependency closure includes neither provider nor the Claude Agent SDK. + ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui dsh plugin --profile tui remove turtle-ui diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index ee99592fbb..a75ef3426f 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -42,6 +42,18 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,`dsh.profile.bundles` 都会与已安装状态对齐:每个解析到 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的包的依赖加入层栈(因此让包获得该声明的 `update` 会将其激活),没有组合包声明的依赖保持为普通依赖并给出一次性警告,已移除的依赖则退出层栈。 +Codex 与 Claude Code subagent provider 是两个彼此独立的可选 Bundle。可以只添加一个包、在同一命令中添加两个包,或独立移除任一包: + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +``` + +pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑仍保留既有热重载行为。下一次启动时,每个已安装的产品 Bundle 只注册自己的休眠 Host provider,不会启动、安装、认证或配置原生产品。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用对应行,新 Agent 才能看到该工具。只安装一个 provider 不会安装另一个产品包;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK。 + ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui dsh plugin --profile tui remove turtle-ui diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c989d9ce3e..d4bcb91f6a 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -641,6 +641,21 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle']) expect(manifest.dsh.profile.bundles).toContain('anchored-bundle') + + const removed = await runBuiltBin( + ['plugin', '--profile', 'anchor', 'remove', 'anchored-bundle'], + { DSH_HOME: home }, + checkout, + ) + expect(removed.code).toBe(0) + const afterRemove = JSON.parse( + readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8'), + ) as { + dependencies?: Record + dsh: { profile: { bundles: string[] } } + } + expect(Object.keys(afterRemove.dependencies ?? {})).toEqual([]) + expect(afterRemove.dsh.profile.bundles).not.toContain('anchored-bundle') } finally { rmSync(home, { recursive: true, force: true }) rmSync(checkout, { recursive: true, force: true }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index fcbb00d33f..dfd86a566d 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' @@ -9,7 +9,7 @@ import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } 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' @@ -26,6 +26,8 @@ const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ 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') +const CODEX_PATCH = join(REPO_ROOT, 'packages/subagent/subagent-codex/cordis.patch.yml') +const CLAUDE_CODE_PATCH = join(REPO_ROOT, 'packages/subagent/subagent-claude-code/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.' @@ -43,7 +45,11 @@ const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell * touch the network, or write outside the test. Everything that decides an * agent's capabilities is the real thing, including both shipped presets. */ -async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise { +async function bootWeb( + settingsFile: string, + extra: PatchOptions[] = [], + profilePackages: readonly string[] = [], +): Promise { const storageRoot = join(dirname(settingsFile), 'storages') const patches: PatchOptions[] = [ ...loadOverlayPatches('dsh-test', BASE_PATCH), @@ -112,6 +118,16 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis healProfilesModuleFallback(INSTALL_ANCHOR, home) const profileDir = join(home, 'profiles', 'spec') await mkdir(profileDir, { recursive: true }) + // Product Bundles are installed into the Profile, not the dsh app. Model + // pnpm's package link for only the selected products; their own production + // dependencies resolve from the linked workspace packages, while shared + // peers still resolve through the installation fallback above. + for (const packageDir of profilePackages) { + const manifest = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) as { name: string } + const link = join(profileDir, 'node_modules', manifest.name) + await mkdir(dirname(link), { recursive: true }) + await symlink(packageDir, link, 'junction') + } const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') return await boot('dsh-test', rootConfig, patches, (bootCtx) => { @@ -416,17 +432,17 @@ 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 +describe('product subagent Bundle and user-preset intersection', () => { + const presetIds = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const + type Product = 'codex' | 'claude-code' - beforeAll(async () => { + async function bootProducts(installed: readonly Product[]): Promise { 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) { + for (const id of presetIds) { let composition = standard if (id === 'products-codex' || id === 'products-both') { composition = enablePresetTool(composition, 'tool-subagent-codex') @@ -438,50 +454,75 @@ describe('product subagent rows in user presets', () => { 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' }, - ], - includeUserRoot: false, + const productPatches = installed.flatMap(product => loadOverlayPatches( + 'dsh-test', + product === 'codex' ? CODEX_PATCH : CLAUDE_CODE_PATCH, + )) + return await bootWeb(settingsFile, [ + ...productPatches, + { + id: 'agent-presets', + config: { + default: 'standard', + roots: [ + { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, + { path: userRoot, trust: 'user' }, + ], + includeUserRoot: false, + }, }, - }]) - }, 120_000) + ], installed.map(product => dirname(product === 'codex' ? CODEX_PATCH : CLAUDE_CODE_PATCH))) + } - afterAll(async () => { - await productCtx.fiber.dispose() - }) - - it('composes none, either product, or both without changing the shared host registry', async () => { - const expected = new Map([ + it('composes the intersection of installed Bundles and enabled preset rows', async () => { + const enabledByPreset = new Map([ ['products-none', []], - ['products-codex', ['subagent_codex']], - ['products-claude', ['subagent_claude_code']], - ['products-both', ['subagent_claude_code', 'subagent_codex']], + ['products-codex', ['codex']], + ['products-claude', ['claude-code']], + ['products-both', ['codex', 'claude-code']], ]) - expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([ - 'spawn', 'fork', 'codex', 'claude-code', - ])) + const installations: Product[][] = [ + [], + ['codex'], + ['claude-code'], + ['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), - }) + for (const installed of installations) { + const productCtx = await bootProducts(installed) + const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { - const tools = toolNames(productCtx, handle.agent) - expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) - .toEqual(productTools) + expect(productCtx.subagents.list() + .filter(name => name === 'codex' || name === 'claude-code') + .sort()) + .toEqual([...installed].sort()) + for (const [id, enabled] of enabledByPreset) { + const handle = await productCtx.agents.create({ + sessionId: SessionId(`preset-${id}-${installed.join('-') || 'none'}-${randomUUID()}`), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), + }) + try { + const expectedTools = enabled + .filter(product => installed.includes(product)) + .map(product => product === 'codex' ? 'subagent_codex' : 'subagent_claude_code') + .sort() + expect(toolNames(productCtx, handle.agent) + .filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) + .toEqual(expectedTools) + } finally { + await handle.dispose() + } + } + expect(spawn).not.toHaveBeenCalled() } finally { - await handle.dispose() + spawn.mockRestore() + await productCtx.fiber.dispose() } } - }) + }, 120_000) it('applies a product-row edit only to later sessions on the preset', async () => { + const productCtx = await bootProducts(['codex']) const preset = await productCtx.agentPresets.resolve('products-none') const original = await readFile(preset.path, 'utf8') const existing = await productCtx.agents.create({ @@ -505,8 +546,9 @@ describe('product subagent rows in user presets', () => { } finally { await existing.dispose() await writeFile(preset.path, original) + await productCtx.fiber.dispose() } - }) + }, 120_000) }) describe('a switch survives the session', () => { diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d5ac47cf78..6277e1e081 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: 56e029df192f28a787748b12074ee4dfe67d1c58 -module-graph.zh.md: 839c5edf758a3076874643cb6bdbd91954ca369e +module-graph.md: 4518321070889ffd6206be727b15b4e4c6d542d0 +module-graph.zh.md: 3516346d8c458e196f32864837f9ee323f55a304 diff --git a/docs/module-graph.md b/docs/module-graph.md index 56e029df19..4518321070 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -990,6 +990,12 @@ flowchart TD pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -1072,13 +1078,6 @@ flowchart TD pkg_compact_basic --> pkg_llm pkg_compact_basic --> pkg_session pkg_compact_basic --> pkg_token_meter - pkg_subagent_codex --> pkg_invariants - pkg_subagent_codex --> pkg_llm - pkg_subagent_codex --> pkg_sdk_protocol - pkg_subagent_codex --> pkg_session - pkg_subagent_codex --> pkg_subagent - pkg_subagent_codex --> pkg_subprocess - pkg_subagent_codex --> pkg_timeout pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session @@ -1526,6 +1525,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-codex`](../packages/subagent/subagent-codex) | `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), [`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), [`system-prompt`](../packages/core/system-prompt), [`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) | @@ -1539,7 +1539,6 @@ flowchart TD | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 839c5edf75..3516346d8c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -992,6 +992,12 @@ flowchart TD pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -1074,13 +1080,6 @@ flowchart TD pkg_compact_basic --> pkg_llm pkg_compact_basic --> pkg_session pkg_compact_basic --> pkg_token_meter - pkg_subagent_codex --> pkg_invariants - pkg_subagent_codex --> pkg_llm - pkg_subagent_codex --> pkg_sdk_protocol - pkg_subagent_codex --> pkg_session - pkg_subagent_codex --> pkg_subagent - pkg_subagent_codex --> pkg_subprocess - pkg_subagent_codex --> pkg_timeout pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session @@ -1528,6 +1527,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-codex`](../packages/subagent/subagent-codex) | `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), [`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), [`system-prompt`](../packages/core/system-prompt), [`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) | @@ -1541,7 +1541,6 @@ flowchart TD | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml index fcdc0c4fb6..10ccb77e4d 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml @@ -1,5 +1,5 @@ -# Test-only composition of both public opt-in providers and foreground tools. -# The owning e2e boots this tree but never invokes a model or product process. +# Test-only composition of the Claude Code foreground tool around its Bundle-supplied provider. +# The owning e2e applies the package's real patch and never invokes a model or product process. - id: fixture name: './fixture.ts' @@ -9,20 +9,6 @@ - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' -- 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: diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts index d7540e1a2d..72e2fb5482 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts @@ -1,20 +1,21 @@ #!/usr/bin/env node -/** Inspect both public product-provider compositions without invoking them. */ +/** Inspect the public Claude Code Bundle composition without invoking the product. */ -import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { boot, loadOverlayPatches, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-tools' const configPath = process.argv[2] -if (configPath === undefined) { - throw new Error('product-provider Loader composition driver requires a config path') +const bundlePatchPath = process.argv[3] +if (configPath === undefined || bundlePatchPath === undefined) { + throw new Error('Claude Code Loader composition driver requires config and Bundle patch paths') } let starts = 0 const ctx = await boot( - 'product-provider-loader-composition', + 'subagent-claude-code-loader-composition', resolveConfigPath(configPath, undefined), - undefined, + loadOverlayPatches('subagent-claude-code-loader-composition', bundlePatchPath), (hostCtx) => { hostCtx.on('subagent/start', () => { starts += 1 @@ -23,41 +24,27 @@ const ctx = await boot( ) try { - const providerNames = ['codex', 'claude-code'] as const - const toolNames = ['subagent_codex', 'subagent_claude_code'] as const - const providers = providerNames.map((providerName) => { - const provider = ctx.subagents.getProvider(providerName) - if (provider === undefined) { - throw new Error(`${providerName} provider was not registered`) - } - return { + const provider = ctx.subagents.getProvider('claude-code') + if (provider === undefined) throw new Error('claude-code provider was not registered') + const tool = ctx.tools.schemas().find(schema => schema.name === 'subagent_claude_code') + if (tool === undefined) throw new Error('subagent_claude_code tool was not registered') + const properties = tool.parameters.properties + if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) { + throw new Error('subagent_claude_code has invalid parameter properties') + } + + process.stdout.write(`${JSON.stringify({ + providers: ctx.subagents.list(), + provider: { name: provider.name, capabilities: provider.capabilities, inheritsParentContext: provider.inheritsParentContext, - } - }) - const tools = toolNames.map((toolName) => { - const tool = ctx.tools.schemas().find(schema => schema.name === toolName) - if (tool === undefined) throw new Error(`${toolName} tool was not registered`) - const properties = tool.parameters.properties - if ( - typeof properties !== 'object' - || properties === null - || Array.isArray(properties) - ) { - throw new Error(`${toolName} has invalid parameter properties`) - } - return { + }, + tool: { name: tool.name, parameterNames: Object.keys(properties).sort(), required: tool.parameters.required, - } - }) - - process.stdout.write(`${JSON.stringify({ - registeredProviders: ctx.subagents.list(), - providers, - tools, + }, starts, })}\n`) } finally { diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index e770b11c95..45b601aed7 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -1,5 +1,5 @@ -# Test-only composition of the public opt-in provider and foreground tool. -# The owning e2e boots this tree but never invokes the model or Codex. +# Test-only composition of the foreground tool around a Bundle-supplied provider. +# The owning e2e applies the package's real patch and never invokes the model or Codex. - id: fixture name: './fixture.ts' @@ -9,9 +9,6 @@ - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' -- id: subagent-codex - name: '@deepseek-ai/dsh-subagent-codex' - - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts index 873f5e36de..af54c5fc0c 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts @@ -1,20 +1,21 @@ #!/usr/bin/env node /** Inspect the public Codex provider composition without invoking the product. */ -import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { boot, loadOverlayPatches, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-tools' const configPath = process.argv[2] -if (configPath === undefined) { - throw new Error('subagent-codex Loader composition driver requires a config path') +const bundlePatchPath = process.argv[3] +if (configPath === undefined || bundlePatchPath === undefined) { + throw new Error('subagent-codex Loader composition driver requires config and Bundle patch paths') } let starts = 0 const ctx = await boot( 'subagent-codex-loader-composition', resolveConfigPath(configPath, undefined), - undefined, + loadOverlayPatches('subagent-codex-loader-composition', bundlePatchPath), (hostCtx) => { hostCtx.on('subagent/start', () => { starts += 1 diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index eafbe0b0ab..0441ec7d87 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/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/README.md -README.md: 696aa9ef7bbed23774f2b9ab2648ca83edcf0978 -README.zh.md: 2bb22c7949d759f404288548ea0eccfa0aac866b +README.md: 4d7a064939ae04f25737b324ec35332b7b944f80 +README.zh.md: 8910b33a97acd2ef3ee5b659305739246004de01 diff --git a/packages/bundle/README.md b/packages/bundle/README.md index 696aa9ef7b..4d7a064939 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../boot/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts. +The manifest declaration, not this directory, defines Bundle identity. Domain packages can carry their own optional Profile layer; the [Codex and Claude Code subagent packages](../subagent/README.md) are directly installable examples. + | Package | Role | ctx key | |---|---|---| | [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) | diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index 2bb22c7949..8910b33a97 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -4,6 +4,8 @@ Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 约定](../boot/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。 +Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包可以携带自己的可选 Profile 层;[Codex 与 Claude Code subagent 包](../subagent/README.md)就是可直接安装的例子。 + | 包 | 职责 | ctx key | |---|---|---| | [`base/`](base/README.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) | diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 3e15c33837..895dcb6994 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: bd38f39f58ee1f765ff34d40cf57cc6daed2b32b -README.zh.md: 2c6ff8513bae2b7d4b595733e83223bb7af34780 +README.md: a963bcca671c613ebdcc7b453384b1d9b8393662 +README.zh.md: d6594dd35c6932da38f6c8d0069623037dff24db diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index bd38f39f58..a963bcca67 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, 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 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, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile installs either [product provider package](../../subagent/README.md) only when needed. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider nor the Claude Agent SDK. 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 patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled. @@ -19,5 +19,4 @@ 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 2c6ff8513b..d6594dd35c 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、遥测与宿主级 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,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与核心 spawn/fork subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 仅在需要时安装对应的[产品 provider 包](../../subagent/README.md)。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider,也不包含 Claude Agent SDK。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机看到的是被禁用的 pwsh 行。 @@ -19,5 +19,4 @@ patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` ## 已知限制与延期工作 - **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/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 10ad9837b2..600d1b909d 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/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/README.md -README.md: 134028c9993255464c08d912d305c65ed85d65c0 -README.zh.md: ab4284d0525ceed349a778385c918a9dec19406f +README.md: 870cb4be0dc9dc11fe43e6f1e03281a528a769aa +README.zh.md: 122c34c264d01974a18e9ec9fe5cb661bd1e2b67 diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 134028c999..870cb4be0d 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -18,6 +18,8 @@ This family lets an agent delegate work to child agents. Multiple named provider | [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | Provides the child-to-parent report channel | registers in child scopes | +The Codex and Claude Code packages are also independent Profile Bundles. Install either or both with `dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code`, then restart that Profile; each installed package registers only its own dormant Host provider. Full Agent Presets keep separate disabled tool templates, so installation alone exposes no model tool. Removing one package withdraws only that provider on the next Profile start. + See the decisions for the [capability family](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable children](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [control tools](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). The subsystem reference — start requests, results, live runs, the provider contract, continuable background children — is [docs/subsystems/subagent.md](../../docs/subsystems/subagent.md); design rationale in the [subagent capability seam](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable background subagents](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [merged subagent control service](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md) Agent Notes. diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index ab4284d052..122c34c264 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -18,6 +18,8 @@ | [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 | +Codex 与 Claude Code 包也分别是独立的 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code` 安装其中一个或两个包,再重启该 Profile;每个已安装包只注册自己的休眠 Host provider。完整 Agent Preset 仍保留彼此独立且默认禁用的工具模板,因此只安装 Bundle 不会向模型暴露工具。移除其中一个包后,下一次 Profile 启动只会撤回对应 provider。 + 参见有关[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)的决策。 子系统参考——启动请求、结果、实时运行、提供方约定、可续跑后台子 agent——见 [docs/subsystems/subagent.md](../../docs/subsystems/subagent.md);设计依据见 [subagent 能力 seam](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可续跑后台 subagent](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 与[合并 subagent 控制服务](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md) Agent Note。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index bbb3c9cf1a..38d5ddb93a 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: 17b14e847baea3eadda7129b5e49f5e65b668cc8 -README.zh.md: 2f59144d5bd9f26a58773e6dd53909b2b0e8da14 +README.md: b1a5c4bb4b9d1c3221c38092d8b0b967888b7746 +README.zh.md: 0b5419c3b0c57798068556891b2940e3689a7f74 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 17b14e847b..b1a5c4bb4b 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -31,15 +31,26 @@ The provider advertises no optional start-time capabilities and reports `inherit 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. +This package is an optional Profile Bundle. Install it into the target Profile, then restart that Profile; its declared `cordis.patch.yml` layer registers only the dormant `claude-code` Host provider and starts no Claude process. Removing the package withdraws that provider on the next Profile start. + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +dsh --profile +``` + +Installation controls Host availability, not model permission. 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 new agents composed from the copy. The Profile's own patch can replace the Bundle row's complete `config`, while a custom Host composition can still mount the package directly. ```yaml +# $DSH_HOME/profiles//cordis.patch.yml (optional provider override) - id: subagent-claude-code - name: '@deepseek-ai/dsh-subagent-claude-code' config: env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY +``` +```yaml +# A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 2f59144d5b..0b5419c3b0 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -31,15 +31,26 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 生产环境从子进程执行世界清除凭证后的 `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`。自定义宿主组装仍可直接使用两条配置行。 +本包是可选的 Profile Bundle。将它安装进目标 Profile 后重启该 Profile;包所声明的 `cordis.patch.yml` 层只注册休眠的 `claude-code` Host provider,不会启动 Claude 进程。移除该包后,下一次 Profile 启动会撤回这一 provider。 + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +dsh --profile +``` + +安装决定 Host 可用性,而不是模型权限。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的新 agent 暴露 `subagent_claude_code`。Profile 自己的 patch 可以替换 Bundle 行的完整 `config`,而自定义 Host 组合仍可直接挂载本包。 ```yaml +# $DSH_HOME/profiles//cordis.patch.yml (optional provider override) - id: subagent-claude-code - name: '@deepseek-ai/dsh-subagent-claude-code' config: env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY +``` +```yaml +# A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/packages/subagent/subagent-claude-code/cordis.patch.yml b/packages/subagent/subagent-claude-code/cordis.patch.yml new file mode 100644 index 0000000000..63c0319626 --- /dev/null +++ b/packages/subagent/subagent-claude-code/cordis.patch.yml @@ -0,0 +1,6 @@ +# This optional Profile layer registers the dormant Claude Code provider. Agent +# presets separately decide whether one session receives its delegation tool. + +- insert: + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 7966835ccd..8e1b531c2d 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -22,15 +22,22 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts index 51a2ea0025..7ad1871e7a 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -12,60 +13,49 @@ const fixtureDir = fileURLToPath(new URL( )) const driver = join(fixtureDir, 'driver.ts') const configPath = join(fixtureDir, 'cordis.yml') +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + dsh?: { bundle?: { patch?: string } } +} +const bundlePatch = manifest.dsh?.bundle?.patch +if (bundlePatch === undefined) throw new Error('Claude Code package must declare a Bundle patch') +const bundlePatchPath = join(packageDir, bundlePatch) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -describe('product-provider public Loader composition', () => { - it('loads both opt-in packages and foreground tools without starting either product', async () => { +describe('Claude Code provider public Loader composition', () => { + it('loads its Bundle patch and foreground tool without starting Claude Code', async () => { const { stdout, stderr } = await runLoaderSmoke({ label: 'product-provider Loader composition', tempDirPrefix: 'dsh-product-provider-loader-', binScript: driver, libBinScript: driver, configPath, + binArgs: [configPath, bundlePatchPath], tsconfigPath: repoTsconfig, env: { - // Loading either optional package must not probe or start its binary. + // Loading the optional package must not probe or start a Claude binary. PATH: '', }, }) expect(stderr).toBe('') expect(JSON.parse(stdout)).toEqual({ - registeredProviders: ['codex', 'claude-code'], - providers: [ - { - name: 'codex', - capabilities: { - outputSchema: false, - depthLimit: false, - toolFilter: false, - persona: false, - }, - inheritsParentContext: false, + providers: ['claude-code'], + provider: { + name: 'claude-code', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, }, - { - name: 'claude-code', - capabilities: { - outputSchema: false, - depthLimit: false, - toolFilter: false, - persona: false, - }, - inheritsParentContext: false, - }, - ], - tools: [ - { - name: 'subagent_codex', - parameterNames: ['description', 'prompt'], - required: ['description', 'prompt'], - }, - { - name: 'subagent_claude_code', - parameterNames: ['description', 'prompt'], - required: ['description', 'prompt'], - }, - ], + inheritsParentContext: false, + }, + tool: { + name: 'subagent_claude_code', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, starts: 0, }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) 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 cee479fd9d..953445c2e1 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 @@ -1,4 +1,7 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { PassThrough } from 'node:stream' +import { fileURLToPath } from 'node:url' import type { Options, Query, @@ -8,6 +11,7 @@ import type { } from '@anthropic-ai/claude-agent-sdk' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as yaml from 'js-yaml' import { afterEach, beforeEach, @@ -282,6 +286,31 @@ afterEach(() => { }) describe('task admission and package contracts', () => { + it('ships one independently installable provider-only Bundle patch', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + exports?: Record + files?: string[] + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.exports?.['./cordis.patch.yml']).toBe('./cordis.patch.yml') + expect(manifest.files).toContain('cordis.patch.yml') + expect(manifest.dependencies).toHaveProperty('@anthropic-ai/claude-agent-sdk') + expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') + + const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) + const rows = Array.isArray(parsed) + ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) + : [] + expect(rows).toEqual([{ + id: 'subagent-claude-code', + name: '@deepseek-ai/dsh-subagent-claude-code', + }]) + expect(JSON.stringify(rows)).not.toContain('tool-subagent') + }) + it('preserves text sequences and rejects empty, blank, and non-text tasks', () => { expect(textTask([ { type: 'text', text: 'one' }, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 1cfb9645c2..459b1de1a4 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: 3d59ca1eaf3db9dd9d9d2cd451692ebd2a956ef4 -README.zh.md: b60cb1bba9b2d7b3f61c544c1600862a0ad6ce5b +README.md: 18e805f0e0a1d8d33ba77182ed73d213beb62e07 +README.zh.md: ff22fe151021dbff496165225ed4e8724af4d18c diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 3d59ca1eaf..18e805f0e0 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -27,15 +27,26 @@ 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. -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. +This package is an optional Profile Bundle. Install it into the target Profile, then restart that Profile; its declared `cordis.patch.yml` layer registers only the dormant `codex` Host provider and starts no Codex process. Removing the package withdraws that provider on the next Profile start. + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex +dsh --profile +``` + +Installation controls Host availability, not model permission. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to new agents composed from the copy. The Profile's own patch can replace the Bundle row's complete `config`, while a custom Host composition can still mount the package directly. ```yaml +# $DSH_HOME/profiles//cordis.patch.yml (optional provider override) - id: subagent-codex - name: '@deepseek-ai/dsh-subagent-codex' config: env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY +``` +```yaml +# A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index b60cb1bba9..ff22fe1510 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -27,15 +27,26 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 -随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Codex 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。自定义宿主组装仍可直接使用两条配置行。 +本包是可选的 Profile Bundle。将它安装进目标 Profile 后重启该 Profile;包所声明的 `cordis.patch.yml` 层只注册休眠的 `codex` Host provider,不会启动 Codex 进程。移除该包后,下一次 Profile 启动会撤回这一 provider。 + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex +dsh --profile +``` + +安装决定 Host 可用性,而不是模型权限。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的新 agent 暴露 `subagent_codex`。Profile 自己的 patch 可以替换 Bundle 行的完整 `config`,而自定义 Host 组合仍可直接挂载本包。 ```yaml +# $DSH_HOME/profiles//cordis.patch.yml (optional provider override) - id: subagent-codex - name: '@deepseek-ai/dsh-subagent-codex' config: env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY +``` +```yaml +# A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/packages/subagent/subagent-codex/cordis.patch.yml b/packages/subagent/subagent-codex/cordis.patch.yml new file mode 100644 index 0000000000..75e7464574 --- /dev/null +++ b/packages/subagent/subagent-codex/cordis.patch.yml @@ -0,0 +1,6 @@ +# This optional Profile layer registers the dormant Codex provider. Agent +# presets separately decide whether one session receives its delegation tool. + +- insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 6901a6d20b..badc4f1b50 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -22,19 +22,25 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", @@ -42,6 +48,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { @@ -50,7 +57,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index 6c4019f8c8..afdec98305 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -12,6 +13,13 @@ const fixtureDir = fileURLToPath(new URL( )) const driver = join(fixtureDir, 'driver.ts') const configPath = join(fixtureDir, 'cordis.yml') +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + dsh?: { bundle?: { patch?: string } } +} +const bundlePatch = manifest.dsh?.bundle?.patch +if (bundlePatch === undefined) throw new Error('Codex package must declare a Bundle patch') +const bundlePatchPath = join(packageDir, bundlePatch) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('Codex provider public Loader composition', () => { @@ -22,6 +30,7 @@ describe('Codex provider public Loader composition', () => { binScript: driver, libBinScript: driver, configPath, + binArgs: [configPath, bundlePatchPath], tsconfigPath: repoTsconfig, env: { // Loading the optional package must not probe or start a Codex binary. diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 81923f2228..50a98cf0a6 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1,6 +1,10 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { PassThrough } from 'node:stream' +import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as yaml from 'js-yaml' import { describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' @@ -260,6 +264,33 @@ function turnCompleted( } describe('task admission and package contracts', () => { + it('ships one independently installable provider-only Bundle patch', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + peerDependencies?: Record + exports?: Record + files?: string[] + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.exports?.['./cordis.patch.yml']).toBe('./cordis.patch.yml') + expect(manifest.files).toContain('cordis.patch.yml') + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-sdk-protocol') + expect(manifest.peerDependencies).not.toHaveProperty('@deepseek-ai/dsh-sdk-protocol') + expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code') + + const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) + const rows = Array.isArray(parsed) + ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) + : [] + expect(rows).toEqual([{ + id: 'subagent-codex', + name: '@deepseek-ai/dsh-subagent-codex', + }]) + expect(JSON.stringify(rows)).not.toContain('tool-subagent') + }) + it('resolves the fixed app-server command through the Windows npm shim boundary', () => { expect(codexAppServerArgv('win32')).toEqual([ 'cmd.exe', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a2d4a5838..7ef83149d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6939,6 +6939,9 @@ importers: packages/subagent/subagent-codex: dependencies: + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/protocol '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -6961,9 +6964,6 @@ importers: '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../support/loader-smoke - '@deepseek-ai/dsh-sdk-protocol': - specifier: workspace:^ - version: link:../../sdk/protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 263781ce5a..ef389a3cb1 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -135,6 +135,8 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-base': ['cordis.patch.yml'], '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], + '@deepseek-ai/dsh-subagent-codex': ['cordis.patch.yml'], + '@deepseek-ai/dsh-subagent-claude-code': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], // The Python runtime uses a distinct closed-resolution bin; the public CLI // keeps config-owned bare-package resolution through lib/bin.js. diff --git a/scripts/verify-config-source-ownership.spec.ts b/scripts/verify-config-source-ownership.spec.ts index 41026c5fe4..0c675c4f1a 100644 --- a/scripts/verify-config-source-ownership.spec.ts +++ b/scripts/verify-config-source-ownership.spec.ts @@ -14,7 +14,7 @@ describe('configuration source ownership gate', () => { it('rejects inline endpoints in shipped bundle patches', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-config-source-ownership-')) roots.push(root) - const directory = join(root, 'packages/bundle/base') + const directory = join(root, 'packages/subagent/subagent-codex') mkdirSync(directory, { recursive: true }) writeFileSync( join(directory, 'cordis.patch.yml'), @@ -22,7 +22,7 @@ describe('configuration source ownership gate', () => { ) expect(collectConfigSourceOwnershipViolations(root)).toEqual([ - 'packages/bundle/base/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + 'packages/subagent/subagent-codex/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + ' environment snapshot; inlining here bypasses both ladders.', ]) diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index 0684124215..c42e7cb793 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -14,7 +14,8 @@ const SHIPPED_CONFIG_GLOBS = [ 'apps/*/config/*.yml', 'examples/*/*.cordis.yml', 'examples/*/cordis.yml', - 'packages/bundle/*/cordis.patch.yml', + // Bundle identity comes from the package manifest, not the domain directory. + 'packages/*/*/cordis.patch.yml', // The Python runtime ships its own default composition inside the wheel. 'python/*/src/**/cordis.yml', ] diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts index 6c1304e16a..e889209516 100644 --- a/scripts/verify-cordis-config.spec.ts +++ b/scripts/verify-cordis-config.spec.ts @@ -4,8 +4,46 @@ * metadata field must stay static, and a disabled expression must parse. */ +import { globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { metadataExpressionErrors } from './verify-cordis-config.ts' +import { + bundleManifestPaths, + bundlePluginDependencyErrors, + metadataExpressionErrors, +} from './verify-cordis-config.ts' + +interface WorkspaceManifest { + name?: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record +} + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)) + +function productionClosure(entry: string): Set { + const manifests = new Map() + for (const path of globSync(['apps/*/package.json', 'packages/*/*/package.json'], { cwd: repoRoot })) { + const manifest = JSON.parse(readFileSync(join(repoRoot, path), 'utf8')) as WorkspaceManifest + if (manifest.name !== undefined) manifests.set(manifest.name, manifest) + } + const visited = new Set() + const pending = [entry] + for (let name = pending.pop(); name !== undefined; name = pending.pop()) { + if (visited.has(name)) continue + visited.add(name) + const manifest = manifests.get(name) + pending.push( + ...Object.keys(manifest?.dependencies ?? {}), + ...Object.keys(manifest?.optionalDependencies ?? {}), + ...Object.keys(manifest?.peerDependencies ?? {}), + ) + } + return visited +} describe('verify-cordis-config metadata expressions', () => { it('accepts a disabled !!js expression', () => { @@ -37,3 +75,61 @@ describe('verify-cordis-config metadata expressions', () => { expect(problems.some(problem => problem.includes('[0].disabled: disabled expression does not parse'))).toBe(true) }) }) + +describe('workspace Bundle discovery and product dependency closures', () => { + it('discovers a Bundle outside packages/bundle from its manifest declaration', () => { + const fixture = mkdtempSync(join(tmpdir(), 'dsh-bundle-discovery-')) + try { + const bundleDir = join(fixture, 'packages/subagent/example') + const plainDir = join(fixture, 'packages/bundle/plain') + mkdirSync(bundleDir, { recursive: true }) + mkdirSync(plainDir, { recursive: true }) + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-subagent-example', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(plainDir, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-plain', + })) + + expect(bundleManifestPaths(fixture)).toEqual([ + 'packages/subagent/example/package.json', + ]) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }) + + it('allows a Bundle to mount itself but rejects an undeclared plugin package', () => { + const manifestPath = 'packages/subagent/example/package.json' + const file = 'packages/subagent/example/cordis.patch.yml' + const manifest = { + name: '@deepseek-ai/dsh-subagent-example', + dependencies: {}, + } + const self = { file, name: '@deepseek-ai/dsh-subagent-example' } + expect(bundlePluginDependencyErrors(manifestPath, manifest, [self])).toEqual([]) + expect(bundlePluginDependencyErrors(manifestPath, manifest, [ + self, + { file, name: '@deepseek-ai/dsh-missing-plugin' }, + ])).toEqual([ + `${file}: @deepseek-ai/dsh-missing-plugin must be declared in ${manifestPath} dependencies`, + ]) + }) + + it('keeps the default and two optional product closures independent', () => { + const shipped = productionClosure('@deepseek-ai/dsh') + expect(shipped).not.toContain('@deepseek-ai/dsh-subagent-codex') + expect(shipped).not.toContain('@deepseek-ai/dsh-subagent-claude-code') + expect(shipped).not.toContain('@anthropic-ai/claude-agent-sdk') + + const codex = productionClosure('@deepseek-ai/dsh-subagent-codex') + expect(codex).toContain('@deepseek-ai/dsh-sdk-protocol') + expect(codex).not.toContain('@deepseek-ai/dsh-subagent-claude-code') + expect(codex).not.toContain('@anthropic-ai/claude-agent-sdk') + + const claudeCode = productionClosure('@deepseek-ai/dsh-subagent-claude-code') + expect(claudeCode).toContain('@anthropic-ai/claude-agent-sdk') + expect(claudeCode).not.toContain('@deepseek-ai/dsh-subagent-codex') + }) +}) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index bb10db0c70..62e641996e 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -20,12 +20,14 @@ interface JsExpr { __jsExpr: string } -interface PackageManifest { +export interface PackageManifest { name?: string dependencies?: Record + optionalDependencies?: Record + dsh?: { bundle?: { patch?: string } } } -interface PluginReference { +export interface PluginReference { file: string name: string } @@ -260,11 +262,14 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const violations: string[] = [] + const bundleManifests = bundleManifestPaths() // App overlays (and any config left under apps/cli/config) resolve from the // dsh app's own dependency surface — the profile module fallback mirrors it. const appDependencies = { ...readManifest('apps/cli/package.json').dependencies, - // The fallback also links every bundle's own dependencies (healProfilesModuleFallback). + // The fallback also links every in-box bundle's own dependencies + // (healProfilesModuleFallback). Optional Profile bundles stay outside the + // app installation until that Profile installs them. ...Object.fromEntries(globSync('packages/bundle/*/package.json', { cwd: root }) .flatMap(file => Object.entries(readManifest(file).dependencies ?? {}))), } @@ -274,20 +279,49 @@ function validateAppResolution(): string[] { violations.push(...missingPluginDependencies(appReferences, appDependencies, 'apps/cli/package.json or a bundle manifest')) // Each bundle's patch rows must resolve from that bundle's own dependencies: // per-layer resolution anchors on the bundle package directory. - for (const manifestPath of globSync('packages/bundle/*/package.json', { cwd: root })) { + for (const manifestPath of bundleManifests) { const bundleDir = manifestPath.replace(/\/package\.json$/, '') const manifest = readManifest(manifestPath) - const references = pluginReferences.filter(reference => reference.file.startsWith(`${bundleDir}/`)) - violations.push(...missingPluginDependencies( - // A bundle may mount its own package (the web-app runtime row). - references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), - manifest.dependencies ?? {}, - manifestPath, - )) + const patch = manifest.dsh?.bundle?.patch + if (typeof patch !== 'string') continue + const patchFile = relative(root, resolve(root, bundleDir, patch)).replaceAll('\\', '/') + const references = pluginReferences.filter(reference => reference.file === patchFile) + violations.push(...bundlePluginDependencyErrors(manifestPath, manifest, references)) } return violations } +/** + * Discover workspace Bundle packages from their manifest declaration. + * @param repoRoot Repository root to scan. + * @returns Sorted repository-relative package manifest paths. + */ +export function bundleManifestPaths(repoRoot: string = root): string[] { + return globSync('packages/*/*/package.json', { cwd: repoRoot }) + .filter(path => typeof readManifest(path, repoRoot).dsh?.bundle?.patch === 'string') + .sort() +} + +/** + * Validate plugin packages referenced by one Bundle patch. + * @param manifestPath Repository-relative Bundle manifest path. + * @param manifest Parsed Bundle manifest. + * @param references Plugin rows read from the Bundle package directory. + * @returns Missing production dependency diagnostics. + */ +export function bundlePluginDependencyErrors( + manifestPath: string, + manifest: PackageManifest, + references: readonly PluginReference[], +): string[] { + return missingPluginDependencies( + // A Bundle may mount its own package (for example, its provider or runtime row). + references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), + manifest.dependencies ?? {}, + manifestPath, + ) +} + /** * Every configured specifier of a local workspace package must resolve through * the tsconfig `paths` facade to a `.ts`/`.tsx` source file. The `dsh` source @@ -363,8 +397,8 @@ function missingPluginDependencies( : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`) } -function readManifest(path: string): PackageManifest { - return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest +function readManifest(path: string, repoRoot: string = root): PackageManifest { + return JSON.parse(readFileSync(resolve(repoRoot, path), 'utf8')) as PackageManifest } function localPackageDirectories(): Map { From 208f37157abc37964d3fffee3a9c44cafa84bc33 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 13 Aug 2026 16:41:25 +0800 Subject: [PATCH 04/95] fix(subagent): simplify optional provider delivery --- ...ludes-product-subagent-providers.i18n.yaml | 4 +-- ...dsh-excludes-product-subagent-providers.md | 2 +- ...-excludes-product-subagent-providers.zh.md | 2 +- .../editing-cordis-compositions/SKILL.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/tests/web-agent-presets.e2e.ts | 28 ++++++++++--------- examples/acp-agent/tests/acp.snapshot.ts | 13 ++++++++- .../tests/snapshots/skill-load/input.json | 2 +- .../tests/snapshots/skill-load/session.jsonl | 18 ++++++------ .../subagent-claude-code/package.json | 1 - .../tests/subagent-claude-code.spec.ts | 2 -- packages/subagent/subagent-codex/package.json | 1 - .../tests/subagent-codex.spec.ts | 2 -- scripts/check-workspace-constraints.ts | 18 +++++++----- 16 files changed, 57 insertions(+), 46 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml index d021cfbab7..7d14cbe963 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md -2026-08-12-production-dsh-excludes-product-subagent-providers.md: 94cfe82d0aa42076f3c0723ed99a83a1e53e3724 -2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: a9dbdcff748ea46dc84c1480e6e50a945219a5c7 +2026-08-12-production-dsh-excludes-product-subagent-providers.md: 53551ad06ce7b735620d605669ddb5ca2d20aef5 +2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: a5ff017e2717dee326e8aaf7987ab8360452902f diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md index 94cfe82d0a..53551ad06c 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md @@ -16,7 +16,7 @@ The two Bundles remain independent. The Codex Bundle owns its `@deepseek-ai/dsh- ## Verification -Package tests pin each Bundle manifest, exported patch, exact self-provider row, and product-specific runtime dependency. Workspace validation discovers Bundle manifests by declaration rather than directory. Production-closure tests prove the default, Codex-only, and Claude-only dependency boundaries, while real Bundle-patch and Agent-Preset composition covers all four installed sets against all four tool sets and proves composition starts no product process. The base bundle test continues to reject both provider dependencies and configuration rows. +Package tests pin each Bundle manifest, published patch, exact self-provider row, and product-specific runtime dependency. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Production-closure tests prove the default, Codex-only, and Claude-only dependency boundaries, while real Bundle-patch and Agent-Preset composition covers all four installed sets, the full tool-grant matrix on a Host with both providers, representative missing-provider cases, and zero product processes. The base bundle test continues to reject both provider dependencies and configuration rows. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md index a9dbdcff74..a5ff017e27 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 验证 -包测试会固定每个 Bundle 的 manifest、导出的 patch、准确的自身提供方行以及产品专属运行时依赖。工作区验证会按 Bundle 声明发现 manifest,而非按目录发现。生产闭包测试证明默认、仅 Codex 与仅 Claude 三种依赖边界;真实 Bundle patch 与 Agent Preset 的组装会覆盖四种安装集合与四种工具集合的完整矩阵,并证明组装不会启动产品进程。base 组合包测试仍会拒绝这两个提供方依赖与配置行。 +包测试会固定每个 Bundle 的 manifest、发布 patch、准确的自身提供方行以及产品专属运行时依赖。工作区验证会从 Bundle 声明派生每个发布 patch,而非维护包目录。生产闭包测试证明默认、仅 Codex 与仅 Claude 三种依赖边界;真实 Bundle patch 与 Agent Preset 的组装会覆盖四种安装集合、同时安装两个提供方时的完整工具授权矩阵、缺失提供方的代表场景以及零产品进程。base 组合包测试仍会拒绝这两个提供方依赖与配置行。 ## 考虑过的替代方案 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 2eff9a9cce..68999a7ed6 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 @@ -154,7 +154,7 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o maxDepth: provider-managed ``` -The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must already provide `codex` or `claude` on `PATH`; neither the Bundle nor the preset installs, authenticates, selects a model for, starts, or probes either product. +The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must already provide `codex` or `claude` on `PATH`; neither the Bundle nor the preset starts, authenticates, selects a model for, probes, or manages a host-level installation of either product. The Claude Code Bundle's Agent SDK dependency still carries its platform CLI payload, which production ignores in favor of the host's `claude`. ## What not to move into a preset diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 5b97a26ad2..6d2f457b01 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: c220fd68cb0d79d2060e46a33d8af54c249a9c68 -README.zh.md: 15f026c79665ae2978bdfd65c321b05c10cc06e4 +README.md: dcbe28fab05031b2f176e5a20df77967d2d97885 +README.zh.md: 0cd7216615a58b206b441096292e1a19a73a01b3 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index c220fd68cb..dcbe28fab0 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -52,7 +52,7 @@ dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` retain their existing hot-reload behavior. On the next start, each installed product Bundle registers only its dormant Host provider and does not start, install, authenticate, or configure the native product. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the matching row before a new Agent can see that tool. Installing one provider never installs the other product package; the default dsh dependency closure includes neither provider nor the Claude Agent SDK. +The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` retain their existing hot-reload behavior. On the next start, each installed product Bundle registers only its dormant Host provider and does not start, authenticate, configure, or manage a host-level installation of the native product. The Claude Code Bundle's Agent SDK dependency still carries its platform CLI payload, which production ignores in favor of the host's `claude`. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the matching row before a new Agent can see that tool. Installing one provider never installs the other product package; the default dsh dependency closure includes neither provider nor the Claude Agent SDK. ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 15f026c796..0cd7216615 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -52,7 +52,7 @@ dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑仍保留既有热重载行为。下一次启动时,每个已安装的产品 Bundle 只注册自己的休眠 Host provider,不会启动、安装、认证或配置原生产品。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用对应行,新 Agent 才能看到该工具。只安装一个 provider 不会安装另一个产品包;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK。 +pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑仍保留既有热重载行为。下一次启动时,每个已安装的产品 Bundle 只注册自己的休眠 Host provider,不会启动、认证、配置原生产品,也不会管理宿主级产品安装。Claude Code Bundle 的 Agent SDK 依赖仍携带平台 CLI 载荷,但生产环境会忽略该载荷并使用宿主提供的 `claude`。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用对应行,新 Agent 才能看到该工具。只安装一个 provider 不会安装另一个产品包;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK。 ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 5da62e0aba..77185270e9 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -437,6 +437,7 @@ describe('the shipped Web composition', () => { describe('product subagent Bundle and user-preset intersection', () => { const presetIds = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const type Product = 'codex' | 'claude-code' + type PresetId = typeof presetIds[number] async function bootProducts(installed: readonly Product[]): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) @@ -477,20 +478,20 @@ describe('product subagent Bundle and user-preset intersection', () => { } it('composes the intersection of installed Bundles and enabled preset rows', async () => { - const enabledByPreset = new Map([ - ['products-none', []], - ['products-codex', ['codex']], - ['products-claude', ['claude-code']], - ['products-both', ['codex', 'claude-code']], - ]) - const installations: Product[][] = [ - [], - ['codex'], - ['claude-code'], - ['codex', 'claude-code'], + const enabledByPreset: Record = { + 'products-none': [], + 'products-codex': ['codex'], + 'products-claude': ['claude-code'], + 'products-both': ['codex', 'claude-code'], + } + const scenarios: Array<{ installed: Product[]; presets: readonly PresetId[] }> = [ + { installed: [], presets: ['products-both'] }, + { installed: ['codex'], presets: ['products-both'] }, + { installed: ['claude-code'], presets: ['products-both'] }, + { installed: ['codex', 'claude-code'], presets: presetIds }, ] - for (const installed of installations) { + for (const { installed, presets } of scenarios) { const productCtx = await bootProducts(installed) const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { @@ -498,7 +499,8 @@ describe('product subagent Bundle and user-preset intersection', () => { .filter(name => name === 'codex' || name === 'claude-code') .sort()) .toEqual([...installed].sort()) - for (const [id, enabled] of enabledByPreset) { + for (const id of presets) { + const enabled = enabledByPreset[id] const handle = await productCtx.agents.create({ sessionId: SessionId(`preset-${id}-${installed.join('-') || 'none'}-${randomUUID()}`), setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index db4a2b5d2f..19bb86c298 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { mkdir, utimes, writeFile } from 'node:fs/promises' +import { copyFile, mkdir, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' @@ -28,6 +28,10 @@ const AGENT = { configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } +const EDITING_CORDIS_SKILL = fileURLToPath(new URL( + '../../../apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md', + import.meta.url, +)) // The Code Mode overlay configs (include-patched variants of cordis.yml; the // replay swap resolves each one's sibling `*cordis.snapshot.yml`). @@ -69,6 +73,12 @@ const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' +async function prepareEditingCordisSkillWorkspace(cwd: string): Promise { + const target = join(cwd, '.dsh', 'skills', 'editing-cordis-compositions', 'SKILL.md') + await mkdir(dirname(target), { recursive: true }) + await copyFile(EDITING_CORDIS_SKILL, target) +} + async function prepareDelimiterPathWorkspace(cwd: string): Promise { const dir = join(cwd, 'scope') await mkdir(dir, { recursive: true }) @@ -280,6 +290,7 @@ const SCENARIOS: Scenario[] = [ headerClass: 'skill', systemPromptSource: 'text-turn', toolSchemasSource: 'text-turn', + prepareWorkspace: prepareEditingCordisSkillWorkspace, }, { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, // web_fetch markdown rendering end to end: the overlay's loopback fixture diff --git a/examples/acp-agent/tests/snapshots/skill-load/input.json b/examples/acp-agent/tests/snapshots/skill-load/input.json index a5ee78bff6..48235fc667 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/input.json +++ b/examples/acp-agent/tests/snapshots/skill-load/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Load the snapshot-skill skill with the skill tool, then reply DONE." } + { "op": "prompt", "text": "Load the editing-cordis-compositions skill with the skill tool, then reply DONE." } ] } diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index ec369b492a..0f9608ae4c 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -1,25 +1,25 @@ {"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498773710,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498773710,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Load the editing-cordis-compositions skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"}]}} {"type":"turn/start","seq":1,"time":1785821378605,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821378605,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785498773754,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the editing-cordis-compositions skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785498773755,"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":"3fc7e2f8-90fc-496c-b516-700cef1d86f1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `editing-cordis-compositions`: Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing.\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"editing-cordis-compositions","description":"Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing."},{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"59831057-0914-4e8b-967d-ef7dc850a62a"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the editing-cordis-compositions ski","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1785498773756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730426819,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":10,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":11,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} {"type":"assistant/chunk","seq":12,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":13,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"snapshot-skill\"}"}}} +{"type":"assistant/chunk","seq":13,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"editing-cordis-compositions\"}"}}} {"type":"assistant/chunk","seq":14,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}} -{"type":"assistant/chunk","seq":15,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}}}} {"type":"assistant/chunk","seq":16,"time":1785498773765,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}} {"type":"assistant/chunk","seq":17,"time":1785730426828,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":18,"time":1785730426828,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fd7a47e-84c9-4d31-aa95-9939671ba0a5"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[10,11,12,13,14,15,16,17],"surfaceOp":"append"} -{"type":"tool/call","seq":19,"time":1785730426828,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} -{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}],"isError":false}],"role":"user","id":"1609c2f6-3bc5-4ade-95dd-29e7f7565987"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"assistant/message","seq":18,"time":1785730426828,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fd7a47e-84c9-4d31-aa95-9939671ba0a5"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[10,11,12,13,14,15,16,17],"surfaceOp":"append"} +{"type":"tool/call","seq":19,"time":1785730426828,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} +{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nCodex and Claude Code providers are optional Profile Bundles. Install only the products the Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\n```\n\nThe Bundle owns Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing either package with `dsh plugin --profile remove ` withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n enableRunInBackground: false\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n enableRunInBackground: false\n maxDepth: provider-managed\n```\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must already provide `codex` or `claude` on `PATH`; neither the Bundle nor the preset starts, authenticates, selects a model for, probes, or manages a host-level installation of either product. The Claude Code Bundle's Agent SDK dependency still carries its platform CLI payload, which production ignores in favor of the host's `claude`.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"fa340fc0-3edc-4a61-92b2-2c4d70c4b6d7"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","seq":21,"time":1785730426838,"data":{"turn":1,"step":1}} {"type":"step/start","seq":22,"time":1785730426848,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 473a37e3d8..e62b68ef3d 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -22,7 +22,6 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, 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 889e248983..0fb0e55a98 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 @@ -290,12 +290,10 @@ describe('task admission and package contracts', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dependencies?: Record - exports?: Record files?: string[] dsh?: { bundle?: { patch?: string } } } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') - expect(manifest.exports?.['./cordis.patch.yml']).toBe('./cordis.patch.yml') expect(manifest.files).toContain('cordis.patch.yml') expect(manifest.dependencies).toHaveProperty('@anthropic-ai/claude-agent-sdk') expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 246f59348e..fd4a3c3611 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -22,7 +22,6 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 05207c419a..4fa001a9f4 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -269,12 +269,10 @@ describe('task admission and package contracts', () => { const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dependencies?: Record peerDependencies?: Record - exports?: Record files?: string[] dsh?: { bundle?: { patch?: string } } } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') - expect(manifest.exports?.['./cordis.patch.yml']).toBe('./cordis.patch.yml') expect(manifest.files).toContain('cordis.patch.yml') expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-sdk-protocol') expect(manifest.peerDependencies).not.toHaveProperty('@deepseek-ai/dsh-sdk-protocol') diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index d55ad16401..e11672a055 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -84,6 +84,11 @@ interface PackageManifest { devDependencies?: Record dependencies?: Record optionalDependencies?: Record + dsh?: { + bundle?: { + patch?: string + } + } } /** One workspace manifest and its repo-relative path. */ @@ -131,12 +136,6 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib. - '@deepseek-ai/dsh-base': ['cordis.patch.yml'], - '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], - '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], - '@deepseek-ai/dsh-subagent-codex': ['cordis.patch.yml'], - '@deepseek-ai/dsh-subagent-claude-code': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], // The Python runtime uses a distinct closed-resolution bin; the public CLI // keeps config-owned bare-package resolution through lib/bin.js. @@ -154,7 +153,12 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl } function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { - const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] + const declaredPatch = manifest.dsh?.bundle?.patch + const bundleFiles = declaredPatch === undefined ? [] : [declaredPatch.replace(/^\.\//, '')] + const extras = [ + ...bundleFiles, + ...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []), + ] return [ 'lib/index.js', // Every package publishes its invariant ownership companion as a separate From bb3010a8dfb406a472115052754591c58ce09fa8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 13 Aug 2026 18:45:16 +0800 Subject: [PATCH 05/95] fix(subagent): use bundled Claude Code CLI --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 6 +- ...ct-subagent-providers-in-shared-host.zh.md | 6 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 6 +- ...ude-code-and-codex-subagent-backends.zh.md | 6 +- ...ludes-product-subagent-providers.i18n.yaml | 4 +- ...dsh-excludes-product-subagent-providers.md | 8 +- ...-excludes-product-subagent-providers.zh.md | 8 +- .../editing-cordis-compositions/SKILL.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/web/tests/skill-tool-row.e2e.ts | 10 +- .../snapshots/skill-tool-row/ui.expected.md | 10 +- .../tests/snapshots/skill-load/session.jsonl | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 16 ++-- .../subagent-claude-code/README.zh.md | 16 ++-- .../subagent-claude-code/src/index.ts | 6 -- .../subagent-claude-code/src/process.ts | 18 +--- .../subagent/subagent-claude-code/src/run.ts | 3 - .../tests/real-deepseek.e2e.ts | 3 +- .../tests/real-product.spec.ts | 40 +++----- .../tests/subagent-claude-code.spec.ts | 91 +++++++++++++------ 25 files changed, 144 insertions(+), 137 deletions(-) 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 index ba797746c5..e8572ed53d 100644 --- 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 @@ -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-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 6db6ca665532ec2b457859243fee5cdc750e954b -2026-08-10-product-subagent-providers-in-shared-host.zh.md: e6c221299d80e6e66858db607c6b4696942b8a61 +2026-08-10-product-subagent-providers-in-shared-host.md: 2b1a417f7e751b2edee7e3b23dd439feab1353ea +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 2f687b7dc9332680b8aa610f46668f197066c517 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 index 6db6ca6655..2b1a417f7e 100644 --- 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 @@ -16,15 +16,15 @@ When installed in a Profile, each product Bundle loads its fixed `codex` or `cla The [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) partially supersedes only this note's former default-inclusion choice: the base bundle excludes both providers, and each provider package owns its directly installable Bundle patch. This note continues to own process-wide Host placement whenever a product Bundle is installed. The provider-contract 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. Bundle 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 providers have different executable owners. Codex starts a host `codex` from `PATH`. The Claude Code Bundle installs its pinned Agent SDK and matching platform CLI; the provider lets that SDK choose the private native executable and passes the command through the shared subprocess owner without consulting or falling back to a host `claude`. Loading either Bundle only registers the provider and creates no product state, probes no version or authentication, and adds no product-specific setting. A missing Codex command or Claude platform payload, authentication failure, and other product failures remain local to the attempted delegation. ## Verification -Real composition loads the selected set of no product Bundle, Codex only, Claude Code only, or both, and crosses it with Agent Presets that grant none, either, or both tools. It proves the Host registry equals the installed Bundle set, model-visible tools equal the installed-and-granted intersection, and no product process starts during composition. Preset edit coverage retains generation isolation. Keyless ACP snapshots pin the model-visible tool schemas, while provider tests separately prove native executable resolution, failure, cancellation, and process-tree quiescence. +Real composition loads the selected set of no product Bundle, Codex only, Claude Code only, or both, and crosses it with Agent Presets that grant none, either, or both tools. It proves the Host registry equals the installed Bundle set, model-visible tools equal the installed-and-granted intersection, and no product process starts during composition. Preset edit coverage retains generation isolation. Keyless ACP snapshots pin the model-visible tool schemas, while provider tests separately prove host executable resolution for Codex, SDK platform-payload selection without fallback for Claude Code, failure, cancellation, and process-tree quiescence. ## Alternatives considered -**Keep both dormant providers in every base Profile.** This makes every matching Preset row immediately usable, but forces every production installation to carry both provider packages and the Claude Agent SDK even when neither integration is wanted. +**Keep both dormant providers in every base Profile.** This makes every matching Preset row immediately usable, but forces every production installation to carry both provider packages, the Claude Agent SDK, and its large platform CLI payload even when neither integration is wanted. **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. 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 index e6c221299d..2f687b7dc9 100644 --- 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 @@ -16,15 +16,15 @@ Status: implemented [生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只部分取代本说明先前关于默认包含提供方的选择:base 组合包排除两个提供方,每个提供方包负责其可直接安装的 Bundle patch。本说明继续负责产品 Bundle 安装后进程级的 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Bundle 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。 +两个提供方的可执行文件归属不同。Codex 会启动从 `PATH` 解析出的宿主 `codex`。Claude Code Bundle 会安装锁定的 Agent SDK 与匹配平台 CLI;提供方让 SDK 选择该私有原生可执行文件,再把命令交给共享子进程责任方,既不查询也不回退宿主 `claude`。加载任一 Bundle 只会注册提供方,不会创建产品状态、探测版本或身份验证,也不会新增产品专属设置。Codex 命令缺失、Claude 平台载荷缺失、身份验证失败和其他产品故障仍局限于发生问题的那次委派。 ## 验证 -真实组装会加载未安装产品 Bundle、仅安装 Codex、仅安装 Claude Code 或两者都安装这四种集合,并与不授权工具、仅授权其中一个或同时授权两者的 Agent Preset 完整交叉。测试证明 Host 注册表等于已安装 Bundle 集合,模型可见工具等于已安装且已授权集合的交集,并且组装期间不会启动产品进程。Preset 编辑覆盖继续证明代际隔离。无密钥 ACP(Agent Client Protocol)快照固定模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。 +真实组装会加载未安装产品 Bundle、仅安装 Codex、仅安装 Claude Code 或两者都安装这四种集合,并与不授权工具、仅授权其中一个或同时授权两者的 Agent Preset 完整交叉。测试证明 Host 注册表等于已安装 Bundle 集合,模型可见工具等于已安装且已授权集合的交集,并且组装期间不会启动产品进程。Preset 编辑覆盖继续证明代际隔离。无密钥 ACP(Agent Client Protocol)快照固定模型可见工具 schema,提供方测试则分别证明 Codex 的宿主可执行文件解析、Claude Code 的 SDK 平台载荷选择与无回退行为,以及失败、取消和进程树完全停稳。 ## 考虑过的替代方案 -**在每个 base Profile 中保留两个休眠提供方。** 这样每条匹配的 Preset 行都能立即使用,但即使用户不需要任一集成,每次生产安装仍会携带两个提供方包和 Claude Agent SDK。 +**在每个 base Profile 中保留两个休眠提供方。** 这样每条匹配的 Preset 行都能立即使用,但即使用户不需要任一集成,每次生产安装仍会携带两个提供方包、Claude Agent SDK 及其大型平台 CLI 载荷。 **存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 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 6bb4a7f909..856c199c8b 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: 80dc7ad8488b3ed557361ab3e88948e56763c860 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 705757e26bccc55eab0787eb440720a2b5a67601 +2026-08-04-claude-code-and-codex-subagent-backends.md: f9c7529db664d5b7bebcd77ba69fd974d6cad5b3 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 8b0e42507c5a0850d569bff3c9abe962b4a03fe0 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 80dc7ad848..f9c7529db6 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 /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. +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The provider omits `pathToClaudeCodeExecutable`, so the SDK selects Claude Code 2.1.220 from the matching OS, CPU, and Linux-libc platform package in its own optional dependency closure. The provider does not resolve or fall back to a host `claude`; an omitted, unsupported, missing, or damaged platform payload fails the first delegation at the SDK startup boundary. The provider uses the official `query()` entrypoint and passes the SDK's native `claude` or `claude.exe` command, arguments, cwd, environment, and forwarded signal from `spawnClaudeCodeProcess` 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 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. Loader and optional Bundle-composition evidence resolve the selected 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, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and 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. Unit coverage proves that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and surfaces the SDK's missing-payload error without host fallback. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader and optional Bundle-composition evidence resolve the selected product packages by name while starting neither product. 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. @@ -89,6 +89,6 @@ The project owner's distribution authorization is scoped to the official `@anthr Users delegate through two stable foreground tools backed by the official product integrations. Installed providers remain in the process-wide Host and tools remain per Preset under the [shared-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md); optional package availability and default exclusion are owned by the [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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. +Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Codex behavior depends on the deployment's installed CLI and native configuration; Claude Code behavior depends on the Bundle-pinned platform CLI plus native account 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. Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe. 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 705757e26b..8b0e42507c 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 /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。提供方会省略 `pathToClaudeCodeExecutable`,因此 SDK 会从自己的 optional dependency 闭包中,按操作系统、CPU 与 Linux libc 选择携带 Claude Code 2.1.220 的匹配平台包。提供方既不会解析也不会回退宿主 `claude`;省略 optional dependency、不受支持的平台,以及缺失或损坏的平台载荷,都会在第一次委派的 SDK 启动边界失败。提供方使用官方 `query()` 入口点,并把 SDK 的 `spawnClaudeCodeProcess` 给出的原生 `claude` 或 `claude.exe` 命令、参数、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,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与可选 Bundle 组装证据会按名称解析已选择的产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消和整棵进程树退出。单元覆盖会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖、直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并且在载荷缺失时原样暴露 SDK 错误且不回退宿主 CLI。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 与可选 Bundle 组装证据会按名称解析已选择的产品包且不启动产品。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -89,6 +89,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl 用户通过官方产品集成支持的两个稳定前台工具进行委派。已安装提供方位于进程级 Host、工具按 Preset 暴露,这些规则由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;可选包可用性与默认排除由[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 -每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 +每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。Codex 行为取决于部署环境中安装的 CLI 与原生配置;Claude Code 行为取决于 Bundle 锁定的平台 CLI,以及原生账户和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml index 7d14cbe963..b22e11f805 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md -2026-08-12-production-dsh-excludes-product-subagent-providers.md: 53551ad06ce7b735620d605669ddb5ca2d20aef5 -2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: a5ff017e2717dee326e8aaf7987ab8360452902f +2026-08-12-production-dsh-excludes-product-subagent-providers.md: b729115ead5ec6823b0c98815fa12f50022defdb +2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: ac10ae2b4f04ddc3997b5fe4bbb8f656742de547 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md index 53551ad06c..b729115ead 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md @@ -6,17 +6,17 @@ English | [中文](2026-08-12-production-dsh-excludes-product-subagent-providers ## Problem -`@deepseek-ai/dsh` receives the `@deepseek-ai/dsh-base` dependency closure. Including the Codex and Claude Code subagent providers there makes every production install download optional product integration code, including the Claude Agent SDK, even when neither integration is used. +`@deepseek-ai/dsh` receives the `@deepseek-ai/dsh-base` dependency closure. Including the Codex and Claude Code subagent providers there makes every production install download optional product integration code, including the Claude Agent SDK and its roughly 250 MB unpacked platform CLI payload, even when neither integration is used. ## Decision This decision partially supersedes only the default-inclusion part of the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. Each existing provider package is instead a directly installable Profile Bundle whose `dsh.bundle.patch` points to one package-owned `cordis.patch.yml`. That patch contributes exactly one self-provider Host row and no Agent tool row. -The two Bundles remain independent. The Codex Bundle owns its `@deepseek-ai/dsh-sdk-protocol` runtime dependency; the Claude Code Bundle owns its Agent SDK runtime dependency. Installing one does not pull in the other, and the default `@deepseek-ai/dsh` production closure contains neither provider nor the Claude Agent SDK. An installed Bundle registers a dormant provider on the next Profile start, while an Agent Preset independently decides whether a new Session receives the corresponding tool. Installation does not start, authenticate, configure, or grant model access to either product. +The two Bundles remain independent. The Codex Bundle owns its `@deepseek-ai/dsh-sdk-protocol` runtime dependency and continues to use a host `codex` from `PATH`. The Claude Code Bundle owns the pinned Agent SDK and the matching platform CLI selected from the SDK's optional dependencies; production uses that private CLI and never falls back to a host `claude`. Installing one Bundle does not pull in the other, and the default `@deepseek-ai/dsh` production closure contains neither provider, the Claude Agent SDK, nor its platform payloads. An installed Bundle registers a dormant provider on the next Profile start, while an Agent Preset independently decides whether a new Session receives the corresponding tool. Installation brings only the selected package closure onto disk; it does not start a product, authenticate an account, rewrite native settings, or grant model access. ## Verification -Package tests pin each Bundle manifest, published patch, exact self-provider row, and product-specific runtime dependency. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Production-closure tests prove the default, Codex-only, and Claude-only dependency boundaries, while real Bundle-patch and Agent-Preset composition covers all four installed sets, the full tool-grant matrix on a Host with both providers, representative missing-provider cases, and zero product processes. The base bundle test continues to reject both provider dependencies and configuration rows. +Package tests pin each Bundle manifest, published patch, exact self-provider row, and product-specific runtime closure. Claude coverage pins Agent SDK 0.3.220, Claude Code 2.1.220, all eight platform package identities and versions, the SDK-selected executable entering the shared subprocess owner, and first-delegation failure without host fallback when the payload is missing. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Production-closure tests prove the default, Codex-only, and Claude-only dependency boundaries, while real Bundle-patch and Agent-Preset composition covers all four installed sets, the full tool-grant matrix on a Host with both providers, representative missing-provider cases, and zero product processes. The base bundle test continues to reject both provider dependencies and configuration rows. ## Alternatives considered @@ -26,4 +26,4 @@ Package tests pin each Bundle manifest, published patch, exact self-provider row ## Consequences -Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. A Profile can add or remove either provider package, or both, directly; the changed Host availability takes effect on the next Profile start. A separately authored Agent Preset still grants the model-visible tool only to newly composed Sessions. No wrapper package, meta Bundle, dynamic installer, or persisted product-enable state is introduced. +Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. A Profile can add or remove either provider package, or both, directly; the changed Host availability takes effect on the next Profile start. Selecting Claude Code explicitly accepts its SDK and one large platform CLI payload, while selecting Codex does not install a product CLI. A separately authored Agent Preset still grants the model-visible tool only to newly composed Sessions. No wrapper package, meta Bundle, dynamic installer, or persisted product-enable state is introduced. diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md index a5ff017e27..ac10ae2b4f 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md @@ -6,17 +6,17 @@ Status: implemented ## 问题 -`@deepseek-ai/dsh` 会获得 `@deepseek-ai/dsh-base` 的依赖闭包。如果 base 包含 Codex 与 Claude Code subagent 提供方,每次生产安装都会下载可选的产品集成代码,包括 Claude Agent SDK,即使用户并未使用任一集成。 +`@deepseek-ai/dsh` 会获得 `@deepseek-ai/dsh-base` 的依赖闭包。如果 base 包含 Codex 与 Claude Code subagent 提供方,每次生产安装都会下载可选的产品集成代码,包括 Claude Agent SDK 及其解包后约 250 MB 的平台 CLI 载荷,即使用户并未使用任一集成。 ## 决策 本决策只部分取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)中关于默认包含提供方的部分:`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。现有的每个提供方包改为可直接安装的 Profile Bundle,其 `dsh.bundle.patch` 指向包自身拥有的 `cordis.patch.yml`。该 patch 恰好贡献一条挂载自身提供方的 Host 行,不包含 Agent 工具行。 -两个 Bundle 彼此独立。Codex Bundle 自己负责运行时依赖 `@deepseek-ai/dsh-sdk-protocol`;Claude Code Bundle 自己负责 Agent SDK 运行时依赖。安装其中一个不会带入另一个,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一提供方,也不包含 Claude Agent SDK。已安装的 Bundle 会在下次 Profile 启动时注册一个休眠提供方,而 Agent Preset 独立决定新 Session 是否获得对应工具。安装不会启动产品、验证身份、配置产品或向模型授予任一产品的访问权。 +两个 Bundle 彼此独立。Codex Bundle 自己负责运行时依赖 `@deepseek-ai/dsh-sdk-protocol`,并继续使用 `PATH` 中的宿主 `codex`。Claude Code Bundle 自己负责锁定的 Agent SDK,以及从 SDK optional dependencies 中选出的匹配平台 CLI;生产运行只使用该私有 CLI,绝不会回退到宿主 `claude`。安装其中一个 Bundle 不会带入另一个,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一提供方,也不包含 Claude Agent SDK 或其平台载荷。已安装的 Bundle 会在下次 Profile 启动时注册一个休眠提供方,而 Agent Preset 独立决定新 Session 是否获得对应工具。安装只会把所选包闭包放到磁盘上;它不会启动产品、验证账户、改写原生设置或向模型授予访问权。 ## 验证 -包测试会固定每个 Bundle 的 manifest、发布 patch、准确的自身提供方行以及产品专属运行时依赖。工作区验证会从 Bundle 声明派生每个发布 patch,而非维护包目录。生产闭包测试证明默认、仅 Codex 与仅 Claude 三种依赖边界;真实 Bundle patch 与 Agent Preset 的组装会覆盖四种安装集合、同时安装两个提供方时的完整工具授权矩阵、缺失提供方的代表场景以及零产品进程。base 组合包测试仍会拒绝这两个提供方依赖与配置行。 +包测试会固定每个 Bundle 的 manifest、发布 patch、准确的自身提供方行以及产品专属运行时闭包。Claude 覆盖会固定 Agent SDK 0.3.220、Claude Code 2.1.220、八个平台包的身份与版本、SDK 所选可执行文件进入共享子进程责任方的路径,以及载荷缺失时第一次委派失败且不回退宿主 CLI。工作区验证会从 Bundle 声明派生每个发布 patch,而非维护包目录。生产闭包测试证明默认、仅 Codex 与仅 Claude 三种依赖边界;真实 Bundle patch 与 Agent Preset 的组装会覆盖四种安装集合、同时安装两个提供方时的完整工具授权矩阵、缺失提供方的代表场景以及零产品进程。base 组合包测试仍会拒绝这两个提供方依赖与配置行。 ## 考虑过的替代方案 @@ -26,4 +26,4 @@ Status: implemented ## 后果 -安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。Profile 可以直接添加或移除任一提供方包,也可以同时操作两者;Host 可用性的变化会在下次 Profile 启动时生效。单独创作的 Agent Preset 仍只会向新组装的 Session 授予模型可见工具。本决策不引入 wrapper 包、meta Bundle、动态安装程序或持久化的产品启用状态。 +安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。Profile 可以直接添加或移除任一提供方包,也可以同时操作两者;Host 可用性的变化会在下次 Profile 启动时生效。选择 Claude Code 代表明确接受其 SDK 与一个大型平台 CLI 载荷,而选择 Codex 不会安装产品 CLI。单独创作的 Agent Preset 仍只会向新组装的 Session 授予模型可见工具。本决策不引入 wrapper 包、meta Bundle、动态安装程序或持久化的产品启用状态。 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 68999a7ed6..dcf2194352 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 @@ -154,7 +154,7 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o maxDepth: provider-managed ``` -The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must already provide `codex` or `claude` on `PATH`; neither the Bundle nor the preset starts, authenticates, selects a model for, probes, or manages a host-level installation of either product. The Claude Code Bundle's Agent SDK dependency still carries its platform CLI payload, which production ignores in favor of the host's `claude`. +The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must provide `codex` on `PATH` for the Codex provider. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. Neither Bundle nor the preset starts a product during composition, authenticates an account, selects a model, probes credentials, or manages native product settings. ## What not to move into a preset diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 24ef2b7f35..38a333beea 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: dd4e9801452d6c80dc7054b17c70397536adc05c -README.zh.md: d029f0f42e2008eddc5ff07177f8eb6ab11fcbba +README.md: 4bb0502a9af9299fed991bb8a2279d74ccf97037 +README.zh.md: 1b641cefaa4f5ece496fc6af2447e45cd308f395 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index dd4e980145..4bb0502a9a 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -52,7 +52,7 @@ dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` retain their existing hot-reload behavior. On the next start, each installed product Bundle registers only its dormant Host provider and does not start, authenticate, configure, or manage a host-level installation of the native product. The Claude Code Bundle's Agent SDK dependency still carries its platform CLI payload, which production ignores in favor of the host's `claude`. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the matching row before a new Agent can see that tool. Installing one provider never installs the other product package; the default dsh dependency closure includes neither provider nor the Claude Agent SDK. +The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` retain their existing hot-reload behavior. On the next start, each installed product Bundle registers only its dormant Host provider and starts no product process. The Codex provider resolves a host `codex` from `PATH`; the Claude Code Bundle instead installs the pinned Agent SDK and one matching private platform CLI, uses only that CLI, and never falls back to a host `claude`. Authentication and native product settings remain user-managed for both products; the [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) discloses the current platform payload size and missing-payload failure. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the matching row before a new Agent can see that tool. Installing one provider never installs the other product package; the default dsh dependency closure includes neither provider nor the Claude Agent SDK or its platform payloads. ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d029f0f42e..1b641cefaa 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -52,7 +52,7 @@ dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑仍保留既有热重载行为。下一次启动时,每个已安装的产品 Bundle 只注册自己的休眠 Host provider,不会启动、认证、配置原生产品,也不会管理宿主级产品安装。Claude Code Bundle 的 Agent SDK 依赖仍携带平台 CLI 载荷,但生产环境会忽略该载荷并使用宿主提供的 `claude`。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用对应行,新 Agent 才能看到该工具。只安装一个 provider 不会安装另一个产品包;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK。 +pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑仍保留既有热重载行为。下一次启动时,每个已安装的产品 Bundle 只注册自己的休眠 Host provider,不会启动产品进程。Codex provider 会从 `PATH` 解析宿主 `codex`;Claude Code Bundle 则会安装锁定的 Agent SDK 与一个匹配的私有平台 CLI,只使用该 CLI,并且绝不会回退到宿主 `claude`。两个产品的身份验证与原生设置仍由用户管理;[Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)会披露当前平台载荷体积与载荷缺失时的失败行为。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用对应行,新 Agent 才能看到该工具。只安装一个 provider 不会安装另一个产品包;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK 或其平台载荷。 ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index af6c941bcd..3e18ff9548 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -17,7 +17,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-tool-row', import. const UI_EXPECTED = fileURLToPath(new URL('./snapshots/skill-tool-row/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'skill-tool-row-web-e2e' -const PROMPT = 'Load the snapshot-skill skill with the skill tool, then reply DONE.' +const PROMPT = 'Load the editing-cordis-compositions skill with the skill tool, then reply DONE.' describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { let scaffold: WebScaffold @@ -53,17 +53,17 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { it('expands the loaded skill to its exact recorded instructions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-tool-row')) const call = page.locator('[data-tool="skill"]') - const row = call.getByRole('button', { name: 'Skill snapshot-skill' }) + const row = call.getByRole('button', { name: 'Skill editing-cordis-compositions' }) await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') - expect(await call.getByText('snapshot-skill', { exact: true }).count()).toBe(1) + expect(await call.getByText('editing-cordis-compositions', { exact: true }).count()).toBe(1) await row.click() await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') await call.getByText('Instructions', { exact: true }).waitFor() const output = call.locator('pre') await output.waitFor() - expect(await output.textContent()).toContain('') - expect(await output.textContent()).toContain('Follow these snapshot-only instructions.') + expect(await output.textContent()).toContain('') + expect(await output.textContent()).toContain('The Claude Code Bundle installs and exclusively uses the matching platform CLI') expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px') const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index 6dfa55d454..fe2eecebad 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -1,13 +1,13 @@ - banner: - navigation "Session hierarchy": - - button "Load the snapshot-skill skill with" [disabled] + - button "Load the editing-cordis-compositions ski" [disabled] - button "Session log": - text: Session log - img - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}} +- text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": @@ -22,10 +22,10 @@ - img - img - text: Think Load the requested skill. -- button "Skill snapshot-skill" [expanded]: +- button "Skill editing-cordis-compositions" [expanded]: - img - - text: Skill snapshot-skill -- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. " + - text: Skill editing-cordis-compositions +- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents Codex and Claude Code providers are optional Profile Bundles. Install only the products the Profile needs, then restart the Profile so its Host registers those providers: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code ``` The Bundle owns Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing either package with `dsh plugin --profile remove ` withdraws only that provider on the next Profile start. 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 an installed matching provider, and enabling both exposes the installed intersection. The host must provide `codex` on `PATH` for the Codex provider. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. Neither Bundle nor the preset starts a product during composition, authenticates an account, selects a model, probes credentials, or manages native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " - button "Inspect" - button "Think The skill is loaded.": - img diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 0f9608ae4c..7292e114d4 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -19,7 +19,7 @@ {"type":"assistant/chunk","seq":17,"time":1785730426828,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":18,"time":1785730426828,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fd7a47e-84c9-4d31-aa95-9939671ba0a5"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[10,11,12,13,14,15,16,17],"surfaceOp":"append"} {"type":"tool/call","seq":19,"time":1785730426828,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} -{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nCodex and Claude Code providers are optional Profile Bundles. Install only the products the Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\n```\n\nThe Bundle owns Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing either package with `dsh plugin --profile remove ` withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n enableRunInBackground: false\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n enableRunInBackground: false\n maxDepth: provider-managed\n```\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must already provide `codex` or `claude` on `PATH`; neither the Bundle nor the preset starts, authenticates, selects a model for, probes, or manages a host-level installation of either product. The Claude Code Bundle's Agent SDK dependency still carries its platform CLI payload, which production ignores in favor of the host's `claude`.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"fa340fc0-3edc-4a61-92b2-2c4d70c4b6d7"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nCodex and Claude Code providers are optional Profile Bundles. Install only the products the Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\n```\n\nThe Bundle owns Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing either package with `dsh plugin --profile remove ` withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n enableRunInBackground: false\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n enableRunInBackground: false\n maxDepth: provider-managed\n```\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must provide `codex` on `PATH` for the Codex provider. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. Neither Bundle nor the preset starts a product during composition, authenticates an account, selects a model, probes credentials, or manages native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"fa340fc0-3edc-4a61-92b2-2c4d70c4b6d7"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","seq":21,"time":1785730426838,"data":{"turn":1,"step":1}} {"type":"step/start","seq":22,"time":1785730426848,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index cb854f43ed..e93da05c24 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: b1a5c4bb4b9d1c3221c38092d8b0b967888b7746 -README.zh.md: b17b78f03f47b35409e211d814dce444835058bd +README.md: 058e72a46cd6fb7c15779f3650e191535a8b2571 +README.zh.md: 05914165c26162de9c0a37cfa7090a8d667e63bc diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index b1a5c4bb4b..058e72a46c 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, 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. +This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, lets the pinned SDK select its installed platform CLI, 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 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. +Production omits `pathToClaudeCodeExecutable`, so Agent SDK 0.3.220 selects the matching native `claude` or `claude.exe` from its own platform package and passes that absolute command through the custom-spawn hook to `dsh-subprocess`. The provider does not inspect `PATH`, implement platform selection, or fall back to a host `claude`. Native settings and authentication remain authoritative. The plugin does not 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; `PATH` does not choose the Claude executable. -This package is an optional Profile Bundle. Install it into the target Profile, then restart that Profile; its declared `cordis.patch.yml` layer registers only the dormant `claude-code` Host provider and starts no Claude process. Removing the package withdraws that provider on the next Profile start. +This package is an optional Profile Bundle. Install it into the target Profile, then restart that Profile; installation brings the pinned Agent SDK and one compatible platform CLI payload into that Profile, while the declared `cordis.patch.yml` layer registers only the dormant `claude-code` Host provider and starts no Claude process. Removing the package withdraws that provider and its private runtime closure on the next Profile start. ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code @@ -63,7 +63,9 @@ Installation controls Host availability, not model permission. Full Agent Preset ## 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 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 runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose eight platform packages carry Claude Code 2.1.220. A normal install selects one payload for the current OS, CPU, and Linux libc. For the current darwin-arm64 payload, `npm pack --dry-run --json` reports 74,858,812 packed bytes and 256,908,856 unpacked bytes; other platforms may differ, and these values are disclosure rather than an installation threshold. The keyless real-product test runs the SDK-selected CLI against a loopback Messages fixture and asserts that the shared subprocess argv begins with that platform package's native executable. Loader composition proves that both product packages coexist without starting either product. + +Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload leaves provider registration dormant but makes the first delegation fail with the SDK's native-payload startup error. The provider neither probes a host CLI nor retries with one. 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. @@ -73,7 +75,7 @@ The project owner's identity-scoped distribution authorization covers the offici #### What the model sees -The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation. +The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from native Claude settings; the executable version comes from the Bundle's pinned SDK platform payload. #### Token effect @@ -101,8 +103,8 @@ 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. +- **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, or rewrite Claude settings; configuration and authentication failures surface as startup or run errors. +- **The SDK platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first query; there is no host-CLI fallback. - **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 b17b78f03f..05914165c2 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,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,让锁定版本的 SDK 选择随包安装的平台 CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 ## 启动与所有权 @@ -29,9 +29,9 @@ 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 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境会省略 `pathToClaudeCodeExecutable`,因此 Agent SDK 0.3.220 会从自己的平台包中选择匹配的原生 `claude` 或 `claude.exe`,再通过 custom-spawn 钩子把该绝对命令交给 `dsh-subprocess`。提供方不会检查 `PATH`、重复实现平台选择,也不会回退到宿主 `claude`。原生设置与身份验证继续是权威来源。本插件不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承;`PATH` 不参与选择 Claude 可执行文件。 -本包是可选的 Profile Bundle。将它安装进目标 Profile 后重启该 Profile;包所声明的 `cordis.patch.yml` 层只注册休眠的 `claude-code` Host provider,不会启动 Claude 进程。移除该包后,下一次 Profile 启动会撤回这一 provider。 +本包是可选的 Profile Bundle。将它安装进目标 Profile 后重启该 Profile;安装会把锁定的 Agent SDK 与一个兼容的平台 CLI 载荷带入该 Profile,而包所声明的 `cordis.patch.yml` 层只注册休眠的 `claude-code` Host provider,不会启动 Claude 进程。移除该包后,下一次 Profile 启动会撤回这一 provider 及其私有运行时闭包。 ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code @@ -63,7 +63,9 @@ dsh --profile ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装。无密钥真实产品测试使用由 SDK 分发的 Claude Code 2.1.220 CLI 作为确定性 fixture(测试前置数据),并通过同一套原生可执行文件解析路径与 Windows batch shim 路径运行;这项测试不声称兼容每个独立安装的版本。Loader 组合证明两个产品包能够共存且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其八个平台包都携带 Claude Code 2.1.220。普通安装会按当前操作系统、CPU 及 Linux libc 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json` 报告压缩包为 74,858,812 字节、解包后为 256,908,856 字节;其他平台可能不同,这些数值只用于披露而不是安装阈值。无密钥真实产品测试会让 SDK 选择 CLI,通过回环 Messages fixture 运行它,并断言共享子进程 argv 的首项就是该平台包的原生可执行文件。Loader 组合证明两个产品包能够共存且不会启动任一产品。 + +如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,提供方注册仍保持休眠,但第一次委派会以 SDK 的原生载荷启动错误失败。提供方既不会探测宿主 CLI,也不会用它重试。 限定于项目所有者身份的分发授权涵盖官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会认定其中声明的条款属于宽松许可;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -73,7 +75,7 @@ dsh --profile #### 模型看到的内容 -Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。 +Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自原生 Claude 设置,可执行版本则来自 Bundle 锁定的 SDK 平台载荷。 #### 对 token 的影响 @@ -101,8 +103,8 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 -- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 -- **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 +- **身份验证与账户状态仍由原生机制管理**:Bundle 会提供 CLI,但不会创建账户、登录或改写 Claude 设置;配置与身份验证失败会呈现为启动错误或运行错误。 +- **委派时必须存在 SDK 平台载荷**:省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次 query 时失败;不会回退到宿主 CLI。 - **没有人工交互路径**:`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 ccd150b746..fef8a5141e 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -66,18 +66,12 @@ class ClaudeCodeProvider implements SubagentProvider { '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 1e2a259ca2..32a545bf08 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -6,7 +6,6 @@ */ import { EventEmitter } from 'node:events' -import { extname } from 'node:path' import type { SpawnedProcess, SpawnOptions, @@ -17,8 +16,6 @@ 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)) @@ -43,33 +40,22 @@ 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. - * @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, 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 batchShim = platform === 'win32' && (extension === '.cmd' || extension === '.bat') - const env = sdkEnvironmentOverlay(options.env) - const argv = batchShim - ? ['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 { - argv, + argv: [options.command, ...options.args], cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env, + env: sdkEnvironmentOverlay(options.env), } } diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 6c1e0a8dbf..9dc4d740ac 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -44,8 +44,6 @@ 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. */ @@ -182,7 +180,6 @@ 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 89d08a1878..1881e14d33 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 { delimiter, dirname, join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' @@ -87,7 +87,6 @@ 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 f6767817c8..768952887f 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -3,12 +3,12 @@ import { mkdirSync, mkdtempSync, readFileSync, - symlinkSync, + realpathSync, writeFileSync, } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import type { @@ -119,7 +119,6 @@ interface RealHarness { readonly parent: Agent readonly workspace: string readonly env: Record - readonly executable: string } async function realHarness(behavior: MessagesBehavior): Promise<{ @@ -131,17 +130,9 @@ 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&%literal%!bang!bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) - mkdirSync(nativeBin) - 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`, @@ -149,7 +140,6 @@ 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, @@ -183,7 +173,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ session: { header: { cwd: workspace } }, } as unknown as Agent return { - harness: { ctx, handles, spawnSpecs, parent, workspace, env, executable }, + harness: { ctx, handles, spawnSpecs, parent, workspace, env }, fixture, } } @@ -225,7 +215,7 @@ describe('real Claude Agent SDK 0.3.220 and its distributed 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(process.platform === 'win32' ? claudeBin : harness.executable, ['--version'], { + const version = await execFileAsync(claudeBin, ['--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') @@ -242,18 +232,16 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') - if (process.platform === 'win32') { - expect(harness.spawnSpecs[0]?.argv.slice(0, 6)).toEqual([ - 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_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) - } + const spawnedExecutable = harness.spawnSpecs[0]?.argv[0] + expect(spawnedExecutable).toBeDefined() + expect(process.platform === 'win32' + ? realpathSync(spawnedExecutable!).toLowerCase() + : realpathSync(spawnedExecutable!)) + .toBe(process.platform === 'win32' + ? realpathSync(claudeBin).toLowerCase() + : realpathSync(claudeBin)) + expect(harness.spawnSpecs[0]?.env) + .not.toHaveProperty('DSH_CLAUDE_CODE_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 0fb0e55a98..9d1bd7c6bc 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 @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import { PassThrough } from 'node:stream' import { fileURLToPath } from 'node:url' import type { @@ -56,6 +56,19 @@ type QueryFactory = (params: { const queryMock = vi.hoisted(() => vi.fn()) +const CLAUDE_AGENT_SDK_VERSION = '0.3.220' +const CLAUDE_CODE_VERSION = '2.1.220' +const CLAUDE_PLATFORM_PACKAGES = [ + '@anthropic-ai/claude-agent-sdk-darwin-arm64', + '@anthropic-ai/claude-agent-sdk-darwin-x64', + '@anthropic-ai/claude-agent-sdk-linux-arm64', + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl', + '@anthropic-ai/claude-agent-sdk-linux-x64', + '@anthropic-ai/claude-agent-sdk-linux-x64-musl', + '@anthropic-ai/claude-agent-sdk-win32-arm64', + '@anthropic-ai/claude-agent-sdk-win32-x64', +] as const + vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({ ...await importOriginal(), query: queryMock, @@ -252,7 +265,6 @@ function fakeRun( const options: FakeRun['options'] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', - executable: '/native/claude', env: { ANTHROPIC_API_KEY: 'fake-key' }, disposeGraceMs: 5, spawn: (spawnSpec) => { @@ -295,9 +307,41 @@ describe('task admission and package contracts', () => { } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') expect(manifest.files).toContain('cordis.patch.yml') - expect(manifest.dependencies).toHaveProperty('@anthropic-ai/claude-agent-sdk') + expect(manifest.dependencies).toHaveProperty( + '@anthropic-ai/claude-agent-sdk', + CLAUDE_AGENT_SDK_VERSION, + ) expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') + const sdkRoot = dirname(fileURLToPath( + import.meta.resolve('@anthropic-ai/claude-agent-sdk'), + )) + const sdkManifest = JSON.parse(readFileSync( + resolve(sdkRoot, 'package.json'), + 'utf8', + )) as { + version: string + claudeCodeVersion: string + optionalDependencies: Record + } + expect(sdkManifest.version).toBe(CLAUDE_AGENT_SDK_VERSION) + expect(sdkManifest.claudeCodeVersion).toBe(CLAUDE_CODE_VERSION) + expect(sdkManifest.optionalDependencies).toEqual(Object.fromEntries( + CLAUDE_PLATFORM_PACKAGES.map(packageName => [ + packageName, + CLAUDE_AGENT_SDK_VERSION, + ]), + )) + const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8') + for (const packageName of CLAUDE_PLATFORM_PACKAGES) { + expect(lockfile).toContain( + ` '${packageName}@${CLAUDE_AGENT_SDK_VERSION}':`, + ) + expect(lockfile).toContain( + ` '${packageName}': ${CLAUDE_AGENT_SDK_VERSION}`, + ) + } + const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) const rows = Array.isArray(parsed) ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) @@ -360,7 +404,7 @@ describe('task admission and package contracts', () => { const spawn = vi.spyOn(ctx.subprocess, 'spawn') .mockImplementation(() => child.handle) const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable') - .mockResolvedValue('/native/claude') + .mockResolvedValue('/host/bin/claude') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) await ctx.plugin(claudeCode, { env: { @@ -382,10 +426,15 @@ describe('task admission and package contracts', () => { ) expect(queryMock).not.toHaveBeenCalled() - resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) + vi.stubEnv('PATH', '/host/bin') + queryMock.mockImplementationOnce(() => { + throw new Error( + 'Native CLI binary for fixture-platform not found. Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set options.pathToClaudeCodeExecutable.', + ) + }) await expect(ctx.subagents.start('claude-code', request())) - .rejects.toThrow('claude missing from PATH') - expect(queryMock).not.toHaveBeenCalled() + .rejects.toThrow('Native CLI binary for fixture-platform not found') + expect(resolveExecutable).not.toHaveBeenCalled() const run = await ctx.subagents.start('claude-code', request()) child.settle({ exitCode: 9, signal: null }) @@ -397,13 +446,9 @@ 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(resolveExecutable).not.toHaveBeenCalled() + expect(queryMock.mock.calls[1]?.[0].options) + .not.toHaveProperty('pathToClaudeCodeExecutable') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: process.cwd(), graceMs: 29, @@ -483,20 +528,17 @@ 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}` + it('forwards the SDK-selected Windows native executable without a batch shim', () => { + const command = String.raw`C:\Program Files\Claude\claude.exe` const spec = claudeSpawnSpec(sdkSpawnOptions({ command, args: ['--output-format', 'stream-json'], - }), 7, 'win32') + }), 7) expect(spec.argv).toEqual([ - 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', - '--output-format', 'stream-json', + command, '--output-format', 'stream-json', ]) - expect(spec.env).toEqual(expect.objectContaining({ - DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`, - })) + expect(spec.env).not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE') }) it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { @@ -566,7 +608,6 @@ 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', @@ -582,10 +623,10 @@ describe('query options and result mapping', () => { expect(options).toMatchObject({ abortController: controller, cwd: '/workspace', - pathToClaudeCodeExecutable: '/native/claude', persistSession: false, disallowedTools: ['AskUserQuestion'], }) + expect(options).not.toHaveProperty('pathToClaudeCodeExecutable') expect(options.env).toMatchObject({ HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', @@ -730,7 +771,6 @@ describe('run publication, cancellation, and settlement', () => { let index = 0 const spec: ClaudeCodeRunSpec = { cwd: '/workspace', - executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, @@ -781,7 +821,6 @@ describe('run publication, cancellation, and settlement', () => { request(undefined, parentAbort.signal), { cwd: '/workspace', - executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => child.handle, From 8fa6ad9132561c66c3016a024a4b1a6c1d141db1 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 13 Aug 2026 19:34:56 +0800 Subject: [PATCH 06/95] fix(subagent): preserve Claude spawn diagnostics --- .../subagent/subagent-claude-code/src/run.ts | 41 ++++++++++-- .../tests/subagent-claude-code.spec.ts | 63 ++++++++++++++++++- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 9dc4d740ac..6b20f2f838 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -58,6 +58,11 @@ function thrown(value: unknown): Error { /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) } + +/** Read live request cancellation across awaited startup cleanup. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} /* jscpd:ignore-end */ /** @@ -236,12 +241,39 @@ export async function startClaudeCodeRun( request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = controller.signal.aborted requestCancel() + const startupError = thrown(error) + if (child !== undefined && child.pid <= 0) { + let closeError: Error | undefined + try { + query?.close() + } catch (disposeError: unknown) { + closeError = thrown(disposeError) + } + + let spawnError = startupError + try { + await child.done + } catch (childError: unknown) { + spawnError = thrown(childError) + } + + if (cancelledBeforeCleanup || isAborted(request.signal)) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + if (closeError !== undefined) { + throw new AggregateError( + [spawnError, closeError], + `subagent-claude-code: Claude Code process startup failed: ${spawnError.message}; query cleanup also failed`, + ) + } + throw spawnError + } if (child !== undefined) { try { await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [startupError, thrown(disposeError)], 'subagent-claude-code: startup failed and CLI cleanup also failed', ) } @@ -250,16 +282,15 @@ export async function startClaudeCodeRun( query.close() } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [startupError, thrown(disposeError)], 'subagent-claude-code: startup failed and query cleanup also failed', ) } } - // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. - if (cancelledBeforeCleanup || request.signal.aborted) { + if (cancelledBeforeCleanup || isAborted(request.signal)) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw thrown(error) + throw startupError } const publishedQuery = query 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 9d1bd7c6bc..34d0fb150b 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 @@ -908,14 +908,73 @@ describe('run publication, cancellation, and settlement', () => { expect(factoryController?.signal.aborted).toBe(true) expect(spawned.terminate).toHaveBeenCalledOnce() + const spawnError = Object.assign( + new Error('spawn /sdk/claude EACCES'), + { code: 'EACCES', path: '/sdk/claude' }, + ) const failedSpawn = fakeChild({ pid: -1, - doneError: new Error('spawn failed'), + doneError: spawnError, }) const failed = fakeRun([], undefined, failedSpawn) await expect(startClaudeCodeRun(request(), failed.spec)) - .rejects.toBeInstanceOf(AggregateError) + .rejects.toBe(spawnError) expect(failed.close).toHaveBeenCalledOnce() + expect(failedSpawn.terminate).not.toHaveBeenCalled() + expect(failedSpawn.waitForExit).not.toHaveBeenCalled() + + const failedSpawnAbort = new AbortController() + const cancelledFailedSpawn = fakeChild({ + pid: -1, + doneError: spawnError, + }) + const cancelledFailedClose = vi.fn() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + failedSpawnAbort.abort(new Error('startup cancelled')) + return queryFrom([], undefined, cancelledFailedClose) + }) + await expect(startClaudeCodeRun( + request(undefined, failedSpawnAbort.signal), + { ...unused.spec, spawn: () => cancelledFailedSpawn.handle }, + )).rejects.toThrow('aborted before SDK startup') + expect(cancelledFailedClose).toHaveBeenCalledOnce() + + const failedSpawnCloseError = new Error('query close failed') + const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) + const failedSpawnWithCloseFailure = fakeChild({ + pid: -1, + doneError: spawnError, + }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return queryFrom([], undefined, failedSpawnClose) + }) + const failedWithCloseFailure = startClaudeCodeRun(request(), { + ...unused.spec, + spawn: () => failedSpawnWithCloseFailure.handle, + }) + await expect(failedWithCloseFailure) + .rejects.toThrow('spawn /sdk/claude EACCES') + await expect(failedWithCloseFailure).rejects.toMatchObject({ + errors: [spawnError, failedSpawnCloseError], + }) + + const cleanupError = new Error('live child cleanup failed') + const constructionError = new Error( + 'query construction failed with a live child', + ) + const liveChildCleanupFailure = fakeChild({ doneError: cleanupError }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw constructionError + }) + await expect(startClaudeCodeRun(request(), { + ...unused.spec, + spawn: () => liveChildCleanupFailure.handle, + })).rejects.toMatchObject({ + errors: [constructionError, cleanupError], + }) }) }) From 9fcdb2c160ab34dd04d90c4fcf82601a0e77eac1 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 13 Aug 2026 20:30:36 +0800 Subject: [PATCH 07/95] fix(subagent): close Claude SDK runtime dependencies --- apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- packages/subagent/subagent-claude-code/package.json | 4 +++- .../subagent-claude-code/tests/subagent-claude-code.spec.ts | 5 +++++ pnpm-lock.yaml | 6 ++++++ 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 38a333beea..95635b579d 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: 4bb0502a9af9299fed991bb8a2279d74ccf97037 -README.zh.md: 1b641cefaa4f5ece496fc6af2447e45cd308f395 +README.md: f1fc9a2857fb143e435a9aa3cddbddfd03b72ee2 +README.zh.md: 2d9036f80e602525405947beae8ee6c4cfcec645 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 4bb0502a9a..f1fc9a2857 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -52,7 +52,7 @@ dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` retain their existing hot-reload behavior. On the next start, each installed product Bundle registers only its dormant Host provider and starts no product process. The Codex provider resolves a host `codex` from `PATH`; the Claude Code Bundle instead installs the pinned Agent SDK and one matching private platform CLI, uses only that CLI, and never falls back to a host `claude`. Authentication and native product settings remain user-managed for both products; the [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) discloses the current platform payload size and missing-payload failure. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the matching row before a new Agent can see that tool. Installing one provider never installs the other product package; the default dsh dependency closure includes neither provider nor the Claude Agent SDK or its platform payloads. +The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` take effect through hot reload. On the next start, each installed product Bundle registers only its dormant Host provider and starts no product process. The Codex provider resolves a host `codex` from `PATH`; the Claude Code Bundle instead installs the pinned Agent SDK and one matching private platform CLI, uses only that CLI, and never falls back to a host `claude`. Authentication and native product settings remain user-managed for both products; the [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) discloses the current platform payload size and missing-payload failure. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the matching row before a new Agent can see that tool. Installing one provider never installs the other product package; the default dsh dependency closure includes neither provider nor the Claude Agent SDK or its platform payloads. ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 1b641cefaa..2d9036f80e 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -52,7 +52,7 @@ dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑仍保留既有热重载行为。下一次启动时,每个已安装的产品 Bundle 只注册自己的休眠 Host provider,不会启动产品进程。Codex provider 会从 `PATH` 解析宿主 `codex`;Claude Code Bundle 则会安装锁定的 Agent SDK 与一个匹配的私有平台 CLI,只使用该 CLI,并且绝不会回退到宿主 `claude`。两个产品的身份验证与原生设置仍由用户管理;[Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)会披露当前平台载荷体积与载荷缺失时的失败行为。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用对应行,新 Agent 才能看到该工具。只安装一个 provider 不会安装另一个产品包;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK 或其平台载荷。 +pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑通过热重载生效。下一次启动时,每个已安装的产品 Bundle 只注册自己的休眠 Host provider,不会启动产品进程。Codex provider 会从 `PATH` 解析宿主 `codex`;Claude Code Bundle 则会安装锁定的 Agent SDK 与一个匹配的私有平台 CLI,只使用该 CLI,并且绝不会回退到宿主 `claude`。两个产品的身份验证与原生设置仍由用户管理;[Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)会披露当前平台载荷体积与载荷缺失时的失败行为。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用对应行,新 Agent 才能看到该工具。只安装一个 provider 不会安装另一个产品包;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK 或其平台载荷。 ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 3399234904..3eb33f524f 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -49,7 +49,9 @@ "dependencies": { "@anthropic-ai/sdk": "0.93.0", "@anthropic-ai/claude-agent-sdk": "0.3.220", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", 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 34d0fb150b..bfbc117a70 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 @@ -311,6 +311,11 @@ describe('task admission and package contracts', () => { '@anthropic-ai/claude-agent-sdk', CLAUDE_AGENT_SDK_VERSION, ) + expect(manifest.dependencies).toHaveProperty( + '@modelcontextprotocol/sdk', + '^1.29.0', + ) + expect(manifest.dependencies).toHaveProperty('zod', '^4.4.3') expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') const sdkRoot = dirname(fileURLToPath( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2850bbe5b5..64ce4f8242 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7074,6 +7074,12 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ From b4366e711d634290101b6b82deafac405e3de3b1 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 14 Aug 2026 16:05:48 +0800 Subject: [PATCH 08/95] feat(subagent): make Claude Code provider directly installable --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 14 ++--- ...ct-subagent-providers-in-shared-host.zh.md | 14 ++--- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 6 +- ...ude-code-and-codex-subagent-backends.zh.md | 6 +- ...ludes-product-subagent-providers.i18n.yaml | 4 +- ...dsh-excludes-product-subagent-providers.md | 8 +-- ...-excludes-product-subagent-providers.zh.md | 8 +-- .../agent-presets/code/agent.cordis.yml | 4 +- .../agent-presets/cordis/agent.cordis.yml | 4 +- .../editing-cordis-compositions/SKILL.md | 8 +-- .../agent-presets/standard/agent.cordis.yml | 4 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 7 +-- apps/cli/reference/README.zh.md | 7 +-- apps/cli/tests/web-agent-presets.e2e.ts | 57 ++++++------------- .../snapshots/skill-tool-row/ui.expected.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 15 ++--- docs/module-graph.zh.md | 15 ++--- .../subagent/subagent-codex/cordis.yml | 7 ++- .../subagent/subagent-codex/driver.ts | 9 ++- .../tests/snapshots/skill-load/session.jsonl | 2 +- packages/bundle/README.i18n.yaml | 4 +- packages/bundle/README.md | 2 +- packages/bundle/README.zh.md | 2 +- packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 4 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 2 +- packages/subagent/README.zh.md | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent/subagent-claude-code/src/run.ts | 20 +++++-- .../tests/subagent-claude-code.spec.ts | 28 +++++++++ .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 15 +---- packages/subagent/subagent-codex/README.zh.md | 15 +---- .../subagent/subagent-codex/cordis.patch.yml | 6 -- packages/subagent/subagent-codex/package.json | 9 +-- .../tests/loader-composition.e2e.ts | 9 --- .../tests/subagent-codex.spec.ts | 29 ---------- pnpm-lock.yaml | 6 +- .../verify-config-source-ownership.spec.ts | 4 +- scripts/verify-cordis-config.spec.ts | 7 +-- 48 files changed, 171 insertions(+), 232 deletions(-) delete mode 100644 packages/subagent/subagent-codex/cordis.patch.yml 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 index e8572ed53d..b6bff51d29 100644 --- 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 @@ -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-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 2b1a417f7e751b2edee7e3b23dd439feab1353ea -2026-08-10-product-subagent-providers-in-shared-host.zh.md: 2f687b7dc9332680b8aa610f46668f197066c517 +2026-08-10-product-subagent-providers-in-shared-host.md: 564fd315c41c2f6999eed65bd7241e8b7167f43e +2026-08-10-product-subagent-providers-in-shared-host.zh.md: eaa24bb323191b14edbd2f756033b700374f5356 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 index 2b1a417f7e..564fd315c4 100644 --- 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 @@ -6,21 +6,21 @@ 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) are independently installable packages loaded beside the common subagent tool. Agent Presets are 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. Bundle installation and Preset tool grant are therefore separate deployment and agent-authoring decisions. +The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) are separate packages loaded beside the common subagent tool. The Claude Code package is directly installable as a Profile Bundle, while a deployment mounts the Codex package explicitly. Agent Presets are the ordinary owner of one agent's model-visible tools, but a preset cannot safely own either provider: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Host availability and Preset tool grants are therefore separate deployment and agent-authoring decisions. The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while granting 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 -When installed in a Profile, each product Bundle loads its fixed `codex` or `claude-code` provider exactly once in the shared 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 grant neither tool, either one, or both without changing the provider registry. A tool whose provider Bundle is not installed remains unavailable rather than mounting another provider in the Agent plane. +The Claude Code Bundle and an explicit Codex Host row each load their fixed provider exactly once in the shared 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 grant neither tool, either one, or both without changing the provider registry. A tool whose provider is absent remains unavailable rather than mounting another provider in the Agent plane. -The [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) partially supersedes only this note's former default-inclusion choice: the base bundle excludes both providers, and each provider package owns its directly installable Bundle patch. This note continues to own process-wide Host placement whenever a product Bundle is installed. The provider-contract 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 [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) partially supersedes only this note's former default-inclusion choice: the base bundle excludes both providers, the Claude Code package owns a directly installable Bundle patch, and Codex remains an explicitly mounted Host plugin. This note continues to own process-wide Host placement whenever either provider is present. The provider-contract 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 have different executable owners. Codex starts a host `codex` from `PATH`. The Claude Code Bundle installs its pinned Agent SDK and matching platform CLI; the provider lets that SDK choose the private native executable and passes the command through the shared subprocess owner without consulting or falling back to a host `claude`. Loading either Bundle only registers the provider and creates no product state, probes no version or authentication, and adds no product-specific setting. A missing Codex command or Claude platform payload, authentication failure, and other product failures remain local to the attempted delegation. +The providers have different executable owners. Codex starts a host `codex` from `PATH`. The Claude Code Bundle installs its pinned Agent SDK and matching platform CLI; the provider lets that SDK choose the private native executable and passes the command through the shared subprocess owner without consulting or falling back to a host `claude`. Loading either provider only registers it and creates no product state, probes no version or authentication, and adds no product-specific setting. A missing Codex command or Claude platform payload, authentication failure, and other product failures remain local to the attempted delegation. ## Verification -Real composition loads the selected set of no product Bundle, Codex only, Claude Code only, or both, and crosses it with Agent Presets that grant none, either, or both tools. It proves the Host registry equals the installed Bundle set, model-visible tools equal the installed-and-granted intersection, and no product process starts during composition. Preset edit coverage retains generation isolation. Keyless ACP snapshots pin the model-visible tool schemas, while provider tests separately prove host executable resolution for Codex, SDK platform-payload selection without fallback for Claude Code, failure, cancellation, and process-tree quiescence. +Real composition loads either no Claude Code Bundle or the Claude Code Bundle and crosses that availability with Agent Presets that leave its tool disabled or grant it. It proves the Host registry and model-visible tools reflect those two decisions, no product process starts during composition, and Preset edits affect only later Sessions. Existing Codex Loader and provider tests separately prove explicit Host composition and host executable resolution. Keyless ACP snapshots pin the model-visible tool schemas, while provider tests prove SDK platform-payload selection without fallback for Claude Code, failure, cancellation, and process-tree quiescence. ## Alternatives considered @@ -34,6 +34,6 @@ Real composition loads the selected set of no product Bundle, Codex only, Claude ## Consequences -A user installs only the product Bundles available to a Profile and manages model-visible grants through the same Agent Preset authoring path as other plugins. Each new session receives the intersection of its preset's tool rows and the Profile's installed providers. An installed but ungranted product remains dormant and consumes its package and module-loading footprint but no product process, login, model call, or product home; an uninstalled product contributes no provider or SDK closure. +A user installs the Claude Code Bundle only in Profiles that need it, while a deployment that uses Codex mounts that Host plugin explicitly. Model-visible grants use the same Agent Preset authoring path as other plugins. Each new Session receives the intersection of its preset's tool rows and the Host's available providers. A present but ungranted product remains dormant and consumes its package and module-loading footprint but no product process, login, model call, or product home; an absent product contributes no provider closure. -The Host registry remains the single provider authority, each Bundle remains the deployment availability authority, and each Preset remains the model-tool authority. This explicit two-gate lifecycle avoids a global enable switch and keeps package removal independent from per-session authoring. +The Host registry remains the single provider authority, the Profile Bundle or explicit Host composition remains the deployment availability authority, and each Preset remains the model-tool authority. This explicit two-gate lifecycle avoids a global enable switch and keeps package removal independent from per-session authoring. 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 index 2f687b7dc9..eaa24bb323 100644 --- 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 @@ -6,21 +6,21 @@ Status: implemented ## 问题 -[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)是可独立安装的包,由部署环境在通用 subagent 工具旁加载。Agent Preset 是单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。因此,Bundle 安装与 Preset 工具授权分别属于部署决策和 agent 创作决策。 +[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)由两个独立包实现,并在通用 subagent 工具旁加载。Claude Code 包可作为 Profile Bundle 直接安装,而部署环境会显式挂载 Codex 包。Agent Preset 是单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有任一产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。因此,Host 可用性与 Preset 工具授权分别属于部署决策和 agent 创作决策。 归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具授权仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 ## 决策 -产品 Bundle 安装到 Profile 后,会在共享 Host 平面中恰好加载一次其固定的 `codex` 或 `claude-code` 提供方。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不授权任何工具、只授权其中一个或同时授权两者,而无需更改提供方注册表。若工具对应的提供方 Bundle 尚未安装,该工具仍不可用,而不会在 Agent 平面中另行挂载提供方。 +Claude Code Bundle 与显式 Codex Host 行都会在共享 Host 平面中恰好加载一次各自固定的提供方。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不授权任何工具、只授权其中一个或同时授权两者,而无需更改提供方注册表。若工具对应的提供方不存在,该工具仍不可用,而不会在 Agent 平面中另行挂载提供方。 -[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只部分取代本说明先前关于默认包含提供方的选择:base 组合包排除两个提供方,每个提供方包负责其可直接安装的 Bundle patch。本说明继续负责产品 Bundle 安装后进程级的 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 +[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只部分取代本说明先前关于默认包含提供方的选择:base 组合包排除两个提供方,Claude Code 包拥有可直接安装的 Bundle patch,而 Codex 仍是显式挂载的 Host 插件。本说明继续负责任一提供方存在时的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -两个提供方的可执行文件归属不同。Codex 会启动从 `PATH` 解析出的宿主 `codex`。Claude Code Bundle 会安装锁定的 Agent SDK 与匹配平台 CLI;提供方让 SDK 选择该私有原生可执行文件,再把命令交给共享子进程责任方,既不查询也不回退宿主 `claude`。加载任一 Bundle 只会注册提供方,不会创建产品状态、探测版本或身份验证,也不会新增产品专属设置。Codex 命令缺失、Claude 平台载荷缺失、身份验证失败和其他产品故障仍局限于发生问题的那次委派。 +两个提供方的可执行文件归属不同。Codex 会启动从 `PATH` 解析出的宿主 `codex`。Claude Code Bundle 会安装锁定的 Agent SDK 与匹配平台 CLI;提供方让 SDK 选择该私有原生可执行文件,再把命令交给共享子进程责任方,既不查询也不回退宿主 `claude`。加载任一提供方只会完成注册,不会创建产品状态、探测版本或身份验证,也不会新增产品专属设置。Codex 命令缺失、Claude 平台载荷缺失、身份验证失败和其他产品故障仍局限于发生问题的那次委派。 ## 验证 -真实组装会加载未安装产品 Bundle、仅安装 Codex、仅安装 Claude Code 或两者都安装这四种集合,并与不授权工具、仅授权其中一个或同时授权两者的 Agent Preset 完整交叉。测试证明 Host 注册表等于已安装 Bundle 集合,模型可见工具等于已安装且已授权集合的交集,并且组装期间不会启动产品进程。Preset 编辑覆盖继续证明代际隔离。无密钥 ACP(Agent Client Protocol)快照固定模型可见工具 schema,提供方测试则分别证明 Codex 的宿主可执行文件解析、Claude Code 的 SDK 平台载荷选择与无回退行为,以及失败、取消和进程树完全停稳。 +真实组装会覆盖未安装 Claude Code Bundle 与已安装该 Bundle 两种状态,并分别使用保留禁用行或授权该工具的 Agent Preset。测试证明 Host 注册表和模型可见工具会反映这两个决策,组装期间不会启动产品进程,而且 Preset 编辑只影响后续 Session。现有 Codex Loader 与提供方测试会另行证明显式 Host 组装和宿主可执行文件解析。无密钥 ACP(Agent Client Protocol)快照固定模型可见工具 schema,提供方测试则证明 Claude Code 的 SDK 平台载荷选择与无回退行为,以及失败、取消和进程树完全停稳。 ## 考虑过的替代方案 @@ -34,6 +34,6 @@ Status: implemented ## 后果 -用户只安装 Profile 可用的产品 Bundle,并通过与其他插件相同的 Agent Preset 创作路径管理模型可见授权。每个新会话会获得其 preset 工具行与 Profile 已安装提供方的交集。已安装但未授权的产品保持休眠,会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录;未安装的产品不会进入提供方或 SDK 依赖闭包。 +用户只在需要 Claude Code 的 Profile 中安装该 Bundle;使用 Codex 的部署会显式挂载对应 Host 插件。模型可见授权仍通过与其他插件相同的 Agent Preset 创作路径管理。每个新 Session 会获得其 preset 工具行与 Host 可用提供方的交集。存在但未授权的产品保持休眠,会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录;缺席的产品不会进入提供方闭包。 -Host 注册表仍是提供方的唯一权威,每个 Bundle 仍是部署可用性的权威,每个 Preset 仍是模型工具的权威。这个显式的双门生命周期避免全局启用开关,并让包移除与按会话创作保持独立。 +Host 注册表仍是提供方的唯一权威,Profile Bundle 或显式 Host 组装仍是部署可用性的权威,每个 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 856c199c8b..f5bb0b18c3 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: f9c7529db664d5b7bebcd77ba69fd974d6cad5b3 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 8b0e42507c5a0850d569bff3c9abe962b4a03fe0 +2026-08-04-claude-code-and-codex-subagent-backends.md: fed85f5d338db4b6e53406c94e3ecfd29aebd5af +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 2de5f72bc22bc56318209f13ca09746e6add58b2 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 f9c7529db6..fed85f5d33 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 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) owns process-wide placement when a provider is installed, while the [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their optional direct Bundle installation and exclusion from the default distribution. 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. +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) owns process-wide placement, while the [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns both providers' exclusion from the default distribution and Claude Code's optional direct Bundle installation. Codex remains an explicitly mounted Host plugin. 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, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and 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. Unit coverage proves that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and surfaces the SDK's missing-payload error without host fallback. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader and optional Bundle-composition evidence resolve the selected product packages by name while starting neither product. +The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and 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. Unit coverage proves that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and surfaces the SDK's missing-payload error without host fallback. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader coverage resolves Codex through explicit Host composition and Claude Code through its optional Bundle while starting neither product. 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 delegate through two stable foreground tools backed by the official product integrations. Installed providers remain in the process-wide Host and tools remain per Preset under the [shared-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md); optional package availability and default exclusion are owned by the [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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. +Users delegate through two stable foreground tools backed by the official product integrations. Available providers remain in the process-wide Host and tools remain per Preset under the [shared-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md); Claude Code Bundle availability and both providers' default exclusion are owned by the [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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. Codex behavior depends on the deployment's installed CLI and native configuration; Claude Code behavior depends on the Bundle-pinned platform CLI plus native account 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 8b0e42507c..2de5f72bc2 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 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责提供方安装后的进程级放置,[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责其可选直接 Bundle 安装与默认发行排除。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责进程级放置,[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责两个提供方的默认发行排除,以及 Claude Code 的可选直接 Bundle 安装。Codex 仍是显式挂载的 Host 插件。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 这两个提供方都报告 `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 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消和整棵进程树退出。单元覆盖会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖、直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并且在载荷缺失时原样暴露 SDK 错误且不回退宿主 CLI。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 与可选 Bundle 组装证据会按名称解析已选择的产品包且不启动产品。 +Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消和整棵进程树退出。单元覆盖会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖、直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并且在载荷缺失时原样暴露 SDK 错误且不回退宿主 CLI。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 覆盖会通过显式 Host 组装解析 Codex,并通过可选 Bundle 解析 Claude Code,且不会启动任一产品。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -87,7 +87,7 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八 ## 后果 -用户通过官方产品集成支持的两个稳定前台工具进行委派。已安装提供方位于进程级 Host、工具按 Preset 暴露,这些规则由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;可选包可用性与默认排除由[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 +用户通过官方产品集成支持的两个稳定前台工具进行委派。可用提供方位于进程级 Host、工具按 Preset 暴露,这些规则由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;Claude Code Bundle 可用性与两个提供方的默认排除由[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。Codex 行为取决于部署环境中安装的 CLI 与原生配置;Claude Code 行为取决于 Bundle 锁定的平台 CLI,以及原生账户和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml index b22e11f805..f842aecafe 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md -2026-08-12-production-dsh-excludes-product-subagent-providers.md: b729115ead5ec6823b0c98815fa12f50022defdb -2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: ac10ae2b4f04ddc3997b5fe4bbb8f656742de547 +2026-08-12-production-dsh-excludes-product-subagent-providers.md: 779bff6f668c086722f817a28a224182d0fbac09 +2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: 27106668f8b81491d43e2033c1468799783c0584 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md index b729115ead..779bff6f66 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md @@ -10,13 +10,13 @@ English | [中文](2026-08-12-production-dsh-excludes-product-subagent-providers ## Decision -This decision partially supersedes only the default-inclusion part of the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. Each existing provider package is instead a directly installable Profile Bundle whose `dsh.bundle.patch` points to one package-owned `cordis.patch.yml`. That patch contributes exactly one self-provider Host row and no Agent tool row. +This decision partially supersedes only the default-inclusion part of the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. The Claude Code provider package is a directly installable Profile Bundle whose `dsh.bundle.patch` points to one package-owned `cordis.patch.yml`. That patch contributes exactly one self-provider Host row and no Agent tool row. The Codex package remains available for deployments that mount it explicitly. -The two Bundles remain independent. The Codex Bundle owns its `@deepseek-ai/dsh-sdk-protocol` runtime dependency and continues to use a host `codex` from `PATH`. The Claude Code Bundle owns the pinned Agent SDK and the matching platform CLI selected from the SDK's optional dependencies; production uses that private CLI and never falls back to a host `claude`. Installing one Bundle does not pull in the other, and the default `@deepseek-ai/dsh` production closure contains neither provider, the Claude Agent SDK, nor its platform payloads. An installed Bundle registers a dormant provider on the next Profile start, while an Agent Preset independently decides whether a new Session receives the corresponding tool. Installation brings only the selected package closure onto disk; it does not start a product, authenticate an account, rewrite native settings, or grant model access. +The two optional integrations remain independent. Codex continues to use a host `codex` from `PATH`. The Claude Code Bundle owns the pinned Agent SDK and the matching platform CLI selected from the SDK's optional dependencies; production uses that private CLI and never falls back to a host `claude`. Installing the Claude Code Bundle does not pull in the Codex package, and the default `@deepseek-ai/dsh` production closure contains neither provider, the Claude Agent SDK, nor its platform payloads. The Bundle registers a dormant provider on the next Profile start, while an Agent Preset independently decides whether a new Session receives its tool. Installation brings only the Claude Code package closure onto disk; it does not start a product, authenticate an account, rewrite native settings, or grant model access. ## Verification -Package tests pin each Bundle manifest, published patch, exact self-provider row, and product-specific runtime closure. Claude coverage pins Agent SDK 0.3.220, Claude Code 2.1.220, all eight platform package identities and versions, the SDK-selected executable entering the shared subprocess owner, and first-delegation failure without host fallback when the payload is missing. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Production-closure tests prove the default, Codex-only, and Claude-only dependency boundaries, while real Bundle-patch and Agent-Preset composition covers all four installed sets, the full tool-grant matrix on a Host with both providers, representative missing-provider cases, and zero product processes. The base bundle test continues to reject both provider dependencies and configuration rows. +Package tests pin the Claude Code Bundle manifest, published patch, exact self-provider row, and runtime closure. Claude coverage pins Agent SDK 0.3.220, Claude Code 2.1.220, all eight platform package identities and versions, the SDK-selected executable entering the shared subprocess owner, and first-delegation failure without host fallback when the payload is missing. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Production-closure tests prove the default and Claude-only dependency boundaries, while real Bundle-patch and Agent-Preset composition covers absent and installed Host states, disabled and enabled tool grants, later-Session adoption, and zero product processes. Existing Codex package tests continue to cover explicit Host composition and host executable resolution. The base bundle test continues to reject both provider dependencies and configuration rows. ## Alternatives considered @@ -26,4 +26,4 @@ Package tests pin each Bundle manifest, published patch, exact self-provider row ## Consequences -Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. A Profile can add or remove either provider package, or both, directly; the changed Host availability takes effect on the next Profile start. Selecting Claude Code explicitly accepts its SDK and one large platform CLI payload, while selecting Codex does not install a product CLI. A separately authored Agent Preset still grants the model-visible tool only to newly composed Sessions. No wrapper package, meta Bundle, dynamic installer, or persisted product-enable state is introduced. +Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. A Profile can add or remove the Claude Code provider Bundle directly; the changed Host availability takes effect on the next Profile start and explicitly accepts its SDK plus one large platform CLI payload. A Codex deployment still mounts that provider explicitly and supplies its product CLI through `PATH`. A separately authored Agent Preset grants either model-visible tool only to newly composed Sessions. No wrapper package, meta Bundle, dynamic installer, or persisted product-enable state is introduced. diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md index ac10ae2b4f..27106668f8 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md @@ -10,13 +10,13 @@ Status: implemented ## 决策 -本决策只部分取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)中关于默认包含提供方的部分:`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。现有的每个提供方包改为可直接安装的 Profile Bundle,其 `dsh.bundle.patch` 指向包自身拥有的 `cordis.patch.yml`。该 patch 恰好贡献一条挂载自身提供方的 Host 行,不包含 Agent 工具行。 +本决策只部分取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)中关于默认包含提供方的部分:`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。Claude Code 提供方包是可直接安装的 Profile Bundle,其 `dsh.bundle.patch` 指向包自身拥有的 `cordis.patch.yml`。该 patch 恰好贡献一条挂载自身提供方的 Host 行,不包含 Agent 工具行。Codex 包仍供部署环境显式挂载。 -两个 Bundle 彼此独立。Codex Bundle 自己负责运行时依赖 `@deepseek-ai/dsh-sdk-protocol`,并继续使用 `PATH` 中的宿主 `codex`。Claude Code Bundle 自己负责锁定的 Agent SDK,以及从 SDK optional dependencies 中选出的匹配平台 CLI;生产运行只使用该私有 CLI,绝不会回退到宿主 `claude`。安装其中一个 Bundle 不会带入另一个,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一提供方,也不包含 Claude Agent SDK 或其平台载荷。已安装的 Bundle 会在下次 Profile 启动时注册一个休眠提供方,而 Agent Preset 独立决定新 Session 是否获得对应工具。安装只会把所选包闭包放到磁盘上;它不会启动产品、验证账户、改写原生设置或向模型授予访问权。 +两个可选集成彼此独立。Codex 继续使用 `PATH` 中的宿主 `codex`。Claude Code Bundle 自己负责锁定的 Agent SDK,以及从 SDK optional dependencies 中选出的匹配平台 CLI;生产运行只使用该私有 CLI,绝不会回退到宿主 `claude`。安装 Claude Code Bundle 不会带入 Codex 包,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一提供方,也不包含 Claude Agent SDK 或其平台载荷。该 Bundle 会在下次 Profile 启动时注册一个休眠提供方,而 Agent Preset 独立决定新 Session 是否获得对应工具。安装只会把 Claude Code 包闭包放到磁盘上;它不会启动产品、验证账户、改写原生设置或向模型授予访问权。 ## 验证 -包测试会固定每个 Bundle 的 manifest、发布 patch、准确的自身提供方行以及产品专属运行时闭包。Claude 覆盖会固定 Agent SDK 0.3.220、Claude Code 2.1.220、八个平台包的身份与版本、SDK 所选可执行文件进入共享子进程责任方的路径,以及载荷缺失时第一次委派失败且不回退宿主 CLI。工作区验证会从 Bundle 声明派生每个发布 patch,而非维护包目录。生产闭包测试证明默认、仅 Codex 与仅 Claude 三种依赖边界;真实 Bundle patch 与 Agent Preset 的组装会覆盖四种安装集合、同时安装两个提供方时的完整工具授权矩阵、缺失提供方的代表场景以及零产品进程。base 组合包测试仍会拒绝这两个提供方依赖与配置行。 +包测试会固定 Claude Code Bundle 的 manifest、发布 patch、准确的自身提供方行以及运行时闭包。Claude 覆盖会固定 Agent SDK 0.3.220、Claude Code 2.1.220、八个平台包的身份与版本、SDK 所选可执行文件进入共享子进程责任方的路径,以及载荷缺失时第一次委派失败且不回退宿主 CLI。工作区验证会从 Bundle 声明派生每个发布 patch,而非维护包目录。生产闭包测试证明默认与仅 Claude 两种依赖边界;真实 Bundle patch 与 Agent Preset 组装会覆盖 Host 中缺席和已安装两种状态、禁用和启用两种工具授权、后续 Session 采纳以及零产品进程。现有 Codex 包测试继续覆盖显式 Host 组装和宿主可执行文件解析。base 组合包测试仍会拒绝这两个提供方依赖与配置行。 ## 考虑过的替代方案 @@ -26,4 +26,4 @@ Status: implemented ## 后果 -安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。Profile 可以直接添加或移除任一提供方包,也可以同时操作两者;Host 可用性的变化会在下次 Profile 启动时生效。选择 Claude Code 代表明确接受其 SDK 与一个大型平台 CLI 载荷,而选择 Codex 不会安装产品 CLI。单独创作的 Agent Preset 仍只会向新组装的 Session 授予模型可见工具。本决策不引入 wrapper 包、meta Bundle、动态安装程序或持久化的产品启用状态。 +安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。Profile 可以直接添加或移除 Claude Code provider Bundle;Host 可用性的变化会在下次 Profile 启动时生效,并代表明确接受其 SDK 与一个大型平台 CLI 载荷。Codex 部署仍须显式挂载该 provider,并通过 `PATH` 提供产品 CLI。单独创作的 Agent Preset 仍只会向新组装的 Session 授予任一模型可见工具。本决策不引入 wrapper 包、meta Bundle、动态安装程序或持久化的产品启用状态。 diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index ab6e42450e..a1643edbb2 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -201,8 +201,8 @@ # 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. - # Install the matching optional Provider Bundle in this Profile and restart - # the Host before enabling either template. Installation alone grants no tool. + # Install the optional Claude Code Provider Bundle in this Profile and restart + # the Host before enabling its template. Installation alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 23766c18de..2fac142f83 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -188,8 +188,8 @@ # 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. - # Install the matching optional Provider Bundle in this Profile and restart - # the Host before enabling either template. Installation alone grants no tool. + # Install the optional Claude Code Provider Bundle in this Profile and restart + # the Host before enabling its template. Installation alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true 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 dcf2194352..916958e1c0 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 @@ -123,14 +123,14 @@ After a clean mount-validation, ask the user to start a session on the new prese ## Native product subagents -Codex and Claude Code providers are optional Profile Bundles. Install only the products the Profile needs, then restart the Profile so its Host registers those providers: +The Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider: ```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -The Bundle owns Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing either package with `dsh plugin --profile remove ` withdraws only that provider on the next Profile start. +The Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: @@ -154,7 +154,7 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o maxDepth: provider-managed ``` -The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must provide `codex` on `PATH` for the Codex provider. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. Neither Bundle nor the preset starts a product during composition, authenticates an account, selects a model, probes credentials, or manages native product settings. +The two rows are independent. The Claude Code row becomes available only when its Bundle is installed; the Codex row still requires a deployment whose Host composition registers that provider and whose `PATH` supplies `codex`. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. Neither installing the Claude Code Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings. ## What not to move into a preset diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index c3e11aee19..c1721d0132 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -200,8 +200,8 @@ # 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. - # Install the matching optional Provider Bundle in this Profile and restart - # the Host before enabling either template. Installation alone grants no tool. + # Install the optional Claude Code Provider Bundle in this Profile and restart + # the Host before enabling its template. Installation alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 95635b579d..0a5792a21c 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: f1fc9a2857fb143e435a9aa3cddbddfd03b72ee2 -README.zh.md: 2d9036f80e602525405947beae8ee6c4cfcec645 +README.md: c6c3f61c2910b84b9364ef7eaa13b8c0c011999b +README.zh.md: d6f3e7f1f508724e54e59df01e93199193ddc506 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index f1fc9a2857..c6c3f61c29 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -42,17 +42,14 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. -The Codex and Claude Code subagent providers are separate optional Bundles. Add either package, both in one command, or remove either package independently: +The Claude Code subagent provider is an optional Bundle. Add or remove it independently: ```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code -dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` take effect through hot reload. On the next start, each installed product Bundle registers only its dormant Host provider and starts no product process. The Codex provider resolves a host `codex` from `PATH`; the Claude Code Bundle instead installs the pinned Agent SDK and one matching private platform CLI, uses only that CLI, and never falls back to a host `claude`. Authentication and native product settings remain user-managed for both products; the [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) discloses the current platform payload size and missing-payload failure. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the matching row before a new Agent can see that tool. Installing one provider never installs the other product package; the default dsh dependency closure includes neither provider nor the Claude Agent SDK or its platform payloads. +The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating the Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` take effect through hot reload. On the next start, the installed package registers only its dormant Host provider and starts no Claude process. The Bundle installs the pinned Agent SDK and one matching private platform CLI, uses only that CLI, and never falls back to a host `claude`. Authentication and native Claude settings remain user-managed; the [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) discloses the current platform payload size and missing-payload failure. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the Claude Code row before a new Agent can see that tool. The Codex provider remains an explicitly mounted Host plugin that resolves `codex` from `PATH`; the default dsh dependency closure includes neither provider nor the Claude Agent SDK or its platform payloads. ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 2d9036f80e..d6f3e7f1f5 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -42,17 +42,14 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。 -Codex 与 Claude Code subagent provider 是两个彼此独立的可选 Bundle。可以只添加一个包、在同一命令中添加两个包,或独立移除任一包: +Claude Code subagent provider 是一个可选 Bundle,可以独立添加或移除: ```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code -dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑通过热重载生效。下一次启动时,每个已安装的产品 Bundle 只注册自己的休眠 Host provider,不会启动产品进程。Codex provider 会从 `PATH` 解析宿主 `codex`;Claude Code Bundle 则会安装锁定的 Agent SDK 与一个匹配的私有平台 CLI,只使用该 CLI,并且绝不会回退到宿主 `claude`。两个产品的身份验证与原生设置仍由用户管理;[Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)会披露当前平台载荷体积与载荷缺失时的失败行为。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用对应行,新 Agent 才能看到该工具。只安装一个 provider 不会安装另一个产品包;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK 或其平台载荷。 +pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑通过热重载生效。下一次启动时,已安装的包只注册休眠的 Host provider,不会启动 Claude 进程。该 Bundle 会安装锁定的 Agent SDK 与一个匹配的私有平台 CLI,只使用该 CLI,并且绝不会回退到宿主 `claude`。Claude 的身份验证与原生设置仍由用户管理;[Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)会披露当前平台载荷体积与载荷缺失时的失败行为。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用 Claude Code 行,新 Agent 才能看到该工具。Codex provider 仍须作为 Host 插件显式挂载,并从 `PATH` 解析 `codex`;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK 或其平台载荷。 ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 77185270e9..6c01becf76 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -26,7 +26,6 @@ const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ 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') -const CODEX_PATCH = join(REPO_ROOT, 'packages/subagent/subagent-codex/cordis.patch.yml') const CLAUDE_CODE_PATCH = join(REPO_ROOT, 'packages/subagent/subagent-claude-code/cordis.patch.yml') /** The installation anchor whose dependency surface the preset module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') @@ -434,12 +433,11 @@ describe('the shipped Web composition', () => { }) }) -describe('product subagent Bundle and user-preset intersection', () => { - const presetIds = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const - type Product = 'codex' | 'claude-code' +describe('Claude Code Bundle and user-preset intersection', () => { + const presetIds = ['products-none', 'products-claude'] as const type PresetId = typeof presetIds[number] - async function bootProducts(installed: readonly Product[]): Promise { + async function bootProducts(installed: boolean): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) const userRoot = join(root, 'presets') const settingsFile = join(root, 'settings.yaml') @@ -447,20 +445,14 @@ describe('product subagent Bundle and user-preset intersection', () => { await writeFile(settingsFile, '{}\n') for (const id of presetIds) { let composition = standard - if (id === 'products-codex' || id === 'products-both') { - composition = enablePresetTool(composition, 'tool-subagent-codex') - } - if (id === 'products-claude' || id === 'products-both') { + if (id === 'products-claude') { 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) } - const productPatches = installed.flatMap(product => loadOverlayPatches( - 'dsh-test', - product === 'codex' ? CODEX_PATCH : CLAUDE_CODE_PATCH, - )) + const productPatches = installed ? loadOverlayPatches('dsh-test', CLAUDE_CODE_PATCH) : [] return await bootWeb(settingsFile, [ ...productPatches, { @@ -474,21 +466,13 @@ describe('product subagent Bundle and user-preset intersection', () => { includeUserRoot: false, }, }, - ], installed.map(product => dirname(product === 'codex' ? CODEX_PATCH : CLAUDE_CODE_PATCH))) + ], installed ? [dirname(CLAUDE_CODE_PATCH)] : []) } - it('composes the intersection of installed Bundles and enabled preset rows', async () => { - const enabledByPreset: Record = { - 'products-none': [], - 'products-codex': ['codex'], - 'products-claude': ['claude-code'], - 'products-both': ['codex', 'claude-code'], - } - const scenarios: Array<{ installed: Product[]; presets: readonly PresetId[] }> = [ - { installed: [], presets: ['products-both'] }, - { installed: ['codex'], presets: ['products-both'] }, - { installed: ['claude-code'], presets: ['products-both'] }, - { installed: ['codex', 'claude-code'], presets: presetIds }, + it('composes the intersection of the installed Bundle and enabled preset row', async () => { + const scenarios: Array<{ installed: boolean; presets: readonly PresetId[] }> = [ + { installed: false, presets: presetIds }, + { installed: true, presets: presetIds }, ] for (const { installed, presets } of scenarios) { @@ -498,21 +482,16 @@ describe('product subagent Bundle and user-preset intersection', () => { expect(productCtx.subagents.list() .filter(name => name === 'codex' || name === 'claude-code') .sort()) - .toEqual([...installed].sort()) + .toEqual(installed ? ['claude-code'] : []) for (const id of presets) { - const enabled = enabledByPreset[id] const handle = await productCtx.agents.create({ - sessionId: SessionId(`preset-${id}-${installed.join('-') || 'none'}-${randomUUID()}`), + sessionId: SessionId(`preset-${id}-${installed ? 'claude' : 'none'}-${randomUUID()}`), setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), }) try { - const expectedTools = enabled - .filter(product => installed.includes(product)) - .map(product => product === 'codex' ? 'subagent_codex' : 'subagent_claude_code') - .sort() expect(toolNames(productCtx, handle.agent) .filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) - .toEqual(expectedTools) + .toEqual(installed && id === 'products-claude' ? ['subagent_claude_code'] : []) } finally { await handle.dispose() } @@ -526,7 +505,7 @@ describe('product subagent Bundle and user-preset intersection', () => { }, 120_000) it('applies a product-row edit only to later sessions on the preset', async () => { - const productCtx = await bootProducts(['codex']) + const productCtx = await bootProducts(true) const preset = await productCtx.agentPresets.resolve('products-none') const original = await readFile(preset.path, 'utf8') const existing = await productCtx.agents.create({ @@ -534,16 +513,16 @@ describe('product subagent Bundle and user-preset intersection', () => { 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')) + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_claude_code') + await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-claude-code')) 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') + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_claude_code') + expect(toolNames(productCtx, later.agent)).toContain('subagent_claude_code') } finally { await later.dispose() } diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index fe2eecebad..e3f7e3f638 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -25,7 +25,7 @@ - button "Skill editing-cordis-compositions" [expanded]: - img - text: Skill editing-cordis-compositions -- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents Codex and Claude Code providers are optional Profile Bundles. Install only the products the Profile needs, then restart the Profile so its Host registers those providers: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code ``` The Bundle owns Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing either package with `dsh plugin --profile remove ` withdraws only that provider on the next Profile start. 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 an installed matching provider, and enabling both exposes the installed intersection. The host must provide `codex` on `PATH` for the Codex provider. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. Neither Bundle nor the preset starts a product during composition, authenticates an account, selects a model, probes credentials, or manages native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " +- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents The Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` The Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start. 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. The Claude Code row becomes available only when its Bundle is installed; the Codex row still requires a deployment whose Host composition registers that provider and whose `PATH` supplies `codex`. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. Neither installing the Claude Code Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " - button "Inspect" - button "Think The skill is loaded.": - img diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 2e9e666180..353df8d744 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: 46f924b0df80aa667d4929e6d2707c8cf051bfb2 -module-graph.zh.md: 3a226bbdacfd1a5b850a406bea66bf29a5db209b +module-graph.md: 24ee9a4815b4236336ae37f6c926dba4718dafb9 +module-graph.zh.md: d87feb946a43a2f9d391e7894b4a143af4e18406 diff --git a/docs/module-graph.md b/docs/module-graph.md index 46f924b0df..24ee9a4815 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -961,12 +961,6 @@ flowchart TD pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout - pkg_subagent_codex --> pkg_invariants - pkg_subagent_codex --> pkg_llm - pkg_subagent_codex --> pkg_session - pkg_subagent_codex --> pkg_subagent - pkg_subagent_codex --> pkg_subprocess - pkg_subagent_codex --> pkg_timeout pkg_subagent_in_process_driver --> pkg_agent pkg_subagent_in_process_driver --> pkg_invariants pkg_subagent_in_process_driver --> pkg_llm @@ -1074,6 +1068,13 @@ flowchart TD pkg_workflow_worker_thread --> pkg_subagent pkg_workflow_worker_thread --> pkg_tools pkg_workflow_worker_thread --> pkg_workflow + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_sdk_protocol + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout pkg_subagent_fork_in_process --> pkg_agent pkg_subagent_fork_in_process --> pkg_invariants pkg_subagent_fork_in_process --> pkg_session @@ -1576,7 +1577,6 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | @@ -1592,6 +1592,7 @@ flowchart TD | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 3a226bbdac..d87feb946a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -963,12 +963,6 @@ flowchart TD pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout - pkg_subagent_codex --> pkg_invariants - pkg_subagent_codex --> pkg_llm - pkg_subagent_codex --> pkg_session - pkg_subagent_codex --> pkg_subagent - pkg_subagent_codex --> pkg_subprocess - pkg_subagent_codex --> pkg_timeout pkg_subagent_in_process_driver --> pkg_agent pkg_subagent_in_process_driver --> pkg_invariants pkg_subagent_in_process_driver --> pkg_llm @@ -1076,6 +1070,13 @@ flowchart TD pkg_workflow_worker_thread --> pkg_subagent pkg_workflow_worker_thread --> pkg_tools pkg_workflow_worker_thread --> pkg_workflow + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_sdk_protocol + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout pkg_subagent_fork_in_process --> pkg_agent pkg_subagent_fork_in_process --> pkg_invariants pkg_subagent_fork_in_process --> pkg_session @@ -1578,7 +1579,6 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | @@ -1594,6 +1594,7 @@ flowchart TD | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index 45b601aed7..e770b11c95 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -1,5 +1,5 @@ -# Test-only composition of the foreground tool around a Bundle-supplied provider. -# The owning e2e applies the package's real patch and never invokes the model or Codex. +# Test-only composition of the public opt-in provider and foreground tool. +# The owning e2e boots this tree but never invokes the model or Codex. - id: fixture name: './fixture.ts' @@ -9,6 +9,9 @@ - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts index af54c5fc0c..873f5e36de 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts @@ -1,21 +1,20 @@ #!/usr/bin/env node /** Inspect the public Codex provider composition without invoking the product. */ -import { boot, loadOverlayPatches, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-tools' const configPath = process.argv[2] -const bundlePatchPath = process.argv[3] -if (configPath === undefined || bundlePatchPath === undefined) { - throw new Error('subagent-codex Loader composition driver requires config and Bundle patch paths') +if (configPath === undefined) { + throw new Error('subagent-codex Loader composition driver requires a config path') } let starts = 0 const ctx = await boot( 'subagent-codex-loader-composition', resolveConfigPath(configPath, undefined), - loadOverlayPatches('subagent-codex-loader-composition', bundlePatchPath), + undefined, (hostCtx) => { hostCtx.on('subagent/start', () => { starts += 1 diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 7292e114d4..8e67021318 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -19,7 +19,7 @@ {"type":"assistant/chunk","seq":17,"time":1785730426828,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":18,"time":1785730426828,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fd7a47e-84c9-4d31-aa95-9939671ba0a5"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[10,11,12,13,14,15,16,17],"surfaceOp":"append"} {"type":"tool/call","seq":19,"time":1785730426828,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} -{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nCodex and Claude Code providers are optional Profile Bundles. Install only the products the Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\n```\n\nThe Bundle owns Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing either package with `dsh plugin --profile remove ` withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n enableRunInBackground: false\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n enableRunInBackground: false\n maxDepth: provider-managed\n```\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only an installed matching provider, and enabling both exposes the installed intersection. The host must provide `codex` on `PATH` for the Codex provider. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. Neither Bundle nor the preset starts a product during composition, authenticates an account, selects a model, probes credentials, or manages native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"fa340fc0-3edc-4a61-92b2-2c4d70c4b6d7"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nThe Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nThe Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n enableRunInBackground: false\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n enableRunInBackground: false\n maxDepth: provider-managed\n```\n\nThe two rows are independent. The Claude Code row becomes available only when its Bundle is installed; the Codex row still requires a deployment whose Host composition registers that provider and whose `PATH` supplies `codex`. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. Neither installing the Claude Code Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"487d970e-9e85-4637-8b56-788e8cc00362"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","seq":21,"time":1785730426838,"data":{"turn":1,"step":1}} {"type":"step/start","seq":22,"time":1785730426848,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index 0441ec7d87..b42a5e5d52 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/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/README.md -README.md: 4d7a064939ae04f25737b324ec35332b7b944f80 -README.zh.md: 8910b33a97acd2ef3ee5b659305739246004de01 +README.md: 3afa53c1444b43c38b7a71f3e9e00d271078be54 +README.zh.md: 740b3579ce1555f2b1b26ca79e8a3d915a338193 diff --git a/packages/bundle/README.md b/packages/bundle/README.md index 4d7a064939..3afa53c144 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../boot/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts. -The manifest declaration, not this directory, defines Bundle identity. Domain packages can carry their own optional Profile layer; the [Codex and Claude Code subagent packages](../subagent/README.md) are directly installable examples. +The manifest declaration, not this directory, defines Bundle identity. Domain packages can carry their own optional Profile layer; the [Claude Code subagent package](../subagent/subagent-claude-code/README.md) is a directly installable example. | Package | Role | ctx key | |---|---|---| diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index 8910b33a97..740b3579ce 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -4,7 +4,7 @@ Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 约定](../boot/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。 -Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包可以携带自己的可选 Profile 层;[Codex 与 Claude Code subagent 包](../subagent/README.md)就是可直接安装的例子。 +Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包可以携带自己的可选 Profile 层;[Claude Code subagent 包](../subagent/subagent-claude-code/README.md)就是可直接安装的例子。 | 包 | 职责 | ctx key | |---|---|---| diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index cac587ee85..ea1fb9b03a 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: a963bcca671c613ebdcc7b453384b1d9b8393662 -README.zh.md: ab43954fe3f4ad46160961e8ca67876df9aac503 +README.md: 5fdd642ecc03fea77b2b00fc6428525c1a40d891 +README.zh.md: c2a07ec15816e41eadf682bcd8631c93bce77ae0 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index a963bcca67..5fdd642ecc 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, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile installs either [product provider package](../../subagent/README.md) only when needed. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider nor the Claude Agent SDK. 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, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile can install the [Claude Code provider Bundle](../../subagent/subagent-claude-code/README.md) only when needed, while a deployment that uses Codex still mounts that provider explicitly. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider nor the Claude Agent SDK. 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 patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index ab43954fe3..c2a07ec158 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、遥测与核心 spawn/fork subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 仅在需要时安装对应的[产品 provider 包](../../subagent/README.md)。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider,也不包含 Claude Agent SDK。后续的组合包层(例如 [`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、遥测与核心 spawn/fork subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 可以仅在需要时安装 [Claude Code provider Bundle](../../subagent/subagent-claude-code/README.md),使用 Codex 的部署仍须显式挂载该 provider。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider,也不包含 Claude Agent SDK。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不受沙盒约束的本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时直接报错)。POSIX 主机看到的是被禁用的 pwsh 行。 @@ -19,4 +19,4 @@ patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` ## 已知限制与暂缓事项 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`\dsh-`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 +- **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`\dsh-`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何临时目录写入权限。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 6f83153195..24fefb0441 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/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/README.md -README.md: 997d05c5030cc4e1ee9ebc1e8b4da2e6056b6266 -README.zh.md: a0e3ff4e17c1e0e092d56dc518bb16f0329c0198 +README.md: aebf368b984dca3ac2e37d1bcdba26a82ce85196 +README.zh.md: fb9e223f916a8b07d3a4ae254fa61087de081a93 diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 997d05c503..aebf368b98 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -18,7 +18,7 @@ This family lets an agent delegate work to child agents. Multiple named provider | [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | Provides the child-to-parent report channel | registers in child scopes | -The Codex and Claude Code packages are also independent Profile Bundles. Install either or both with `dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code`, then restart that Profile; each installed package registers only its own dormant Host provider. Full Agent Presets keep separate disabled tool templates, so installation alone exposes no model tool. Removing one package withdraws only that provider on the next Profile start. +The Claude Code package is also an optional Profile Bundle. Install it with `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code`, then restart that Profile; the package registers only its dormant Host provider, while a copied Agent Preset separately grants the disabled tool template to new Sessions. Removing the package withdraws that provider on the next Profile start. The Codex package remains an explicitly mounted Host plugin and uses a host `codex` from `PATH`. See the decisions for the [capability family](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable children](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [control tools](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index a0e3ff4e17..fb9e223f91 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -18,7 +18,7 @@ | [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 | -Codex 与 Claude Code 包也分别是独立的 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code` 安装其中一个或两个包,再重启该 Profile;每个已安装包只注册自己的休眠 Host provider。完整 Agent Preset 仍保留彼此独立且默认禁用的工具模板,因此只安装 Bundle 不会向模型暴露工具。移除其中一个包后,下一次 Profile 启动只会撤回对应 provider。 +Claude Code 包也是一个可选的 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code` 安装后重启该 Profile;该包只注册休眠的 Host provider,而复制出的 Agent Preset 会单独把默认禁用的工具模板授予新 Session。移除该包后,下一次 Profile 启动会撤回对应 provider。Codex 包仍须作为 Host 插件显式挂载,并使用 `PATH` 中的宿主 `codex`。 参见有关[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)的决策。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index e93da05c24..6d1e544c1b 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: 058e72a46cd6fb7c15779f3650e191535a8b2571 -README.zh.md: 05914165c26162de9c0a37cfa7090a8d667e63bc +README.md: 226270506d743a7bb6023e27ae52c7e32b49c848 +README.zh.md: 453d1f15004267137f58969820e2de8d08ae943a diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 058e72a46c..226270506d 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -63,7 +63,7 @@ Installation controls Host availability, not model permission. Full Agent Preset ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose eight platform packages carry Claude Code 2.1.220. A normal install selects one payload for the current OS, CPU, and Linux libc. For the current darwin-arm64 payload, `npm pack --dry-run --json` reports 74,858,812 packed bytes and 256,908,856 unpacked bytes; other platforms may differ, and these values are disclosure rather than an installation threshold. The keyless real-product test runs the SDK-selected CLI against a loopback Messages fixture and asserts that the shared subprocess argv begins with that platform package's native executable. 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`, whose eight platform packages carry Claude Code 2.1.220. A normal install selects one payload for the current OS, CPU, and Linux libc. For the current darwin-arm64 payload, `npm pack --dry-run --json` reports 74,858,812 packed bytes and 256,908,856 unpacked bytes; other platforms may differ, and these values are disclosure rather than an installation threshold. The keyless real-product test runs the SDK-selected CLI against a loopback Messages fixture and asserts that the shared subprocess argv begins with that platform package's native executable. Loader composition proves that installing the Bundle registers only the dormant Claude Code provider and starts no product process. Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload leaves provider registration dormant but makes the first delegation fail with the SDK's native-payload startup error. The provider neither probes a host CLI nor retries with one. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 05914165c2..453d1f1500 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -63,7 +63,7 @@ dsh --profile ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其八个平台包都携带 Claude Code 2.1.220。普通安装会按当前操作系统、CPU 及 Linux libc 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json` 报告压缩包为 74,858,812 字节、解包后为 256,908,856 字节;其他平台可能不同,这些数值只用于披露而不是安装阈值。无密钥真实产品测试会让 SDK 选择 CLI,通过回环 Messages fixture 运行它,并断言共享子进程 argv 的首项就是该平台包的原生可执行文件。Loader 组合证明两个产品包能够共存且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其八个平台包都携带 Claude Code 2.1.220。普通安装会按当前操作系统、CPU 及 Linux libc 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json` 报告压缩包为 74,858,812 字节、解包后为 256,908,856 字节;其他平台可能不同,这些数值只用于披露而不是安装阈值。无密钥真实产品测试会让 SDK 选择 CLI,通过回环 Messages fixture 运行它,并断言共享子进程 argv 的首项就是该平台包的原生可执行文件。Loader 组合证明安装该 Bundle 只会注册休眠的 Claude Code provider,不会启动产品进程。 如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,提供方注册仍保持休眠,但第一次委派会以 SDK 的原生载荷启动错误失败。提供方既不会探测宿主 CLI,也不会用它重试。 diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 6b20f2f838..fbeca1ab9a 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -257,15 +257,25 @@ export async function startClaudeCodeRun( spawnError = thrown(childError) } - if (cancelledBeforeCleanup || isAborted(request.signal)) { - throw new Error('subagent-claude-code: request was aborted before SDK startup') - } + const cancelled = cancelledBeforeCleanup || isAborted(request.signal) if (closeError !== undefined) { + const failures = cancelled + ? [ + new Error('subagent-claude-code: request was aborted before SDK startup'), + spawnError, + closeError, + ] + : [spawnError, closeError] throw new AggregateError( - [spawnError, closeError], - `subagent-claude-code: Claude Code process startup failed: ${spawnError.message}; query cleanup also failed`, + failures, + cancelled + ? `subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: ${spawnError.message}; query cleanup also failed` + : `subagent-claude-code: Claude Code process startup failed: ${spawnError.message}; query cleanup also failed`, ) } + if (cancelled) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } throw spawnError } if (child !== undefined) { 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 bfbc117a70..521fdf2d2d 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 @@ -945,6 +945,34 @@ describe('run publication, cancellation, and settlement', () => { )).rejects.toThrow('aborted before SDK startup') expect(cancelledFailedClose).toHaveBeenCalledOnce() + const cancelledFailedSpawnCloseError = new Error('cancelled query close failed') + const cancelledFailedSpawnClose = vi.fn(() => { + throw cancelledFailedSpawnCloseError + }) + const cancelledFailedSpawnWithCloseFailure = fakeChild({ + pid: -1, + doneError: spawnError, + }) + const failedSpawnAbortWithCloseFailure = new AbortController() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + failedSpawnAbortWithCloseFailure.abort(new Error('startup cancelled')) + return queryFrom([], undefined, cancelledFailedSpawnClose) + }) + const cancelledWithCloseFailure = startClaudeCodeRun( + request(undefined, failedSpawnAbortWithCloseFailure.signal), + { ...unused.spec, spawn: () => cancelledFailedSpawnWithCloseFailure.handle }, + ) + await expect(cancelledWithCloseFailure).rejects.toMatchObject({ + message: 'subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: spawn /sdk/claude EACCES; query cleanup also failed', + errors: [ + expect.objectContaining({ message: 'subagent-claude-code: request was aborted before SDK startup' }), + spawnError, + cancelledFailedSpawnCloseError, + ], + }) + expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce() + const failedSpawnCloseError = new Error('query close failed') const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) const failedSpawnWithCloseFailure = fakeChild({ diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 2711220b5e..6f111f4ffc 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: 18e805f0e0a1d8d33ba77182ed73d213beb62e07 -README.zh.md: 7e376de43092b25c4206ee5e1cf5d736c2f1c910 +README.md: 3d59ca1eaf3db9dd9d9d2cd451692ebd2a956ef4 +README.zh.md: abff7b569e2ce8261366c004ab6d03ea53300fdb diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 18e805f0e0..3d59ca1eaf 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -27,26 +27,15 @@ 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. -This package is an optional Profile Bundle. Install it into the target Profile, then restart that Profile; its declared `cordis.patch.yml` layer registers only the dormant `codex` Host provider and starts no Codex process. Removing the package withdraws that provider on the next Profile start. - -```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex -dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex -dsh --profile -``` - -Installation controls Host availability, not model permission. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to new agents composed from the copy. The Profile's own patch can replace the Bundle row's complete `config`, while a custom Host composition can still mount the package directly. +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 -# $DSH_HOME/profiles//cordis.patch.yml (optional provider override) - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' config: env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY -``` -```yaml -# A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 7e376de430..abff7b569e 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -27,26 +27,15 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 -本包是可选的 Profile Bundle。将它安装进目标 Profile 后重启该 Profile;包所声明的 `cordis.patch.yml` 层只注册休眠的 `codex` Host provider,不会启动 Codex 进程。移除该包后,下一次 Profile 启动会撤回这一 provider。 - -```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex -dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex -dsh --profile -``` - -安装决定 Host 可用性,而不是模型权限。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的新 agent 暴露 `subagent_codex`。Profile 自己的 patch 可以替换 Bundle 行的完整 `config`,而自定义 Host 组合仍可直接挂载本包。 +随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Codex 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。自定义宿主组装仍可直接使用两条配置行。 ```yaml -# $DSH_HOME/profiles//cordis.patch.yml (optional provider override) - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' config: env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY -``` -```yaml -# A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/packages/subagent/subagent-codex/cordis.patch.yml b/packages/subagent/subagent-codex/cordis.patch.yml deleted file mode 100644 index 75e7464574..0000000000 --- a/packages/subagent/subagent-codex/cordis.patch.yml +++ /dev/null @@ -1,6 +0,0 @@ -# This optional Profile layer registers the dormant Codex provider. Agent -# presets separately decide whether one session receives its delegation tool. - -- insert: - - id: subagent-codex - name: '@deepseek-ai/dsh-subagent-codex' diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index aa05afe260..0256ee8e21 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -28,18 +28,13 @@ "files": [ "lib/index.js", "lib/invariant.js", - "cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "MIT", - "dsh": { - "bundle": { - "patch": "./cordis.patch.yml" - } - }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", @@ -47,7 +42,6 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { @@ -56,6 +50,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index afdec98305..6c4019f8c8 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -13,13 +12,6 @@ const fixtureDir = fileURLToPath(new URL( )) const driver = join(fixtureDir, 'driver.ts') const configPath = join(fixtureDir, 'cordis.yml') -const packageDir = fileURLToPath(new URL('..', import.meta.url)) -const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { - dsh?: { bundle?: { patch?: string } } -} -const bundlePatch = manifest.dsh?.bundle?.patch -if (bundlePatch === undefined) throw new Error('Codex package must declare a Bundle patch') -const bundlePatchPath = join(packageDir, bundlePatch) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('Codex provider public Loader composition', () => { @@ -30,7 +22,6 @@ describe('Codex provider public Loader composition', () => { binScript: driver, libBinScript: driver, configPath, - binArgs: [configPath, bundlePatchPath], tsconfigPath: repoTsconfig, env: { // Loading the optional package must not probe or start a Codex binary. diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 4fa001a9f4..37b2e9ff0b 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1,10 +1,6 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' import { PassThrough } from 'node:stream' -import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import * as yaml from 'js-yaml' import { describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' @@ -264,31 +260,6 @@ function turnCompleted( } describe('task admission and package contracts', () => { - it('ships one independently installable provider-only Bundle patch', () => { - const root = fileURLToPath(new URL('..', import.meta.url)) - const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { - dependencies?: Record - peerDependencies?: Record - files?: string[] - dsh?: { bundle?: { patch?: string } } - } - expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') - expect(manifest.files).toContain('cordis.patch.yml') - expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-sdk-protocol') - expect(manifest.peerDependencies).not.toHaveProperty('@deepseek-ai/dsh-sdk-protocol') - expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code') - - const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) - const rows = Array.isArray(parsed) - ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) - : [] - expect(rows).toEqual([{ - id: 'subagent-codex', - name: '@deepseek-ai/dsh-subagent-codex', - }]) - expect(JSON.stringify(rows)).not.toContain('tool-subagent') - }) - it('resolves the fixed app-server command through the Windows npm shim boundary', () => { expect(codexAppServerArgv('win32')).toEqual([ 'cmd.exe', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64ce4f8242..0b13703008 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7114,9 +7114,6 @@ importers: packages/subagent/subagent-codex: dependencies: - '@deepseek-ai/dsh-sdk-protocol': - specifier: workspace:^ - version: link:../../sdk/protocol '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -7139,6 +7136,9 @@ importers: '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../test-support/loader-smoke + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/verify-config-source-ownership.spec.ts b/scripts/verify-config-source-ownership.spec.ts index 0c675c4f1a..3a099156c2 100644 --- a/scripts/verify-config-source-ownership.spec.ts +++ b/scripts/verify-config-source-ownership.spec.ts @@ -14,7 +14,7 @@ describe('configuration source ownership gate', () => { it('rejects inline endpoints in shipped bundle patches', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-config-source-ownership-')) roots.push(root) - const directory = join(root, 'packages/subagent/subagent-codex') + const directory = join(root, 'packages/subagent/subagent-claude-code') mkdirSync(directory, { recursive: true }) writeFileSync( join(directory, 'cordis.patch.yml'), @@ -22,7 +22,7 @@ describe('configuration source ownership gate', () => { ) expect(collectConfigSourceOwnershipViolations(root)).toEqual([ - 'packages/subagent/subagent-codex/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + 'packages/subagent/subagent-claude-code/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + ' environment snapshot; inlining here bypasses both ladders.', ]) diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts index e889209516..07f655823b 100644 --- a/scripts/verify-cordis-config.spec.ts +++ b/scripts/verify-cordis-config.spec.ts @@ -117,17 +117,12 @@ describe('workspace Bundle discovery and product dependency closures', () => { ]) }) - it('keeps the default and two optional product closures independent', () => { + it('keeps the default and optional Claude Code closure independent', () => { const shipped = productionClosure('@deepseek-ai/dsh') expect(shipped).not.toContain('@deepseek-ai/dsh-subagent-codex') expect(shipped).not.toContain('@deepseek-ai/dsh-subagent-claude-code') expect(shipped).not.toContain('@anthropic-ai/claude-agent-sdk') - const codex = productionClosure('@deepseek-ai/dsh-subagent-codex') - expect(codex).toContain('@deepseek-ai/dsh-sdk-protocol') - expect(codex).not.toContain('@deepseek-ai/dsh-subagent-claude-code') - expect(codex).not.toContain('@anthropic-ai/claude-agent-sdk') - const claudeCode = productionClosure('@deepseek-ai/dsh-subagent-claude-code') expect(claudeCode).toContain('@anthropic-ai/claude-agent-sdk') expect(claudeCode).not.toContain('@deepseek-ai/dsh-subagent-codex') From 15612c19986dd17eb5e8957751b377a864ef59dc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 14 Aug 2026 16:40:23 +0800 Subject: [PATCH 09/95] review fix: trim duplicate closure evidence --- apps/cli/reference/README.i18n.yaml | 4 +-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- scripts/verify-cordis-config.spec.ts | 44 +--------------------------- 4 files changed, 5 insertions(+), 47 deletions(-) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0a5792a21c..cb2090c1f2 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: c6c3f61c2910b84b9364ef7eaa13b8c0c011999b -README.zh.md: d6f3e7f1f508724e54e59df01e93199193ddc506 +README.md: f95973c05401d73a70db07b6ea4c76cd16f406f3 +README.zh.md: aa8a877bc53e430c867d384a2b0653255db5079d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index c6c3f61c29..f95973c054 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -49,7 +49,7 @@ dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating the Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` take effect through hot reload. On the next start, the installed package registers only its dormant Host provider and starts no Claude process. The Bundle installs the pinned Agent SDK and one matching private platform CLI, uses only that CLI, and never falls back to a host `claude`. Authentication and native Claude settings remain user-managed; the [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) discloses the current platform payload size and missing-payload failure. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the Claude Code row before a new Agent can see that tool. The Codex provider remains an explicitly mounted Host plugin that resolves `codex` from `PATH`; the default dsh dependency closure includes neither provider nor the Claude Agent SDK or its platform payloads. +The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating the Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` take effect through hot reload. On the next start, the Bundle registers its dormant Host provider; a copied Preset must separately enable the matching tool row for new Agents. The [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) owns executable, authentication, payload, and failure details; the [subagent package reference](../../../packages/subagent/README.md) owns the current Codex deployment path; and the [base Bundle reference](../../../packages/bundle/base/README.md) owns the default dependency closure. ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d6f3e7f1f5..aa8a877bc5 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -49,7 +49,7 @@ dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑通过热重载生效。下一次启动时,已安装的包只注册休眠的 Host provider,不会启动 Claude 进程。该 Bundle 会安装锁定的 Agent SDK 与一个匹配的私有平台 CLI,只使用该 CLI,并且绝不会回退到宿主 `claude`。Claude 的身份验证与原生设置仍由用户管理;[Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)会披露当前平台载荷体积与载荷缺失时的失败行为。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用 Claude Code 行,新 Agent 才能看到该工具。Codex provider 仍须作为 Host 插件显式挂载,并从 `PATH` 解析 `codex`;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK 或其平台载荷。 +pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑通过热重载生效。下一次启动时,Bundle 会注册休眠的 Host provider;还须在复制出的 Preset 中单独启用对应工具行,新 Agent 才能看到该工具。[Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)负责可执行文件、身份验证、载荷与失败细节;[subagent 包参考](../../../packages/subagent/README.md)负责当前 Codex 部署路径;[base Bundle 参考](../../../packages/bundle/base/README.md)负责默认依赖闭包。 ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts index 07f655823b..f63031e46c 100644 --- a/scripts/verify-cordis-config.spec.ts +++ b/scripts/verify-cordis-config.spec.ts @@ -4,10 +4,9 @@ * metadata field must stay static, and a disabled expression must parse. */ -import { globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { bundleManifestPaths, @@ -15,36 +14,6 @@ import { metadataExpressionErrors, } from './verify-cordis-config.ts' -interface WorkspaceManifest { - name?: string - dependencies?: Record - optionalDependencies?: Record - peerDependencies?: Record -} - -const repoRoot = fileURLToPath(new URL('..', import.meta.url)) - -function productionClosure(entry: string): Set { - const manifests = new Map() - for (const path of globSync(['apps/*/package.json', 'packages/*/*/package.json'], { cwd: repoRoot })) { - const manifest = JSON.parse(readFileSync(join(repoRoot, path), 'utf8')) as WorkspaceManifest - if (manifest.name !== undefined) manifests.set(manifest.name, manifest) - } - const visited = new Set() - const pending = [entry] - for (let name = pending.pop(); name !== undefined; name = pending.pop()) { - if (visited.has(name)) continue - visited.add(name) - const manifest = manifests.get(name) - pending.push( - ...Object.keys(manifest?.dependencies ?? {}), - ...Object.keys(manifest?.optionalDependencies ?? {}), - ...Object.keys(manifest?.peerDependencies ?? {}), - ) - } - return visited -} - describe('verify-cordis-config metadata expressions', () => { it('accepts a disabled !!js expression', () => { const problems = metadataExpressionErrors( @@ -116,15 +85,4 @@ describe('workspace Bundle discovery and product dependency closures', () => { `${file}: @deepseek-ai/dsh-missing-plugin must be declared in ${manifestPath} dependencies`, ]) }) - - it('keeps the default and optional Claude Code closure independent', () => { - const shipped = productionClosure('@deepseek-ai/dsh') - expect(shipped).not.toContain('@deepseek-ai/dsh-subagent-codex') - expect(shipped).not.toContain('@deepseek-ai/dsh-subagent-claude-code') - expect(shipped).not.toContain('@anthropic-ai/claude-agent-sdk') - - const claudeCode = productionClosure('@deepseek-ai/dsh-subagent-claude-code') - expect(claudeCode).toContain('@anthropic-ai/claude-agent-sdk') - expect(claudeCode).not.toContain('@deepseek-ai/dsh-subagent-codex') - }) }) From b2178ade8028597e7565db77dfe3ef4ecd68b99d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 03:25:43 +0800 Subject: [PATCH 10/95] feat(subagent): make Codex provider directly installable --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 14 +- ...ct-subagent-providers-in-shared-host.zh.md | 14 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 10 +- ...ude-code-and-codex-subagent-backends.zh.md | 10 +- ...ludes-product-subagent-providers.i18n.yaml | 4 +- ...dsh-excludes-product-subagent-providers.md | 10 +- ...-excludes-product-subagent-providers.zh.md | 10 +- THIRD_PARTY_NOTICES.md | 18 ++- .../agent-presets/code/agent.cordis.yml | 8 +- .../agent-presets/cordis/agent.cordis.yml | 8 +- .../editing-cordis-compositions/SKILL.md | 10 +- .../agent-presets/standard/agent.cordis.yml | 8 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 7 +- apps/cli/reference/README.zh.md | 7 +- apps/cli/tests/web-agent-presets.e2e.ts | 59 ++++++--- apps/web/tests/skill-tool-row.e2e.ts | 2 +- .../snapshots/skill-tool-row/ui.expected.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 15 +-- docs/module-graph.zh.md | 15 +-- .../subagent/subagent-codex/cordis.yml | 7 +- .../subagent/subagent-codex/driver.ts | 9 +- .../tests/snapshots/skill-load/session.jsonl | 2 +- packages/bundle/README.i18n.yaml | 4 +- packages/bundle/README.md | 2 +- packages/bundle/README.zh.md | 2 +- packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 2 +- packages/subagent/README.zh.md | 2 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 35 +++-- packages/subagent/subagent-codex/README.zh.md | 35 +++-- .../subagent/subagent-codex/cordis.patch.yml | 5 + packages/subagent/subagent-codex/package.json | 12 +- packages/subagent/subagent-codex/src/index.ts | 4 +- packages/subagent/subagent-codex/src/run.ts | 54 ++++++-- .../tests/loader-composition.e2e.ts | 9 ++ .../subagent-codex/tests/real-deepseek.e2e.ts | 8 +- .../subagent-codex/tests/real-product.spec.ts | 41 +++++- .../tests/subagent-codex.spec.ts | 91 +++++++++++-- pnpm-lock.yaml | 12 +- scripts/gen-third-party-notices.spec.ts | 49 +++++++ scripts/gen-third-party-notices.ts | 121 +++++++++++++++++- scripts/verify-cordis-config.spec.ts | 44 +------ 50 files changed, 571 insertions(+), 242 deletions(-) create mode 100644 packages/subagent/subagent-codex/cordis.patch.yml 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 index b6bff51d29..14fc409ebc 100644 --- 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 @@ -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-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 564fd315c41c2f6999eed65bd7241e8b7167f43e -2026-08-10-product-subagent-providers-in-shared-host.zh.md: eaa24bb323191b14edbd2f756033b700374f5356 +2026-08-10-product-subagent-providers-in-shared-host.md: f1eac30e2984b6b9c1a0e83594a8f48dde690811 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 14b7afcf414b9c058670d99531385da49d91fa4e 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 index 564fd315c4..f1eac30e29 100644 --- 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 @@ -6,21 +6,21 @@ 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) are separate packages loaded beside the common subagent tool. The Claude Code package is directly installable as a Profile Bundle, while a deployment mounts the Codex package explicitly. Agent Presets are the ordinary owner of one agent's model-visible tools, but a preset cannot safely own either provider: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Host availability and Preset tool grants are therefore separate deployment and agent-authoring decisions. +The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) are separate, directly installable Profile Bundle packages loaded beside the common subagent tool. Agent Presets are the ordinary owner of one agent's model-visible tools, but a preset cannot safely own either provider: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Bundle installation and Preset tool grants are therefore separate deployment and agent-authoring decisions. The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while granting 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 -The Claude Code Bundle and an explicit Codex Host row each load their fixed provider exactly once in the shared 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 grant neither tool, either one, or both without changing the provider registry. A tool whose provider is absent remains unavailable rather than mounting another provider in the Agent plane. +Each product Bundle loads its fixed provider exactly once in the shared 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 grant neither tool, either one, or both without changing the provider registry. A tool whose provider Bundle is absent remains unavailable rather than mounting another provider in the Agent plane. -The [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) partially supersedes only this note's former default-inclusion choice: the base bundle excludes both providers, the Claude Code package owns a directly installable Bundle patch, and Codex remains an explicitly mounted Host plugin. This note continues to own process-wide Host placement whenever either provider is present. The provider-contract 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 [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) partially supersedes only this note's former default-inclusion choice: the base bundle excludes both providers, and each provider package owns its directly installable Bundle patch. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract 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 have different executable owners. Codex starts a host `codex` from `PATH`. The Claude Code Bundle installs its pinned Agent SDK and matching platform CLI; the provider lets that SDK choose the private native executable and passes the command through the shared subprocess owner without consulting or falling back to a host `claude`. Loading either provider only registers it and creates no product state, probes no version or authentication, and adds no product-specific setting. A missing Codex command or Claude platform payload, authentication failure, and other product failures remain local to the attempted delegation. +The Bundles have different executable owners. The Codex package pins the official wrapper and six platform aliases; the provider runs the package-declared wrapper, which selects the private native payload. The Claude Code package pins its Agent SDK and eight platform packages; the provider lets that SDK select the private native executable. Neither provider consults or falls back to a host product command, while native configuration and authentication remain authoritative. Loading either Bundle only registers the provider and creates no product state, probes no version or authentication, and adds no product-specific setting. A missing platform payload, authentication failure, and other product failures remain local to the attempted delegation. ## Verification -Real composition loads either no Claude Code Bundle or the Claude Code Bundle and crosses that availability with Agent Presets that leave its tool disabled or grant it. It proves the Host registry and model-visible tools reflect those two decisions, no product process starts during composition, and Preset edits affect only later Sessions. Existing Codex Loader and provider tests separately prove explicit Host composition and host executable resolution. Keyless ACP snapshots pin the model-visible tool schemas, while provider tests prove SDK platform-payload selection without fallback for Claude Code, failure, cancellation, and process-tree quiescence. +Real composition loads no product Bundle, Codex only, Claude Code only, or both, then crosses that availability with Agent Presets that grant neither tool, either one, or both. It proves the Host registry and model-visible tools reflect those independent decisions, no product process starts during composition, and Preset edits affect only later Sessions. Package Loader and real-product tests separately prove each private runtime, missing-payload failure without host fallback, cancellation, and process-tree quiescence. Keyless ACP snapshots pin the model-visible tool schemas and generic Job controls. ## Alternatives considered @@ -34,6 +34,6 @@ Real composition loads either no Claude Code Bundle or the Claude Code Bundle an ## Consequences -A user installs the Claude Code Bundle only in Profiles that need it, while a deployment that uses Codex mounts that Host plugin explicitly. Model-visible grants use the same Agent Preset authoring path as other plugins. Each new Session receives the intersection of its preset's tool rows and the Host's available providers. A present but ungranted product remains dormant and consumes its package and module-loading footprint but no product process, login, model call, or product home; an absent product contributes no provider closure. +A user installs only the product Bundles a Profile needs and manages model-visible grants through the same Agent Preset authoring path as other plugins. Each new Session receives the intersection of its preset's tool rows and the Host's installed providers. An installed but ungranted product remains dormant and consumes its package and module-loading footprint but no product process, login, model call, or product home; an uninstalled product contributes no provider or product-runtime closure. -The Host registry remains the single provider authority, the Profile Bundle or explicit Host composition remains the deployment availability authority, and each Preset remains the model-tool authority. This explicit two-gate lifecycle avoids a global enable switch and keeps package removal independent from per-session authoring. +The Host registry remains the single provider authority, each Bundle remains the deployment availability authority, and each Preset remains the model-tool authority. This explicit two-gate lifecycle avoids a global enable switch and keeps package removal independent from per-session authoring. 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 index eaa24bb323..14b7afcf41 100644 --- 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 @@ -6,21 +6,21 @@ Status: implemented ## 问题 -[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)由两个独立包实现,并在通用 subagent 工具旁加载。Claude Code 包可作为 Profile Bundle 直接安装,而部署环境会显式挂载 Codex 包。Agent Preset 是单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有任一产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。因此,Host 可用性与 Preset 工具授权分别属于部署决策和 agent 创作决策。 +[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)由两个可直接安装的独立 Profile Bundle 包实现,并在通用 subagent 工具旁加载。Agent Preset 是单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有任一产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。因此,Bundle 安装与 Preset 工具授权分别属于部署决策和 agent 创作决策。 归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具授权仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 ## 决策 -Claude Code Bundle 与显式 Codex Host 行都会在共享 Host 平面中恰好加载一次各自固定的提供方。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不授权任何工具、只授权其中一个或同时授权两者,而无需更改提供方注册表。若工具对应的提供方不存在,该工具仍不可用,而不会在 Agent 平面中另行挂载提供方。 +每个产品 Bundle 都会在共享 Host 平面中恰好加载一次各自固定的提供方。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不授权任何工具、只授权其中一个或同时授权两者,而无需更改提供方注册表。若工具对应的提供方 Bundle 未安装,该工具仍不可用,而不会在 Agent 平面中另行挂载提供方。 -[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只部分取代本说明先前关于默认包含提供方的选择:base 组合包排除两个提供方,Claude Code 包拥有可直接安装的 Bundle patch,而 Codex 仍是显式挂载的 Host 插件。本说明继续负责任一提供方存在时的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 +[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只部分取代本说明先前关于默认包含提供方的选择:base 组合包排除两个提供方,每个提供方包都拥有可直接安装的 Bundle patch。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -两个提供方的可执行文件归属不同。Codex 会启动从 `PATH` 解析出的宿主 `codex`。Claude Code Bundle 会安装锁定的 Agent SDK 与匹配平台 CLI;提供方让 SDK 选择该私有原生可执行文件,再把命令交给共享子进程责任方,既不查询也不回退宿主 `claude`。加载任一提供方只会完成注册,不会创建产品状态、探测版本或身份验证,也不会新增产品专属设置。Codex 命令缺失、Claude 平台载荷缺失、身份验证失败和其他产品故障仍局限于发生问题的那次委派。 +两个 Bundle 的可执行文件归属不同。Codex 包锁定官方 wrapper 与六个平台 alias;提供方运行包所声明的 wrapper,再由它选择私有原生载荷。Claude Code 包锁定 Agent SDK 与八个平台包;提供方让 SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令,原生配置与身份验证仍保持权威。加载任一 Bundle 只会完成提供方注册,不会创建产品状态、探测版本或身份验证,也不会新增产品专属设置。平台载荷缺失、身份验证失败和其他产品故障仍局限于发生问题的那次委派。 ## 验证 -真实组装会覆盖未安装 Claude Code Bundle 与已安装该 Bundle 两种状态,并分别使用保留禁用行或授权该工具的 Agent Preset。测试证明 Host 注册表和模型可见工具会反映这两个决策,组装期间不会启动产品进程,而且 Preset 编辑只影响后续 Session。现有 Codex Loader 与提供方测试会另行证明显式 Host 组装和宿主可执行文件解析。无密钥 ACP(Agent Client Protocol)快照固定模型可见工具 schema,提供方测试则证明 Claude Code 的 SDK 平台载荷选择与无回退行为,以及失败、取消和进程树完全停稳。 +真实组装会覆盖未安装产品 Bundle、仅安装 Codex、仅安装 Claude Code 或两者都安装四种状态,再与不授权工具、只授权其中一个或同时授权两者的 Agent Preset 交叉。测试证明 Host 注册表与模型可见工具会反映这两个独立决策,组装期间不会启动产品进程,而且 Preset 编辑只影响后续 Session。包级 Loader 与真实产品测试分别证明两个私有运行时、载荷缺失时不回退宿主命令、取消和进程树完全停稳。无密钥 ACP(Agent Client Protocol)快照固定模型可见工具 schema 与通用 Job 控制。 ## 考虑过的替代方案 @@ -34,6 +34,6 @@ Claude Code Bundle 与显式 Codex Host 行都会在共享 Host 平面中恰好 ## 后果 -用户只在需要 Claude Code 的 Profile 中安装该 Bundle;使用 Codex 的部署会显式挂载对应 Host 插件。模型可见授权仍通过与其他插件相同的 Agent Preset 创作路径管理。每个新 Session 会获得其 preset 工具行与 Host 可用提供方的交集。存在但未授权的产品保持休眠,会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录;缺席的产品不会进入提供方闭包。 +用户只安装 Profile 所需的产品 Bundle,并通过与其他插件相同的 Agent Preset 创作路径管理模型可见授权。每个新 Session 会获得其 preset 工具行与 Host 已安装提供方的交集。已安装但未授权的产品保持休眠,会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录;未安装的产品不会进入提供方或产品运行时闭包。 -Host 注册表仍是提供方的唯一权威,Profile Bundle 或显式 Host 组装仍是部署可用性的权威,每个 Preset 仍是模型工具的权威。这个显式的双门生命周期避免全局启用开关,并让包移除与按会话创作保持独立。 +Host 注册表仍是提供方的唯一权威,每个 Bundle 仍是部署可用性的权威,每个 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 77af3b4cb6..5ad029ab36 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: 29f438ca2ddcaea25bc7590fdca8879b80ada80c -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dba880267c8d73cc34414910db029f17a0d7d6a4 +2026-08-04-claude-code-and-codex-subagent-backends.md: 9ea8ad65d8e5aaebf08753da3f4ef667cec2a236 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: a58f4c8273972499b90e29ff318bc7446491fd0e 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 29f438ca2d..9ea8ad65d8 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 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) owns process-wide placement, the [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns both providers' default exclusion plus Claude Code's optional Bundle and Codex's explicit Host installation, and the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +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) owns process-wide placement, the [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and default exclusion, and the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -34,7 +34,7 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product ## Codex provider -`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. +`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider, resolves the `codex` bin declared by its pinned `@openai/codex@0.147.0` package, and starts that wrapper through the current Node executable with `app-server --stdio`. The wrapper selects the private native platform payload; the provider neither resolves nor falls back to a host `codex`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. @@ -62,11 +62,11 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies both fixed one-shot tools expose optional background scheduling alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. +The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, wrapper/native whole-tree exit, and missing-payload failure without host fallback. 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, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and 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. Unit coverage proves that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and surfaces the SDK's missing-payload error without host fallback. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader coverage resolves Codex through explicit Host composition and Claude Code through its optional Bundle while starting neither product. +The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and 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. Unit coverage proves that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and surfaces the SDK's missing-payload error without host fallback. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader coverage resolves both products through their optional Bundle patches while starting neither product. 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. @@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. -Every delegation pays for a fresh product process and independent model context. The product payload reaching the parent is final text only; background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Codex behavior depends on the deployment's host CLI and native configuration; Claude Code behavior depends on the Bundle-pinned platform CLI plus native account 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. +Every delegation pays for a fresh product process and independent model context. The product payload reaching the parent is final text only; background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Both products use Bundle-pinned platform CLIs plus native account 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. Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe. 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 dba880267c..a58f4c8273 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 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责进程级放置,[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责两个提供方的默认发行排除、Claude Code 的可选 Bundle 与 Codex 的显式 Host 安装,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责进程级放置,[生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责两个彼此独立的可选 Bundle 及其默认发行排除,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -34,7 +34,7 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product ## Codex 提供方 -`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,解析锁定的 `@openai/codex@0.147.0` 包所声明的 `codex` bin,并使用当前 Node 可执行文件加 `app-server --stdio` 启动该 wrapper。Wrapper 会选择私有原生平台载荷;提供方既不解析也不回退宿主 `codex`。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 @@ -62,11 +62,11 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,在同一个上下文中验证两个固定一次性工具会与通用 Job 控制工具一起公开可选后台调度,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消、wrapper/原生整棵进程树退出,以及载荷缺失时不回退宿主命令的失败。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消和整棵进程树退出。单元覆盖会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖、直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并且在载荷缺失时原样暴露 SDK 错误且不回退宿主 CLI。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 覆盖会通过显式 Host 组装解析 Codex,并通过可选 Bundle 解析 Claude Code,且不会启动任一产品。 +Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消和整棵进程树退出。单元覆盖会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖、直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并且在载荷缺失时原样暴露 SDK 错误且不回退宿主 CLI。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -90,6 +90,6 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八 用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 -每次委派都要承担新建产品进程和独立模型上下文的开销。到达父级的产品载荷仍只有最终文本;后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。Codex 行为取决于部署环境的宿主 CLI 与原生配置;Claude Code 行为取决于 Bundle 锁定的平台 CLI,以及原生账户和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 +每次委派都要承担新建产品进程和独立模型上下文的开销。到达父级的产品载荷仍只有最终文本;后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。两个产品都使用 Bundle 锁定的平台 CLI,以及原生账户和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml index f842aecafe..6d277ed52d 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.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/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md -2026-08-12-production-dsh-excludes-product-subagent-providers.md: 779bff6f668c086722f817a28a224182d0fbac09 -2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: 27106668f8b81491d43e2033c1468799783c0584 +2026-08-12-production-dsh-excludes-product-subagent-providers.md: faa14a5848a421389d8c427f844c4dedc118a0fd +2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: 33be6118b7e5de261d22e62b1df98e1afad7a08c diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md index 779bff6f66..faa14a5848 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md @@ -6,17 +6,17 @@ English | [中文](2026-08-12-production-dsh-excludes-product-subagent-providers ## Problem -`@deepseek-ai/dsh` receives the `@deepseek-ai/dsh-base` dependency closure. Including the Codex and Claude Code subagent providers there makes every production install download optional product integration code, including the Claude Agent SDK and its roughly 250 MB unpacked platform CLI payload, even when neither integration is used. +`@deepseek-ai/dsh` receives the `@deepseek-ai/dsh-base` dependency closure. Including the Codex and Claude Code subagent providers there makes every production install download optional product integration code and large platform CLI payloads, even when neither integration is used. ## Decision -This decision partially supersedes only the default-inclusion part of the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. The Claude Code provider package is a directly installable Profile Bundle whose `dsh.bundle.patch` points to one package-owned `cordis.patch.yml`. That patch contributes exactly one self-provider Host row and no Agent tool row. The Codex package remains available for deployments that mount it explicitly. +This decision partially supersedes only the default-inclusion part of the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. Each provider package is a directly installable Profile Bundle whose `dsh.bundle.patch` points to one package-owned `cordis.patch.yml`. Each patch contributes exactly one self-provider Host row and no Agent tool row. -The two optional integrations remain independent. Codex continues to use a host `codex` from `PATH`. The Claude Code Bundle owns the pinned Agent SDK and the matching platform CLI selected from the SDK's optional dependencies; production uses that private CLI and never falls back to a host `claude`. Installing the Claude Code Bundle does not pull in the Codex package, and the default `@deepseek-ai/dsh` production closure contains neither provider, the Claude Agent SDK, nor its platform payloads. The Bundle registers a dormant provider on the next Profile start, while an Agent Preset independently decides whether a new Session receives its tool. Installation brings only the Claude Code package closure onto disk; it does not start a product, authenticate an account, rewrite native settings, or grant model access. +The two Bundles remain independent. The Codex Bundle owns the pinned official wrapper and six platform aliases; production starts the package-declared wrapper and never falls back to a host `codex`. The Claude Code Bundle owns the pinned Agent SDK and matching platform CLI; production lets the SDK select that private CLI and never falls back to a host `claude`. Installing one Bundle does not pull in the other, and the default `@deepseek-ai/dsh` production closure contains neither provider nor either product runtime. Each installed Bundle registers a dormant provider on the next Profile start, while an Agent Preset independently decides whether a new Session receives the corresponding tool. Installation does not start a product, authenticate an account, rewrite native settings, or grant model access. ## Verification -Package tests pin the Claude Code Bundle manifest, published patch, exact self-provider row, and runtime closure. Claude coverage pins Agent SDK 0.3.220, Claude Code 2.1.220, all eight platform package identities and versions, the SDK-selected executable entering the shared subprocess owner, and first-delegation failure without host fallback when the payload is missing. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Production-closure tests prove the default and Claude-only dependency boundaries, while real Bundle-patch and Agent-Preset composition covers absent and installed Host states, disabled and enabled tool grants, later-Session adoption, and zero product processes. Existing Codex package tests continue to cover explicit Host composition and host executable resolution. The base bundle test continues to reject both provider dependencies and configuration rows. +Package tests pin both Bundle manifests, published patches, exact self-provider rows, and product runtime dependencies. Claude coverage pins Agent SDK 0.3.220, Claude Code 2.1.220, all eight platform packages, SDK-selected execution, and missing-payload failure without host fallback. Codex coverage pins wrapper 0.147.0, all six platform aliases, package-declared execution, native descendant quiescence, and the same missing-payload behavior. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Package/base assertions plus actual pnpm production evidence prove the default and selected-product dependency boundaries, while real Bundle-patch and Agent-Preset composition covers none, either product, both, the tool-grant intersection, later-Session adoption, and zero startup processes. ## Alternatives considered @@ -26,4 +26,4 @@ Package tests pin the Claude Code Bundle manifest, published patch, exact self-p ## Consequences -Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. A Profile can add or remove the Claude Code provider Bundle directly; the changed Host availability takes effect on the next Profile start and explicitly accepts its SDK plus one large platform CLI payload. A Codex deployment still mounts that provider explicitly and supplies its product CLI through `PATH`. A separately authored Agent Preset grants either model-visible tool only to newly composed Sessions. No wrapper package, meta Bundle, dynamic installer, or persisted product-enable state is introduced. +Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. A Profile can add or remove either provider Bundle independently; changed Host availability takes effect on the next Profile start, and selecting a product explicitly accepts its private platform payload. A separately authored Agent Preset grants either model-visible tool only to newly composed Sessions. No wrapper package beyond the products' official distributions, meta Bundle, dynamic installer, or persisted product-enable state is introduced. diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md index 27106668f8..33be6118b7 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md @@ -6,17 +6,17 @@ Status: implemented ## 问题 -`@deepseek-ai/dsh` 会获得 `@deepseek-ai/dsh-base` 的依赖闭包。如果 base 包含 Codex 与 Claude Code subagent 提供方,每次生产安装都会下载可选的产品集成代码,包括 Claude Agent SDK 及其解包后约 250 MB 的平台 CLI 载荷,即使用户并未使用任一集成。 +`@deepseek-ai/dsh` 会获得 `@deepseek-ai/dsh-base` 的依赖闭包。如果 base 包含 Codex 与 Claude Code subagent 提供方,每次生产安装都会下载可选的产品集成代码与大型平台 CLI 载荷,即使用户并未使用任一集成。 ## 决策 -本决策只部分取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)中关于默认包含提供方的部分:`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。Claude Code 提供方包是可直接安装的 Profile Bundle,其 `dsh.bundle.patch` 指向包自身拥有的 `cordis.patch.yml`。该 patch 恰好贡献一条挂载自身提供方的 Host 行,不包含 Agent 工具行。Codex 包仍供部署环境显式挂载。 +本决策只部分取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)中关于默认包含提供方的部分:`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。每个提供方包都是可直接安装的 Profile Bundle,其 `dsh.bundle.patch` 指向包自身拥有的 `cordis.patch.yml`。每份 patch 恰好贡献一条挂载自身提供方的 Host 行,不包含 Agent 工具行。 -两个可选集成彼此独立。Codex 继续使用 `PATH` 中的宿主 `codex`。Claude Code Bundle 自己负责锁定的 Agent SDK,以及从 SDK optional dependencies 中选出的匹配平台 CLI;生产运行只使用该私有 CLI,绝不会回退到宿主 `claude`。安装 Claude Code Bundle 不会带入 Codex 包,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一提供方,也不包含 Claude Agent SDK 或其平台载荷。该 Bundle 会在下次 Profile 启动时注册一个休眠提供方,而 Agent Preset 独立决定新 Session 是否获得对应工具。安装只会把 Claude Code 包闭包放到磁盘上;它不会启动产品、验证账户、改写原生设置或向模型授予访问权。 +两个 Bundle 彼此独立。Codex Bundle 自己负责锁定的官方 wrapper 与六个平台 alias;生产环境会启动包所声明的 wrapper,绝不会回退到宿主 `codex`。Claude Code Bundle 自己负责锁定的 Agent SDK 与匹配平台 CLI;生产环境让 SDK 选择该私有 CLI,绝不会回退到宿主 `claude`。安装其中一个 Bundle 不会带入另一个,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一提供方,也不包含任一产品运行时。每个已安装 Bundle 会在下次 Profile 启动时注册一个休眠提供方,而 Agent Preset 独立决定新 Session 是否获得对应工具。安装不会启动产品、验证账户、改写原生设置或向模型授予访问权。 ## 验证 -包测试会固定 Claude Code Bundle 的 manifest、发布 patch、准确的自身提供方行以及运行时闭包。Claude 覆盖会固定 Agent SDK 0.3.220、Claude Code 2.1.220、八个平台包的身份与版本、SDK 所选可执行文件进入共享子进程责任方的路径,以及载荷缺失时第一次委派失败且不回退宿主 CLI。工作区验证会从 Bundle 声明派生每个发布 patch,而非维护包目录。生产闭包测试证明默认与仅 Claude 两种依赖边界;真实 Bundle patch 与 Agent Preset 组装会覆盖 Host 中缺席和已安装两种状态、禁用和启用两种工具授权、后续 Session 采纳以及零产品进程。现有 Codex 包测试继续覆盖显式 Host 组装和宿主可执行文件解析。base 组合包测试仍会拒绝这两个提供方依赖与配置行。 +包测试会固定两个 Bundle 的 manifest、发布 patch、准确的自身提供方行与产品运行时依赖。Claude 覆盖会固定 Agent SDK 0.3.220、Claude Code 2.1.220、八个平台包、SDK 所选执行路径,以及载荷缺失时不回退宿主命令的失败。Codex 覆盖会固定 wrapper 0.147.0、六个平台 alias、包声明的执行路径、原生后代进程停稳,以及同样的载荷缺失行为。工作区验证会从 Bundle 声明派生每份发布 patch,而非维护包目录。包与 base 断言加上实际 pnpm 生产证据会证明默认与所选产品的依赖边界;真实 Bundle patch 与 Agent Preset 组装则覆盖未安装、任一单包、双包、工具授权交集、后续 Session 采纳以及零启动进程。 ## 考虑过的替代方案 @@ -26,4 +26,4 @@ Status: implemented ## 后果 -安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。Profile 可以直接添加或移除 Claude Code provider Bundle;Host 可用性的变化会在下次 Profile 启动时生效,并代表明确接受其 SDK 与一个大型平台 CLI 载荷。Codex 部署仍须显式挂载该 provider,并通过 `PATH` 提供产品 CLI。单独创作的 Agent Preset 仍只会向新组装的 Session 授予任一模型可见工具。本决策不引入 wrapper 包、meta Bundle、动态安装程序或持久化的产品启用状态。 +安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。Profile 可以独立添加或移除任一 provider Bundle;Host 可用性的变化会在下次 Profile 启动时生效,选择产品也代表明确接受其私有平台载荷。单独创作的 Agent Preset 仍只会向新组装的 Session 授予任一模型可见工具。本决策不会在产品官方发行版之外引入 wrapper 包,也不引入 meta Bundle、动态安装程序或持久化的产品启用状态。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 92b218ff33..b03dad17cd 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code and Codex platform payload closures. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). @@ -39,6 +39,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | @@ -113,6 +114,20 @@ The installed SDK 0.3.220 declares the following optional platform packages. Eac | [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +## Official Codex platform payloads + +The installed `@openai/codex` wrapper 0.147.0 declares the following optional-dependency aliases. Every alias resolves to an official platform-specific `@openai/codex` version that carries the native Codex CLI and its bundled resources; the declared license is verified against the payload installed for the current host. + +| Optional dependency alias | Published package | Version | Declared license | +| --- | --- | --- | --- | +| `@openai/codex-darwin-arm64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-darwin-arm64) | 0.147.0-darwin-arm64 | Apache-2.0 | +| `@openai/codex-darwin-x64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-darwin-x64) | 0.147.0-darwin-x64 | Apache-2.0 | +| `@openai/codex-linux-arm64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-linux-arm64) | 0.147.0-linux-arm64 | Apache-2.0 | +| `@openai/codex-linux-x64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-linux-x64) | 0.147.0-linux-x64 | Apache-2.0 | +| `@openai/codex-win32-arm64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-win32-arm64) | 0.147.0-win32-arm64 | Apache-2.0 | +| `@openai/codex-win32-x64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-win32-x64) | 0.147.0-win32-x64 | Apache-2.0 | + + ## Development-only npm dependencies External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. @@ -122,7 +137,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | -| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | | [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 581f51773e..eedabe4cd7 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -198,10 +198,10 @@ toolName: subagent_fork backgroundMode: continuable - # Production dsh does not install these optional providers. A deployment - # mounts Codex explicitly; the Claude Code Bundle mounts its provider once - # on the host plane. Copy this preset, then remove `disabled` from the - # matching tool row; Host availability alone grants no tool. + # Production dsh does not install these optional providers. Install the + # matching Bundle in this Profile and restart the Host, then copy this + # preset and remove `disabled` from the matching tool row. Host availability + # alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index db5f01021f..aef4a250e4 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -185,10 +185,10 @@ toolName: subagent_fork backgroundMode: continuable - # Production dsh does not install these optional providers. A deployment - # mounts Codex explicitly; the Claude Code Bundle mounts its provider once - # on the host plane. Copy this preset, then remove `disabled` from the - # matching tool row; Host availability alone grants no tool. + # Production dsh does not install these optional providers. Install the + # matching Bundle in this Profile and restart the Host, then copy this + # preset and remove `disabled` from the matching tool row. Host availability + # alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true 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 3a1fb24947..ed99349d95 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 @@ -123,16 +123,16 @@ After a clean mount-validation, ask the user to start a session on the new prese ## Native product subagents -The Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider: +Codex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers: ```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -The Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start. - -Codex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool. +Each Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: @@ -156,7 +156,7 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o maxDepth: provider-managed ``` -The two rows are independent. Leaving both disabled preserves the copied preset; enabling one exposes only that available product tool. The Claude Code row requires its Bundle, while the Codex row requires an explicit Host composition and a host `codex` on `PATH`. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base Host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Neither installing the Claude Code Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings. +The two rows are independent. Leaving both disabled preserves the copied preset; enabling one exposes only that installed product tool. The Codex Bundle exclusively uses the wrapper and native platform payload selected by its pinned official package, while the Claude Code Bundle exclusively uses the platform CLI selected by its pinned Agent SDK. Neither provider inspects or falls back to a host product command, and a missing optional payload fails the first delegation. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base Host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Neither installing a product Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings. ## What not to move into a preset diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 4637293576..3bccbc7365 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -197,10 +197,10 @@ toolName: subagent_fork backgroundMode: continuable - # Production dsh does not install these optional providers. A deployment - # mounts Codex explicitly; the Claude Code Bundle mounts its provider once - # on the host plane. Copy this preset, then remove `disabled` from the - # matching tool row; Host availability alone grants no tool. + # Production dsh does not install these optional providers. Install the + # matching Bundle in this Profile and restart the Host, then copy this + # preset and remove `disabled` from the matching tool row. Host availability + # alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0a5792a21c..ef63dd2fc6 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: c6c3f61c2910b84b9364ef7eaa13b8c0c011999b -README.zh.md: d6f3e7f1f508724e54e59df01e93199193ddc506 +README.md: 7828f55a2e4adfd85a0018baada6945ea75aacb0 +README.zh.md: e14e13731c314efd4d39913b91f2e90ba624e55c diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index c6c3f61c29..7828f55a2e 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -42,14 +42,17 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. -The Claude Code subagent provider is an optional Bundle. Add or remove it independently: +The Codex and Claude Code subagent providers are separate optional Bundles. Add either package, both in one command, or remove either package independently: ```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating the Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` take effect through hot reload. On the next start, the installed package registers only its dormant Host provider and starts no Claude process. The Bundle installs the pinned Agent SDK and one matching private platform CLI, uses only that CLI, and never falls back to a host `claude`. Authentication and native Claude settings remain user-managed; the [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) discloses the current platform payload size and missing-payload failure. Full Agent Presets keep both product tool rows disabled, so a copied Preset must separately enable the Claude Code row before a new Agent can see that tool. The Codex provider remains an explicitly mounted Host plugin that resolves `codex` from `PATH`; the default dsh dependency closure includes neither provider nor the Claude Agent SDK or its platform payloads. +The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` take effect through hot reload. On the next start, each installed Bundle registers only its dormant Host provider; a copied Preset must separately enable the matching tool row for new Agents. The [Codex provider README](../../../packages/subagent/subagent-codex/README.md) and [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) own executable, authentication, payload, and failure details; the [base Bundle reference](../../../packages/bundle/base/README.md) owns the default dependency closure. ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d6f3e7f1f5..e14e13731c 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -42,14 +42,17 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。 -Claude Code subagent provider 是一个可选 Bundle,可以独立添加或移除: +Codex 与 Claude Code subagent provider 是两个彼此独立的可选 Bundle。可以只添加一个包、在同一命令中添加两个包,或独立移除任一包: ```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` -pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑通过热重载生效。下一次启动时,已安装的包只注册休眠的 Host provider,不会启动 Claude 进程。该 Bundle 会安装锁定的 Agent SDK 与一个匹配的私有平台 CLI,只使用该 CLI,并且绝不会回退到宿主 `claude`。Claude 的身份验证与原生设置仍由用户管理;[Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)会披露当前平台载荷体积与载荷缺失时的失败行为。完整 Agent Preset 中的两个产品工具行仍默认禁用,因此还须在复制出的 Preset 中单独启用 Claude Code 行,新 Agent 才能看到该工具。Codex provider 仍须作为 Host 插件显式挂载,并从 `PATH` 解析 `codex`;默认 dsh 依赖闭包不包含任一 provider,也不包含 Claude Agent SDK 或其平台载荷。 +pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑通过热重载生效。下一次启动时,每个已安装 Bundle 只注册自己的休眠 Host provider;还须在复制出的 Preset 中单独启用对应工具行,新 Agent 才能看到该工具。[Codex provider README](../../../packages/subagent/subagent-codex/README.md)与 [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)负责可执行文件、身份验证、载荷与失败细节;[base Bundle 参考](../../../packages/bundle/base/README.md)负责默认依赖闭包。 ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index b4e875c07d..5e9680fd13 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -26,6 +26,7 @@ const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ 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') +const CODEX_PATCH = join(REPO_ROOT, 'packages/subagent/subagent-codex/cordis.patch.yml') const CLAUDE_CODE_PATCH = join(REPO_ROOT, 'packages/subagent/subagent-claude-code/cordis.patch.yml') /** The installation anchor whose dependency surface the preset module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') @@ -443,11 +444,12 @@ describe('the shipped Web composition', () => { }) }) -describe('Claude Code Bundle and user-preset intersection', () => { - const presetIds = ['products-none', 'products-claude'] as const +describe('product Bundle and user-preset intersection', () => { + const presetIds = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const + type Product = 'codex' | 'claude-code' type PresetId = typeof presetIds[number] - async function bootProducts(installed: boolean): Promise { + async function bootProducts(installed: readonly Product[]): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) const userRoot = join(root, 'presets') const settingsFile = join(root, 'settings.yaml') @@ -455,14 +457,22 @@ describe('Claude Code Bundle and user-preset intersection', () => { await writeFile(settingsFile, '{}\n') for (const id of presetIds) { let composition = standard - if (id === 'products-claude') { + 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) } - const productPatches = installed ? loadOverlayPatches('dsh-test', CLAUDE_CODE_PATCH) : [] + const patchPath = (product: Product): string => ( + product === 'codex' ? CODEX_PATCH : CLAUDE_CODE_PATCH + ) + const productPatches = installed.flatMap(product => ( + loadOverlayPatches('dsh-test', patchPath(product)) + )) return await bootWeb(settingsFile, [ ...productPatches, { @@ -476,13 +486,21 @@ describe('Claude Code Bundle and user-preset intersection', () => { includeUserRoot: false, }, }, - ], installed ? [dirname(CLAUDE_CODE_PATCH)] : []) + ], installed.map(product => dirname(patchPath(product)))) } - it('composes the intersection of the installed Bundle and enabled preset row', async () => { - const scenarios: Array<{ installed: boolean; presets: readonly PresetId[] }> = [ - { installed: false, presets: presetIds }, - { installed: true, presets: presetIds }, + it('composes the intersection of installed Bundles and enabled preset rows', async () => { + const enabledByPreset: Record = { + 'products-none': [], + 'products-codex': ['codex'], + 'products-claude': ['claude-code'], + 'products-both': ['codex', 'claude-code'], + } + const scenarios: Array<{ installed: Product[]; presets: readonly PresetId[] }> = [ + { installed: [], presets: ['products-both'] }, + { installed: ['codex'], presets: ['products-both'] }, + { installed: ['claude-code'], presets: ['products-both'] }, + { installed: ['codex', 'claude-code'], presets: presetIds }, ] for (const { installed, presets } of scenarios) { @@ -492,16 +510,17 @@ describe('Claude Code Bundle and user-preset intersection', () => { expect(productCtx.subagents.list() .filter(name => name === 'codex' || name === 'claude-code') .sort()) - .toEqual(installed ? ['claude-code'] : []) + .toEqual([...installed].sort()) for (const id of presets) { const handle = await productCtx.agents.create({ - sessionId: SessionId(`preset-${id}-${installed ? 'claude' : 'none'}-${randomUUID()}`), + sessionId: SessionId(`preset-${id}-${installed.join('-') || 'none'}-${randomUUID()}`), setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), }) try { - const productTools = installed && id === 'products-claude' - ? ['subagent_claude_code'] - : [] + const productTools = enabledByPreset[id] + .filter(product => installed.includes(product)) + .map(product => product === 'codex' ? 'subagent_codex' : 'subagent_claude_code') + .sort() const tools = toolNames(productCtx, handle.agent) expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) .toEqual(productTools) @@ -524,7 +543,7 @@ describe('Claude Code Bundle and user-preset intersection', () => { }, 120_000) it('applies a product-row edit only to later sessions on the preset', async () => { - const productCtx = await bootProducts(true) + const productCtx = await bootProducts(['codex']) const preset = await productCtx.agentPresets.resolve('products-none') const original = await readFile(preset.path, 'utf8') const existing = await productCtx.agents.create({ @@ -532,16 +551,16 @@ describe('Claude Code Bundle and user-preset intersection', () => { setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), }) try { - expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_claude_code') - await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-claude-code')) + 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_claude_code') - expect(toolNames(productCtx, later.agent)).toContain('subagent_claude_code') + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') + expect(toolNames(productCtx, later.agent)).toContain('subagent_codex') } finally { await later.dispose() } diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 3e18ff9548..ca19ce4204 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -63,7 +63,7 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { const output = call.locator('pre') await output.waitFor() expect(await output.textContent()).toContain('') - expect(await output.textContent()).toContain('The Claude Code Bundle installs and exclusively uses the matching platform CLI') + expect(await output.textContent()).toContain('Codex and Claude Code providers are independent optional Profile Bundles') expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px') const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index f4acc2a978..6f8dc183d7 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -25,7 +25,7 @@ - button "Skill editing-cordis-compositions" [expanded]: - img - text: Skill editing-cordis-compositions -- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents The Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` The Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start. Codex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool. 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 backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` The two rows are independent. Leaving both disabled preserves the copied preset; enabling one exposes only that available product tool. The Claude Code row requires its Bundle, while the Codex row requires an explicit Host composition and a host `codex` on `PATH`. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base Host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Neither installing the Claude Code Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " +- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents Codex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` Each Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start. 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 backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` The two rows are independent. Leaving both disabled preserves the copied preset; enabling one exposes only that installed product tool. The Codex Bundle exclusively uses the wrapper and native platform payload selected by its pinned official package, while the Claude Code Bundle exclusively uses the platform CLI selected by its pinned Agent SDK. Neither provider inspects or falls back to a host product command, and a missing optional payload fails the first delegation. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base Host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Neither installing a product Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " - button "Inspect" - button "Think The skill is loaded.": - img diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 353df8d744..2e9e666180 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: 24ee9a4815b4236336ae37f6c926dba4718dafb9 -module-graph.zh.md: d87feb946a43a2f9d391e7894b4a143af4e18406 +module-graph.md: 46f924b0df80aa667d4929e6d2707c8cf051bfb2 +module-graph.zh.md: 3a226bbdacfd1a5b850a406bea66bf29a5db209b diff --git a/docs/module-graph.md b/docs/module-graph.md index 24ee9a4815..46f924b0df 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -961,6 +961,12 @@ flowchart TD pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout pkg_subagent_in_process_driver --> pkg_agent pkg_subagent_in_process_driver --> pkg_invariants pkg_subagent_in_process_driver --> pkg_llm @@ -1068,13 +1074,6 @@ flowchart TD pkg_workflow_worker_thread --> pkg_subagent pkg_workflow_worker_thread --> pkg_tools pkg_workflow_worker_thread --> pkg_workflow - pkg_subagent_codex --> pkg_invariants - pkg_subagent_codex --> pkg_llm - pkg_subagent_codex --> pkg_sdk_protocol - pkg_subagent_codex --> pkg_session - pkg_subagent_codex --> pkg_subagent - pkg_subagent_codex --> pkg_subprocess - pkg_subagent_codex --> pkg_timeout pkg_subagent_fork_in_process --> pkg_agent pkg_subagent_fork_in_process --> pkg_invariants pkg_subagent_fork_in_process --> pkg_session @@ -1577,6 +1576,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | @@ -1592,7 +1592,6 @@ flowchart TD | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index d87feb946a..3a226bbdac 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -963,6 +963,12 @@ flowchart TD pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout pkg_subagent_in_process_driver --> pkg_agent pkg_subagent_in_process_driver --> pkg_invariants pkg_subagent_in_process_driver --> pkg_llm @@ -1070,13 +1076,6 @@ flowchart TD pkg_workflow_worker_thread --> pkg_subagent pkg_workflow_worker_thread --> pkg_tools pkg_workflow_worker_thread --> pkg_workflow - pkg_subagent_codex --> pkg_invariants - pkg_subagent_codex --> pkg_llm - pkg_subagent_codex --> pkg_sdk_protocol - pkg_subagent_codex --> pkg_session - pkg_subagent_codex --> pkg_subagent - pkg_subagent_codex --> pkg_subprocess - pkg_subagent_codex --> pkg_timeout pkg_subagent_fork_in_process --> pkg_agent pkg_subagent_fork_in_process --> pkg_invariants pkg_subagent_fork_in_process --> pkg_session @@ -1579,6 +1578,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | @@ -1594,7 +1594,6 @@ flowchart TD | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index 6afe2b888d..c70374f83d 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -1,5 +1,5 @@ -# Test-only composition of the public opt-in provider and one-shot task tool. -# The owning e2e boots this tree but never invokes the model or Codex. +# Test-only composition of the Codex one-shot tool around its Bundle-supplied provider. +# The owning e2e applies the package's real patch and never invokes the model or Codex. - id: fixture name: './fixture.ts' @@ -9,9 +9,6 @@ - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' -- id: subagent-codex - name: '@deepseek-ai/dsh-subagent-codex' - - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts index 51cd5eaa6f..25040faea5 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts @@ -1,20 +1,21 @@ #!/usr/bin/env node /** Inspect the public Codex provider composition without invoking the product. */ -import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { boot, loadOverlayPatches, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-tools' const configPath = process.argv[2] -if (configPath === undefined) { - throw new Error('subagent-codex Loader composition driver requires a config path') +const bundlePatchPath = process.argv[3] +if (configPath === undefined || bundlePatchPath === undefined) { + throw new Error('subagent-codex Loader composition driver requires config and Bundle patch paths') } let starts = 0 const ctx = await boot( 'subagent-codex-loader-composition', resolveConfigPath(configPath, undefined), - undefined, + loadOverlayPatches('subagent-codex-loader-composition', bundlePatchPath), (hostCtx) => { hostCtx.on('subagent/start', () => { starts += 1 diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 89aea22173..5d03979af6 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -19,7 +19,7 @@ {"type":"assistant/chunk","seq":17,"time":1785730426828,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":18,"time":1785730426828,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fd7a47e-84c9-4d31-aa95-9939671ba0a5"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[10,11,12,13,14,15,16,17],"surfaceOp":"append"} {"type":"tool/call","seq":19,"time":1785730426828,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} -{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nThe Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nThe Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start.\n\nCodex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nThe two rows are independent. Leaving both disabled preserves the copied preset; enabling one exposes only that available product tool. The Claude Code row requires its Bundle, while the Codex row requires an explicit Host composition and a host `codex` on `PATH`. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base Host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Neither installing the Claude Code Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"e710fcbb-f128-463c-8db2-f90cdc0ad9b8"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nCodex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nEach Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nThe two rows are independent. Leaving both disabled preserves the copied preset; enabling one exposes only that installed product tool. The Codex Bundle exclusively uses the wrapper and native platform payload selected by its pinned official package, while the Claude Code Bundle exclusively uses the platform CLI selected by its pinned Agent SDK. Neither provider inspects or falls back to a host product command, and a missing optional payload fails the first delegation. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base Host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Neither installing a product Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"cfdab17c-645d-4114-956f-1d91776289f5"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","seq":21,"time":1785730426838,"data":{"turn":1,"step":1}} {"type":"step/start","seq":22,"time":1785730426848,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index b42a5e5d52..0441ec7d87 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/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/README.md -README.md: 3afa53c1444b43c38b7a71f3e9e00d271078be54 -README.zh.md: 740b3579ce1555f2b1b26ca79e8a3d915a338193 +README.md: 4d7a064939ae04f25737b324ec35332b7b944f80 +README.zh.md: 8910b33a97acd2ef3ee5b659305739246004de01 diff --git a/packages/bundle/README.md b/packages/bundle/README.md index 3afa53c144..4d7a064939 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../boot/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts. -The manifest declaration, not this directory, defines Bundle identity. Domain packages can carry their own optional Profile layer; the [Claude Code subagent package](../subagent/subagent-claude-code/README.md) is a directly installable example. +The manifest declaration, not this directory, defines Bundle identity. Domain packages can carry their own optional Profile layer; the [Codex and Claude Code subagent packages](../subagent/README.md) are directly installable examples. | Package | Role | ctx key | |---|---|---| diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index 740b3579ce..8910b33a97 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -4,7 +4,7 @@ Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 约定](../boot/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。 -Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包可以携带自己的可选 Profile 层;[Claude Code subagent 包](../subagent/subagent-claude-code/README.md)就是可直接安装的例子。 +Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包可以携带自己的可选 Profile 层;[Codex 与 Claude Code subagent 包](../subagent/README.md)就是可直接安装的例子。 | 包 | 职责 | ctx key | |---|---|---| diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index ea1fb9b03a..05ac3dfe47 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: 5fdd642ecc03fea77b2b00fc6428525c1a40d891 -README.zh.md: c2a07ec15816e41eadf682bcd8631c93bce77ae0 +README.md: 8487426ee7bf1b39a79b4e80b9c7bd661f317998 +README.zh.md: 797968d362cd35d1ba04087b30d8d02d1b537b44 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 5fdd642ecc..8487426ee7 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, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile can install the [Claude Code provider Bundle](../../subagent/subagent-claude-code/README.md) only when needed, while a deployment that uses Codex still mounts that provider explicitly. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider nor the Claude Agent SDK. 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, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile installs either [product provider Bundle](../../subagent/README.md) only when needed. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider, the Claude Agent SDK, nor the Codex wrapper and platform payloads. 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 patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index c2a07ec158..797968d362 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、遥测与核心 spawn/fork subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 可以仅在需要时安装 [Claude Code provider Bundle](../../subagent/subagent-claude-code/README.md),使用 Codex 的部署仍须显式挂载该 provider。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider,也不包含 Claude Agent SDK。后续的组合包层(例如 [`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、遥测与核心 spawn/fork subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 仅在需要时安装任一[产品 provider Bundle](../../subagent/README.md)。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider、Claude Agent SDK,也不包含 Codex wrapper 及其平台载荷。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不受沙盒约束的本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时直接报错)。POSIX 主机看到的是被禁用的 pwsh 行。 diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 24fefb0441..35671f6659 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/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/README.md -README.md: aebf368b984dca3ac2e37d1bcdba26a82ce85196 -README.zh.md: fb9e223f916a8b07d3a4ae254fa61087de081a93 +README.md: c881a749e1c43b3044029ebf4b53ed650566a875 +README.zh.md: 92afbc88f893119ac230f7fd98b1c3c5d6e11f47 diff --git a/packages/subagent/README.md b/packages/subagent/README.md index aebf368b98..c881a749e1 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -18,7 +18,7 @@ This family lets an agent delegate work to child agents. Multiple named provider | [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | Provides the child-to-parent report channel | registers in child scopes | -The Claude Code package is also an optional Profile Bundle. Install it with `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code`, then restart that Profile; the package registers only its dormant Host provider, while a copied Agent Preset separately grants the disabled tool template to new Sessions. Removing the package withdraws that provider on the next Profile start. The Codex package remains an explicitly mounted Host plugin and uses a host `codex` from `PATH`. +The Codex and Claude Code packages are independent optional Profile Bundles. Install either or both with `dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code`, then restart that Profile; each package registers only its dormant Host provider, while a copied Agent Preset separately grants the disabled tool template to new Sessions. Removing one package withdraws only that provider and its private runtime closure on the next Profile start. See the decisions for the [capability family](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable children](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [control tools](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index fb9e223f91..92afbc88f8 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -18,7 +18,7 @@ | [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 | -Claude Code 包也是一个可选的 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code` 安装后重启该 Profile;该包只注册休眠的 Host provider,而复制出的 Agent Preset 会单独把默认禁用的工具模板授予新 Session。移除该包后,下一次 Profile 启动会撤回对应 provider。Codex 包仍须作为 Host 插件显式挂载,并使用 `PATH` 中的宿主 `codex`。 +Codex 与 Claude Code 包是彼此独立的可选 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code` 安装其中一个或两个包,再重启该 Profile;每个包只注册自己的休眠 Host provider,而复制出的 Agent Preset 会单独把默认禁用的工具模板授予新 Session。移除其中一个包后,下一次 Profile 启动只会撤回对应 provider 及其私有运行时闭包。 参见有关[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)的决策。 diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index da14b8ff30..50bfe3dd92 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: 848d170585710b682fa4ce331010fce7080de673 -README.zh.md: 34e9105e6a78bc16f16997c7df89d4f6412eb50c +README.md: 7e8c6c00d3f777ab6478087cfea189658ce83bd5 +README.zh.md: 9a87d3505dc94bf807f27809c4fb53c5644c5052 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 848d170585..7e8c6c00d3 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, 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 `codex` subagent provider. Each accepted run starts the official package-local Codex wrapper with `app-server --stdio` in the delegating Session's workspace, creates one ephemeral Codex thread, 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 @@ -25,27 +25,31 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit child environment layered over the subprocess seam's 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 `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. +Production resolves the `codex` bin declared by its pinned `@openai/codex@0.147.0` dependency and launches that JavaScript wrapper with the current Node executable. The wrapper selects the matching native platform payload; the provider neither inspects nor falls back to a host `codex` on `PATH`. Native Codex configuration and authentication remain authoritative through the parent cwd, `HOME`, and `CODEX_HOME`. The plugin does not select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed by the subprocess seam before the explicit `env` overlay is applied. -Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. +This package is an optional Profile Bundle. Install it into the target Profile, then restart that Profile; installation brings the official wrapper and one compatible native platform payload into that Profile, while the declared `cordis.patch.yml` layer registers only the dormant `codex` Host provider and starts no Codex process. Removing the package withdraws that provider and its private runtime closure on the next Profile start. -The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services. +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex +dsh --profile +``` + +Installation controls Host availability, not model permission. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to new agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base Host and full presets already provide the generic Job registry and controls. The Profile's own patch can replace the Bundle row's complete `config`, while a custom Host composition can still mount the package directly. ```yaml +# $DSH_HOME/profiles//cordis.patch.yml (optional provider override) - id: subagent-codex - name: '@deepseek-ai/dsh-subagent-codex' config: env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY +``` -- id: jobs - name: '@deepseek-ai/dsh-jobs-local' - -- id: tool-jobs - name: '@deepseek-ai/dsh-tool-jobs' - +```yaml +# A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex @@ -55,7 +59,9 @@ The standalone composition below shows the complete explicit capability. A Profi ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. The runtime dependency and all six optional-dependency aliases are pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`. A normal install selects one payload for the current OS and CPU. For the current darwin-arm64 payload, `npm pack --dry-run --json @openai/codex@0.147.0-darwin-arm64` reports 111,199,052 packed bytes and 274,777,843 unpacked bytes. That package contains native `codex`, `codex-code-mode-host`, `rg`, and `zsh` resources; other platforms may differ, and these values are disclosure rather than an installation threshold. The keyless real-product test drives the package wrapper against a loopback Responses fixture, observes the package-local argv, and proves wrapper and native descendants become quiescent. + +Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload makes the first delegation fail with the wrapper's native-payload startup error. The provider neither probes a host CLI nor retries with one. ## Model Experience @@ -63,7 +69,7 @@ The production wire intentionally implements only the app-server methods require #### What the model sees -The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration. +The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd; its model, system instructions, tools, sandbox, and authentication come from native Codex configuration, while the executable version comes from the Bundle's pinned platform payload. #### Token effect @@ -90,7 +96,8 @@ Append-only: foreground adds one result after the reusable parent prefix, while ## Known Limitations and Deferred Work - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. -- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. +- **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, trust a project, or rewrite Codex settings; configuration and authentication failures surface as startup or run errors. +- **The native platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first run; there is no host-CLI fallback. - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. - **Product payload is final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 34e9105e6a..9a87d3505d 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中使用 `app-server --stdio` 启动官方包内 Codex wrapper,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 ## 启动与所有权 @@ -25,27 +25,31 @@ | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 +生产环境会解析锁定的 `@openai/codex@0.147.0` 依赖所声明的 `codex` bin,并使用当前 Node 可执行文件启动该 JavaScript wrapper。Wrapper 会选择匹配的原生平台载荷;提供方既不检查也不回退 `PATH` 中的宿主 `codex`。父会话 cwd、`HOME` 与 `CODEX_HOME` 继续让原生 Codex 配置和身份验证保持权威。本插件不选择模型、不创建产品主目录、不执行登录,也不探测账户。子进程 seam 会先移除具有凭证特征的环境变量,再应用显式 `env` 覆盖。 -生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +本包是可选的 Profile Bundle。将它安装进目标 Profile 后重启该 Profile;安装会把官方 wrapper 与一个兼容的原生平台载荷带入该 Profile,而包所声明的 `cordis.patch.yml` 层只注册休眠的 `codex` Host provider,不会启动 Codex 进程。移除该包后,下一次 Profile 启动会撤回这一 provider 及其私有运行时闭包。 -下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。 +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-codex +dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex +dsh --profile +``` + +安装决定 Host 可用性,而不是模型权限。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的新 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base Host 与完整 preset 已提供通用 Job 注册表和控制工具。Profile 自己的 patch 可以替换 Bundle 行的完整 `config`,而自定义 Host 组合仍可直接挂载本包。 ```yaml +# $DSH_HOME/profiles//cordis.patch.yml (optional provider override) - id: subagent-codex - name: '@deepseek-ai/dsh-subagent-codex' config: env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY +``` -- id: jobs - name: '@deepseek-ai/dsh-jobs-local' - -- id: tool-jobs - name: '@deepseek-ai/dsh-tool-jobs' - +```yaml +# A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex @@ -55,7 +59,9 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。运行时依赖与六个 optional-dependency alias 均锁定到 `@openai/codex@0.147.0` / `codex-cli 0.147.0`。普通安装会按当前操作系统与 CPU 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json @openai/codex@0.147.0-darwin-arm64` 报告压缩包为 111,199,052 字节、解包后为 274,777,843 字节。该包包含原生 `codex`、`codex-code-mode-host`、`rg` 与 `zsh` 资源;其他平台可能不同,这些数值只用于披露而不是安装阈值。无密钥真实产品测试会驱动包内 wrapper 连接回环 Responses fixture,观测包内 argv,并证明 wrapper 与原生后代进程完全停稳。 + +如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,第一次委派会以 wrapper 的原生载荷启动错误失败。提供方既不会探测宿主 CLI,也不会用它重试。 ## 模型体验 @@ -63,7 +69,7 @@ #### 模型看到的内容 -Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。 +Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 配置,可执行版本则来自 Bundle 锁定的平台载荷。 #### 对 token 的影响 @@ -90,7 +96,8 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 ## 已知限制与后续工作 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 -- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 +- **身份验证与账户状态仍由原生机制管理**:Bundle 会提供 CLI,但不会创建账户、登录、信任项目或改写 Codex 设置;配置与身份验证失败会呈现为启动错误或运行错误。 +- **委派时必须存在原生平台载荷**:省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次运行时失败;不会回退到宿主 CLI。 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 - **产品载荷仅包含最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部;通用 Job id、通知与状态来自共享作业运行时。 diff --git a/packages/subagent/subagent-codex/cordis.patch.yml b/packages/subagent/subagent-codex/cordis.patch.yml new file mode 100644 index 0000000000..fb64fdcb9d --- /dev/null +++ b/packages/subagent/subagent-codex/cordis.patch.yml @@ -0,0 +1,5 @@ +# Optional Profile Bundle: register the Codex provider on the Host plane only. +# Agent Presets grant the model-facing tool independently. +- insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 0256ee8e21..1d948eb631 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -28,13 +28,18 @@ "files": [ "lib/index.js", "lib/invariant.js", + "cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", @@ -42,7 +47,9 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "@openai/codex": "0.147.0" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -56,7 +63,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@openai/codex": "0.147.0", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 3b1bbec799..ec9ff34945 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -1,7 +1,7 @@ /** * Fixed Codex one-shot subagent provider. Every accepted run starts a fresh - * official `codex app-server --stdio` process in the delegating Session's - * workspace and publishes only after an ephemeral thread exists. + * official package-local Codex wrapper with `app-server --stdio` in the + * delegating Session's workspace and publishes only after an ephemeral thread exists. * * @module @deepseek-ai/dsh-subagent-codex */ diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index ebce244f3b..d497a4f16c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -8,6 +8,9 @@ */ import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -24,21 +27,46 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +interface CodexPackageManifest { + readonly bin?: string | Readonly> +} + /** - * Resolve the fixed app-server command for a platform. - * - * Windows npm and pnpm installs expose `codex.cmd`, which requires `cmd.exe`; - * the argv is constant so no task or configuration text enters the - * shell boundary. - * @param platform - host platform used to select the executable boundary. - * @returns argv for the fixed Codex app-server command. + * Resolve the official package's declared `codex` bin relative to its manifest. + * @param packageJsonPath - absolute path to the official package manifest. + * @param manifest - parsed manifest carrying the declared bin entry. + * @returns absolute path to the package-local JavaScript wrapper. */ -export function codexAppServerArgv( - platform: NodeJS.Platform = process.platform, -): string[] { - return platform === 'win32' - ? ['cmd.exe', '/d', '/s', '/c', 'codex', 'app-server', '--stdio'] - : ['codex', 'app-server', '--stdio'] +export function codexPackageBinPath( + packageJsonPath: string, + manifest: CodexPackageManifest, +): string { + const declared = typeof manifest.bin === 'string' + ? manifest.bin + : manifest.bin?.codex + if (declared === undefined || declared.length === 0) { + throw new Error('@openai/codex does not declare its codex bin') + } + return resolve(dirname(packageJsonPath), declared) +} + +const codexPackageJsonPath = createRequire(import.meta.url).resolve('@openai/codex/package.json') +const codexPackageManifest = JSON.parse( + readFileSync(codexPackageJsonPath, 'utf8'), +) as CodexPackageManifest + +/** Absolute package-local JavaScript wrapper selected by the package manifest. */ +export const CODEX_PACKAGE_BIN = codexPackageBinPath( + codexPackageJsonPath, + codexPackageManifest, +) + +/** + * Fixed package-local app-server command, independent of the host `PATH`. + * @returns Node, the official wrapper, and the fixed app-server arguments. + */ +export function codexAppServerArgv(): string[] { + return [process.execPath, CODEX_PACKAGE_BIN, 'app-server', '--stdio'] } /** Fully resolved inputs for one Codex app-server run. */ diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index 8e265c3207..b72144e299 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -12,6 +13,13 @@ const fixtureDir = fileURLToPath(new URL( )) const driver = join(fixtureDir, 'driver.ts') const configPath = join(fixtureDir, 'cordis.yml') +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + dsh?: { bundle?: { patch?: string } } +} +const bundlePatch = manifest.dsh?.bundle?.patch +if (bundlePatch === undefined) throw new Error('Codex package must declare a Bundle patch') +const bundlePatchPath = join(packageDir, bundlePatch) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('Codex provider public Loader composition', () => { @@ -22,6 +30,7 @@ describe('Codex provider public Loader composition', () => { binScript: driver, libBinScript: driver, configPath, + binArgs: [configPath, bundlePatchPath], tsconfigPath: repoTsconfig, env: { // Loading the optional package must not probe or start a Codex binary. diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index 075b6048f2..2e876134a3 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -8,7 +8,7 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, join, resolve } from 'node:path' +import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' @@ -18,6 +18,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' +import { CODEX_PACKAGE_BIN } from '../src/run.ts' import { startDeepSeekResponsesBridge, type DeepSeekResponsesBridge, @@ -25,7 +26,6 @@ import { const execFileAsync = promisify(execFile) const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) -const codexBinDir = join(packageRoot, 'node_modules', '.bin') const codexPackage = JSON.parse(readFileSync( join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), 'utf8', @@ -88,7 +88,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( CODEX_HOME: codexHome, HOME: root, XDG_CONFIG_HOME: join(root, 'xdg-config'), - PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + PATH: root, HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', @@ -106,7 +106,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( return handle }) await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) - const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + const version = await execFileAsync(process.execPath, [CODEX_PACKAGE_BIN, '--version'], { env: { ...process.env, ...env }, }) expect(codexPackage.version).toBe('0.147.0') diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 551d6db765..8b1181a743 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process' import { + cpSync, existsSync, mkdirSync, mkdtempSync, @@ -8,16 +9,17 @@ import { } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' +import { CODEX_PACKAGE_BIN } from '../src/run.ts' import { startResponsesFixture, type ResponsesBehavior, @@ -27,7 +29,8 @@ import { const execFileAsync = promisify(execFile) const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) const codexBinDir = join(packageRoot, 'node_modules', '.bin') -const codexEntry = join(packageRoot, 'node_modules', '@openai', 'codex', 'bin', 'codex.js') +const codexEntry = CODEX_PACKAGE_BIN +const codexPackageRoot = dirname(dirname(codexEntry)) const codexPackage = JSON.parse(readFileSync( join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), 'utf8', @@ -48,6 +51,7 @@ afterEach(async () => { interface RealHarness { readonly ctx: Context readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] readonly parent: Agent readonly env: Record readonly workspace: string @@ -89,7 +93,7 @@ async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ CODEX_HOME: codexHome, HOME: root, XDG_CONFIG_HOME: join(root, 'xdg'), - PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + PATH: root, HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', @@ -100,8 +104,10 @@ async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) 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 @@ -111,7 +117,7 @@ async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ id: 'real-parent', session: { header: { cwd: workspace } }, } as unknown as Agent - return { harness: { ctx, handles, parent, env, workspace }, fixture } + return { harness: { ctx, handles, spawnSpecs, parent, env, workspace }, fixture } } async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { @@ -164,6 +170,13 @@ describe('real @openai/codex 0.147.0 product', () => { }) await run.dispose() + expect(harness.spawnSpecs[0]?.argv).toEqual([ + process.execPath, + codexEntry, + 'app-server', + '--stdio', + ]) + expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! expect(recorded.method).toBe('POST') @@ -173,6 +186,24 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) + it('fails a missing platform payload without falling back to a host codex', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-missing-payload-')) + roots.push(root) + const isolatedPackage = join(root, 'node_modules', '@openai', 'codex') + mkdirSync(dirname(isolatedPackage), { recursive: true }) + cpSync(codexPackageRoot, isolatedPackage, { recursive: true, dereference: true }) + const isolatedEntry = join(isolatedPackage, 'bin', 'codex.js') + + await expect(execFileAsync(process.execPath, [isolatedEntry, '--version'], { + env: { + PATH: codexBinDir, + ...process.platform === 'win32' && process.env.SystemRoot !== undefined + ? { SystemRoot: process.env.SystemRoot } + : {}, + }, + })).rejects.toThrow('Missing optional dependency') + }, 30_000) + it('cancels a real app-server command approval without executing the command', async () => { const command = process.platform === 'win32' ? 'cmd /c type nul > approval-side-effect' diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 37b2e9ff0b..a60ed2f759 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1,6 +1,10 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { PassThrough } from 'node:stream' +import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as yaml from 'js-yaml' import { describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' @@ -15,7 +19,9 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { + CODEX_PACKAGE_BIN, codexAppServerArgv, + codexPackageBinPath, DEFAULT_DISPOSE_GRACE_MS, disposeCodexChild, startCodexRun, @@ -26,6 +32,16 @@ import { CodexAppServerWire } from '../src/wire.ts' type JsonObject = Record +const CODEX_VERSION = '0.147.0' +const CODEX_PLATFORM_PACKAGES = [ + '@openai/codex-darwin-arm64', + '@openai/codex-darwin-x64', + '@openai/codex-linux-arm64', + '@openai/codex-linux-x64', + '@openai/codex-win32-arm64', + '@openai/codex-win32-x64', +] as const + const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } }, @@ -260,17 +276,76 @@ function turnCompleted( } describe('task admission and package contracts', () => { - it('resolves the fixed app-server command through the Windows npm shim boundary', () => { - expect(codexAppServerArgv('win32')).toEqual([ - 'cmd.exe', - '/d', - '/s', - '/c', - 'codex', + it('ships one independently installable provider-only Bundle patch', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + files?: string[] + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.files).toContain('cordis.patch.yml') + expect(manifest.dependencies).toHaveProperty( + '@deepseek-ai/dsh-sdk-protocol', + 'workspace:^', + ) + expect(manifest.dependencies).toHaveProperty('@openai/codex', CODEX_VERSION) + expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code') + + const codexPackageJson = fileURLToPath(import.meta.resolve('@openai/codex/package.json')) + const codexManifest = JSON.parse(readFileSync(codexPackageJson, 'utf8')) as { + version: string + bin: Record + optionalDependencies: Record + } + expect(codexManifest.version).toBe(CODEX_VERSION) + expect(codexManifest.bin).toEqual({ codex: 'bin/codex.js' }) + expect(codexManifest.optionalDependencies).toEqual(Object.fromEntries( + CODEX_PLATFORM_PACKAGES.map(packageName => [ + packageName, + `npm:@openai/codex@${CODEX_VERSION}-${packageName.slice('@openai/codex-'.length)}`, + ]), + )) + expect(CODEX_PACKAGE_BIN).toBe(codexPackageBinPath(codexPackageJson, codexManifest)) + + const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8') + for (const packageName of CODEX_PLATFORM_PACKAGES) { + const suffix = packageName.slice('@openai/codex-'.length) + expect(lockfile).toContain(` '@openai/codex@${CODEX_VERSION}-${suffix}':`) + expect(lockfile).toContain( + ` '${packageName}': '@openai/codex@${CODEX_VERSION}-${suffix}'`, + ) + } + + const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) + const rows = Array.isArray(parsed) + ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) + : [] + expect(rows).toEqual([{ + id: 'subagent-codex', + name: '@deepseek-ai/dsh-subagent-codex', + }]) + expect(JSON.stringify(rows)).not.toContain('tool-subagent') + }) + + it('uses only the official package-declared wrapper for app-server', () => { + expect(codexPackageBinPath( + '/package/node_modules/@openai/codex/package.json', + { bin: 'bin/codex.js' }, + )).toBe('/package/node_modules/@openai/codex/bin/codex.js') + expect(codexPackageBinPath( + '/package/node_modules/@openai/codex/package.json', + { bin: { codex: 'bin/codex.js' } }, + )).toBe('/package/node_modules/@openai/codex/bin/codex.js') + expect(() => codexPackageBinPath('/package/package.json', {})) + .toThrow('does not declare its codex bin') + expect(codexAppServerArgv()).toEqual([ + process.execPath, + CODEX_PACKAGE_BIN, 'app-server', '--stdio', ]) - expect(codexAppServerArgv('linux')).toEqual(['codex', 'app-server', '--stdio']) + expect(codexAppServerArgv()).not.toContain('codex') }) it('accepts one or more text blocks and rejects empty or non-text tasks', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b13703008..62c10d31ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7114,9 +7114,15 @@ importers: packages/subagent/subagent-codex: dependencies: + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/protocol '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + '@openai/codex': + specifier: 0.147.0 + version: 0.147.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7136,9 +7142,6 @@ importers: '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../test-support/loader-smoke - '@deepseek-ai/dsh-sdk-protocol': - specifier: workspace:^ - version: link:../../sdk/protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7154,9 +7157,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - '@openai/codex': - specifier: 0.147.0 - version: 0.147.0 packages/subagent/subagent-dsh-sdk: dependencies: diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 479a2f13b1..8438a5edb1 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -4,7 +4,9 @@ import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' import { CLAUDE_AGENT_SDK_PACKAGE, + CODEX_PACKAGE, claudeDistributionFromManifest, + codexDistributionFromManifest, collectPythonDependencies, isOwnerAuthorizedRuntime, isPermissive, @@ -331,6 +333,53 @@ describe('official Claude distribution authorization', () => { }) }) +describe('official Codex platform payloads', () => { + it('derives versioned packages from the wrapper aliases', () => { + expect(codexDistributionFromManifest({ + name: CODEX_PACKAGE, + version: '9.8.7', + optionalDependencies: { + '@openai/codex-linux-x64': 'npm:@openai/codex@9.8.7-linux-x64', + '@openai/codex-darwin-arm64': 'npm:@openai/codex@9.8.7-darwin-arm64', + }, + })).toEqual({ + wrapperVersion: '9.8.7', + payloads: [ + { alias: '@openai/codex-darwin-arm64', version: '9.8.7-darwin-arm64' }, + { alias: '@openai/codex-linux-x64', version: '9.8.7-linux-x64' }, + ], + }) + }) + + it('rejects a wrong identity, missing payloads, and non-official aliases', () => { + expect(() => codexDistributionFromManifest({ + name: '@openai/unrelated', + version: '1.0.0', + optionalDependencies: { + '@openai/codex-linux-x64': 'npm:@openai/codex@1.0.0-linux-x64', + }, + })).toThrow(`expected ${CODEX_PACKAGE} manifest`) + expect(() => codexDistributionFromManifest({ + name: CODEX_PACKAGE, + version: '1.0.0', + })).toThrow('declares no optional platform payloads') + expect(() => codexDistributionFromManifest({ + name: CODEX_PACKAGE, + version: '1.0.0', + optionalDependencies: { + '@openai/unrelated': 'npm:@openai/codex@1.0.0-linux-x64', + }, + })).toThrow('outside its platform alias namespace') + expect(() => codexDistributionFromManifest({ + name: CODEX_PACKAGE, + version: '1.0.0', + optionalDependencies: { + '@openai/codex-linux-x64': '1.0.0', + }, + })).toThrow('does not alias an official versioned payload') + }) +}) + describe('manifestPatterns', () => { it('derives globs from the declared members, so a new member area is read', () => { expect(manifestPatterns(['packages/*/*', 'tools/*', 'native/landlock-run', 'native/landlock-run/packages/*'])).toEqual([ diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index f2411f5eff..f76ae1d358 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -50,6 +50,9 @@ const FIRST_PARTY = new Set([ export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-` const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md' +export const CODEX_PACKAGE = '@openai/codex' +const CODEX_PLATFORM_ALIAS_PREFIX = `${CODEX_PACKAGE}-` +const CODEX_DECLARED_LICENSE = 'Apache-2.0' /** * Whether a non-permissive runtime declaration has an identity-scoped owner @@ -194,6 +197,18 @@ export interface ClaudeDistribution { readonly payloads: ClaudePlatformPayload[] } +/** One optional-dependency alias for an official Codex platform payload. */ +export interface CodexPlatformPayload { + readonly alias: string + readonly version: string +} + +/** Current Codex wrapper and platform payload facts from the official manifest. */ +export interface CodexDistribution { + readonly wrapperVersion: string + readonly payloads: CodexPlatformPayload[] +} + function requiredManifestString( value: string | undefined, field: string, @@ -243,6 +258,40 @@ export function claudeDistributionFromManifest( return { sdkVersion, claudeCodeVersion, payloads } } +/** Derive official Codex platform aliases and published package versions. */ +export function codexDistributionFromManifest( + manifest: VirtualManifest, +): CodexDistribution { + if (manifest.name !== CODEX_PACKAGE) { + throw new Error( + `gen-third-party-notices: expected ${CODEX_PACKAGE} manifest, got ${JSON.stringify(manifest.name)}.`, + ) + } + const wrapperVersion = manifest.version + if (wrapperVersion === undefined || wrapperVersion.length === 0) { + throw new Error(`gen-third-party-notices: ${CODEX_PACKAGE} has no version.`) + } + const entries = Object.entries(manifest.optionalDependencies ?? {}) + if (entries.length === 0) { + throw new Error(`gen-third-party-notices: ${CODEX_PACKAGE} declares no optional platform payloads.`) + } + const payloads = entries.map(([alias, spec]) => { + if (!alias.startsWith(CODEX_PLATFORM_ALIAS_PREFIX)) { + throw new Error( + `gen-third-party-notices: ${CODEX_PACKAGE} optional dependency ${alias} is outside its platform alias namespace.`, + ) + } + const prefix = `npm:${CODEX_PACKAGE}@` + if (!spec.startsWith(prefix) || spec.length === prefix.length) { + throw new Error( + `gen-third-party-notices: ${CODEX_PACKAGE} optional dependency ${alias} does not alias an official versioned payload.`, + ) + } + return { alias, version: spec.slice(prefix.length) } + }).sort((left, right) => left.alias.localeCompare(right.alias)) + return { wrapperVersion, payloads } +} + /** * Resolve one package's manifest inside a pnpm virtual store. The prefix scan * matches ordinary `@scope+name@version` directory names; pnpm 11 truncates @@ -333,6 +382,54 @@ function collectClaudeDistribution(): ClaudeDistribution { return distribution } +/** Resolve an installed versioned Codex payload manifest from either pnpm store. */ +function installedCodexPayload(version: string): VirtualManifest | undefined { + for (const store of ['node_modules', 'native/landlock-run/node_modules']) { + const candidate = resolve( + root, + store, + '.pnpm', + `${CODEX_PACKAGE.replace('/', '+')}@${version}`, + 'node_modules', + CODEX_PACKAGE, + 'package.json', + ) + if (existsSync(candidate)) { + return JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest + } + } + return undefined +} + +function collectCodexDistribution(): CodexDistribution { + const manifest = installedManifest(CODEX_PACKAGE) + if (manifest === undefined) { + throw new Error(`gen-third-party-notices: cannot resolve ${CODEX_PACKAGE}; run \`pnpm install\`.`) + } + const distribution = codexDistributionFromManifest(manifest) + let installedPayloads = 0 + for (const payload of distribution.payloads) { + const installed = installedCodexPayload(payload.version) + if (installed === undefined) continue + installedPayloads += 1 + if ( + installed.name !== CODEX_PACKAGE + || installed.version !== payload.version + || installed.license !== CODEX_DECLARED_LICENSE + ) { + throw new Error( + `gen-third-party-notices: installed ${payload.alias} does not match its official ${CODEX_PACKAGE}@${payload.version} payload and ${CODEX_DECLARED_LICENSE} license.`, + ) + } + } + if (installedPayloads === 0) { + throw new Error( + 'gen-third-party-notices: no Codex platform payload is installed; install optional dependencies before regenerating.', + ) + } + return distribution +} + /** Normalize a manifest repository/homepage value to a browsable https URL. */ function normalizeRepo(raw: string | undefined): string | undefined { if (raw === undefined || raw === '') return undefined @@ -656,6 +753,24 @@ ${rows.join('\n')} ` } +function renderCodexDistribution( + distribution: CodexDistribution | undefined, +): string { + if (distribution === undefined) return '' + const rows = distribution.payloads.map(payload => ( + `| \`${payload.alias}\` | [\`${CODEX_PACKAGE}\`](https://www.npmjs.com/package/${CODEX_PACKAGE}/v/${payload.version}) | ${payload.version} | ${CODEX_DECLARED_LICENSE} |` + )) + return ` +## Official Codex platform payloads + +The installed \`${CODEX_PACKAGE}\` wrapper ${distribution.wrapperVersion} declares the following optional-dependency aliases. Every alias resolves to an official platform-specific \`${CODEX_PACKAGE}\` version that carries the native Codex CLI and its bundled resources; the declared license is verified against the payload installed for the current host. + +| Optional dependency alias | Published package | Version | Declared license | +| --- | --- | --- | --- | +${rows.join('\n')} +` +} + /** * Render the complete notices document. * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold. @@ -673,6 +788,9 @@ export function render(): string { ) ? collectClaudeDistribution() : undefined + const codexDistribution = runtimeDeps.some(dep => dep.name === CODEX_PACKAGE) + ? collectCodexDistribution() + : undefined const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) // A copyleft license reaching a shipped surface is a distribution decision, @@ -693,7 +811,7 @@ export function render(): string { DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code and Codex platform payload closures. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock). @@ -715,6 +833,7 @@ pnpm applies local patches to the following packages at install time, so shipped ${patchedLines.join('\n')} ${renderClaudeDistribution(claudeDistribution)} +${renderCodexDistribution(codexDistribution)} ## Development-only npm dependencies diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts index 07f655823b..f63031e46c 100644 --- a/scripts/verify-cordis-config.spec.ts +++ b/scripts/verify-cordis-config.spec.ts @@ -4,10 +4,9 @@ * metadata field must stay static, and a disabled expression must parse. */ -import { globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { bundleManifestPaths, @@ -15,36 +14,6 @@ import { metadataExpressionErrors, } from './verify-cordis-config.ts' -interface WorkspaceManifest { - name?: string - dependencies?: Record - optionalDependencies?: Record - peerDependencies?: Record -} - -const repoRoot = fileURLToPath(new URL('..', import.meta.url)) - -function productionClosure(entry: string): Set { - const manifests = new Map() - for (const path of globSync(['apps/*/package.json', 'packages/*/*/package.json'], { cwd: repoRoot })) { - const manifest = JSON.parse(readFileSync(join(repoRoot, path), 'utf8')) as WorkspaceManifest - if (manifest.name !== undefined) manifests.set(manifest.name, manifest) - } - const visited = new Set() - const pending = [entry] - for (let name = pending.pop(); name !== undefined; name = pending.pop()) { - if (visited.has(name)) continue - visited.add(name) - const manifest = manifests.get(name) - pending.push( - ...Object.keys(manifest?.dependencies ?? {}), - ...Object.keys(manifest?.optionalDependencies ?? {}), - ...Object.keys(manifest?.peerDependencies ?? {}), - ) - } - return visited -} - describe('verify-cordis-config metadata expressions', () => { it('accepts a disabled !!js expression', () => { const problems = metadataExpressionErrors( @@ -116,15 +85,4 @@ describe('workspace Bundle discovery and product dependency closures', () => { `${file}: @deepseek-ai/dsh-missing-plugin must be declared in ${manifestPath} dependencies`, ]) }) - - it('keeps the default and optional Claude Code closure independent', () => { - const shipped = productionClosure('@deepseek-ai/dsh') - expect(shipped).not.toContain('@deepseek-ai/dsh-subagent-codex') - expect(shipped).not.toContain('@deepseek-ai/dsh-subagent-claude-code') - expect(shipped).not.toContain('@anthropic-ai/claude-agent-sdk') - - const claudeCode = productionClosure('@deepseek-ai/dsh-subagent-claude-code') - expect(claudeCode).toContain('@anthropic-ai/claude-agent-sdk') - expect(claudeCode).not.toContain('@deepseek-ai/dsh-subagent-codex') - }) }) From b6c52c82bbe47a63981e889d08c268d8851e52d3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 03:52:23 +0800 Subject: [PATCH 11/95] fix(infra): resolve Codex notices from wrapper --- scripts/gen-third-party-notices.spec.ts | 40 +++++++++ scripts/gen-third-party-notices.ts | 104 ++++++++++++++---------- 2 files changed, 101 insertions(+), 43 deletions(-) diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 8438a5edb1..bd78e6f0fc 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -7,6 +7,7 @@ import { CODEX_PACKAGE, claudeDistributionFromManifest, codexDistributionFromManifest, + codexDistributionFromInstalledPackage, collectPythonDependencies, isOwnerAuthorizedRuntime, isPermissive, @@ -378,6 +379,45 @@ describe('official Codex platform payloads', () => { }, })).toThrow('does not alias an official versioned payload') }) + + it('resolves installed payload aliases from the wrapper package', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-notices-codex-wrapper-')) + try { + const wrapperPath = join( + fixtureRoot, + 'node_modules/@openai/codex/package.json', + ) + const payloadPath = join( + fixtureRoot, + 'node_modules/@openai/codex-darwin-arm64/package.json', + ) + mkdirSync(resolve(wrapperPath, '..'), { recursive: true }) + mkdirSync(resolve(payloadPath, '..'), { recursive: true }) + writeFileSync(wrapperPath, JSON.stringify({ + name: CODEX_PACKAGE, + version: '9.8.7', + optionalDependencies: { + '@openai/codex-darwin-arm64': 'npm:@openai/codex@9.8.7-darwin-arm64', + '@openai/codex-linux-x64': 'npm:@openai/codex@9.8.7-linux-x64', + }, + })) + writeFileSync(payloadPath, JSON.stringify({ + name: CODEX_PACKAGE, + version: '9.8.7-darwin-arm64', + license: 'Apache-2.0', + })) + + expect(codexDistributionFromInstalledPackage(wrapperPath)).toEqual({ + wrapperVersion: '9.8.7', + payloads: [ + { alias: '@openai/codex-darwin-arm64', version: '9.8.7-darwin-arm64' }, + { alias: '@openai/codex-linux-x64', version: '9.8.7-linux-x64' }, + ], + }) + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } + }) }) describe('manifestPatterns', () => { diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index f76ae1d358..314c9b1b28 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -9,6 +9,7 @@ */ import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' @@ -292,6 +293,57 @@ export function codexDistributionFromManifest( return { wrapperVersion, payloads } } +function requireManifest( + requireFrom: NodeJS.Require, + name: string, +): VirtualManifest | undefined { + let packageJsonPath: string + try { + packageJsonPath = requireFrom.resolve(`${name}/package.json`) + } catch (error: unknown) { + if (error instanceof Error && 'code' in error && error.code === 'MODULE_NOT_FOUND') { + return undefined + } + throw error + } + return JSON.parse(readFileSync(packageJsonPath, 'utf8')) as VirtualManifest +} + +/** + * Derive and verify the Codex distribution from the wrapper package's own + * Node resolution context. + * @param packageJsonPath - absolute manifest path for the installed wrapper. + * @returns the wrapper version and all declared platform aliases. + */ +export function codexDistributionFromInstalledPackage( + packageJsonPath: string, +): CodexDistribution { + const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as VirtualManifest + const distribution = codexDistributionFromManifest(manifest) + const requireFromWrapper = createRequire(packageJsonPath) + let installedPayloads = 0 + for (const payload of distribution.payloads) { + const installed = requireManifest(requireFromWrapper, payload.alias) + if (installed === undefined) continue + installedPayloads += 1 + if ( + installed.name !== CODEX_PACKAGE + || installed.version !== payload.version + || installed.license !== CODEX_DECLARED_LICENSE + ) { + throw new Error( + `gen-third-party-notices: installed ${payload.alias} does not match its official ${CODEX_PACKAGE}@${payload.version} payload and ${CODEX_DECLARED_LICENSE} license.`, + ) + } + } + if (installedPayloads === 0) { + throw new Error( + 'gen-third-party-notices: no Codex platform payload is installed; install optional dependencies before regenerating.', + ) + } + return distribution +} + /** * Resolve one package's manifest inside a pnpm virtual store. The prefix scan * matches ordinary `@scope+name@version` directory names; pnpm 11 truncates @@ -382,52 +434,18 @@ function collectClaudeDistribution(): ClaudeDistribution { return distribution } -/** Resolve an installed versioned Codex payload manifest from either pnpm store. */ -function installedCodexPayload(version: string): VirtualManifest | undefined { - for (const store of ['node_modules', 'native/landlock-run/node_modules']) { - const candidate = resolve( - root, - store, - '.pnpm', - `${CODEX_PACKAGE.replace('/', '+')}@${version}`, - 'node_modules', - CODEX_PACKAGE, - 'package.json', - ) - if (existsSync(candidate)) { - return JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest - } - } - return undefined -} - function collectCodexDistribution(): CodexDistribution { - const manifest = installedManifest(CODEX_PACKAGE) - if (manifest === undefined) { + const requireFromProvider = createRequire(resolve( + root, + 'packages/subagent/subagent-codex/package.json', + )) + let packageJsonPath: string + try { + packageJsonPath = requireFromProvider.resolve(`${CODEX_PACKAGE}/package.json`) + } catch { throw new Error(`gen-third-party-notices: cannot resolve ${CODEX_PACKAGE}; run \`pnpm install\`.`) } - const distribution = codexDistributionFromManifest(manifest) - let installedPayloads = 0 - for (const payload of distribution.payloads) { - const installed = installedCodexPayload(payload.version) - if (installed === undefined) continue - installedPayloads += 1 - if ( - installed.name !== CODEX_PACKAGE - || installed.version !== payload.version - || installed.license !== CODEX_DECLARED_LICENSE - ) { - throw new Error( - `gen-third-party-notices: installed ${payload.alias} does not match its official ${CODEX_PACKAGE}@${payload.version} payload and ${CODEX_DECLARED_LICENSE} license.`, - ) - } - } - if (installedPayloads === 0) { - throw new Error( - 'gen-third-party-notices: no Codex platform payload is installed; install optional dependencies before regenerating.', - ) - } - return distribution + return codexDistributionFromInstalledPackage(packageJsonPath) } /** Normalize a manifest repository/homepage value to a browsable https URL. */ From ffa119e86e1ff17ea0e93a04ed59941c17d58feb Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 03:56:12 +0800 Subject: [PATCH 12/95] docs(subagent): clarify Claude tool opt-in --- packages/subagent/README.i18n.yaml | 4 ++-- packages/subagent/README.md | 2 +- packages/subagent/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 24fefb0441..ed6dd44e77 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/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/README.md -README.md: aebf368b984dca3ac2e37d1bcdba26a82ce85196 -README.zh.md: fb9e223f916a8b07d3a4ae254fa61087de081a93 +README.md: e6503c0f9d9861b41a39ad2790b7c8a171fd9bc0 +README.zh.md: 489577b58446d1ccd1ec7393f46d19f94609d73e diff --git a/packages/subagent/README.md b/packages/subagent/README.md index aebf368b98..e6503c0f9d 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -18,7 +18,7 @@ This family lets an agent delegate work to child agents. Multiple named provider | [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | Provides the child-to-parent report channel | registers in child scopes | -The Claude Code package is also an optional Profile Bundle. Install it with `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code`, then restart that Profile; the package registers only its dormant Host provider, while a copied Agent Preset separately grants the disabled tool template to new Sessions. Removing the package withdraws that provider on the next Profile start. The Codex package remains an explicitly mounted Host plugin and uses a host `codex` from `PATH`. +The Claude Code package is also an optional Profile Bundle. Install it with `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code`, then restart that Profile; the package registers only its dormant Host provider. To grant the tool, copy a complete Agent Preset, remove `disabled` from the matching tool row, and start a new Session. Removing the package withdraws that provider on the next Profile start. The Codex package remains an explicitly mounted Host plugin and uses a host `codex` from `PATH`. See the decisions for the [capability family](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable children](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [control tools](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index fb9e223f91..489577b584 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -18,7 +18,7 @@ | [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 | -Claude Code 包也是一个可选的 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code` 安装后重启该 Profile;该包只注册休眠的 Host provider,而复制出的 Agent Preset 会单独把默认禁用的工具模板授予新 Session。移除该包后,下一次 Profile 启动会撤回对应 provider。Codex 包仍须作为 Host 插件显式挂载,并使用 `PATH` 中的宿主 `codex`。 +Claude Code 包也是一个可选的 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code` 安装后重启该 Profile;该包只注册休眠的 Host provider。要授予工具,请复制一份完整 Agent Preset,删除对应工具行的 `disabled`,再启动新 Session。移除该包后,下一次 Profile 启动会撤回对应 provider。Codex 包仍须作为 Host 插件显式挂载,并使用 `PATH` 中的宿主 `codex`。 参见有关[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)的决策。 From 72cb49dbbb90907cc5a8862d65ad6e1dbcfcfd67 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 04:12:41 +0800 Subject: [PATCH 13/95] refactor(subagent): narrow Codex runtime ownership --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +-- ...oduct-subagent-providers-in-shared-host.md | 4 +-- ...ct-subagent-providers-in-shared-host.zh.md | 4 +-- packages/subagent/subagent-codex/src/run.ts | 27 +++------------- .../subagent-codex/tests/real-deepseek.e2e.ts | 14 ++++---- .../subagent-codex/tests/real-product.spec.ts | 11 ++++--- .../tests/subagent-codex.spec.ts | 32 ++++++------------- 7 files changed, 34 insertions(+), 62 deletions(-) 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 index 14fc409ebc..482e3aee8e 100644 --- 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 @@ -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-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: f1eac30e2984b6b9c1a0e83594a8f48dde690811 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: 14b7afcf414b9c058670d99531385da49d91fa4e +2026-08-10-product-subagent-providers-in-shared-host.md: 4d3af18f0985ccf3322eac955bf53c108104c5a0 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 9e29d8c0d05507261bfbca04b6bb5f3fa96b2c12 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 index f1eac30e29..4d3af18f09 100644 --- 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 @@ -16,11 +16,11 @@ Each product Bundle loads its fixed provider exactly once in the shared Host pla The [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) partially supersedes only this note's former default-inclusion choice: the base bundle excludes both providers, and each provider package owns its directly installable Bundle patch. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract 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 Bundles have different executable owners. The Codex package pins the official wrapper and six platform aliases; the provider runs the package-declared wrapper, which selects the private native payload. The Claude Code package pins its Agent SDK and eight platform packages; the provider lets that SDK select the private native executable. Neither provider consults or falls back to a host product command, while native configuration and authentication remain authoritative. Loading either Bundle only registers the provider and creates no product state, probes no version or authentication, and adds no product-specific setting. A missing platform payload, authentication failure, and other product failures remain local to the attempted delegation. +Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its Agent SDK select the private native executable. Neither provider consults or falls back to a host product command, while native configuration and authentication remain authoritative. Loading either Bundle only registers the provider and creates no product state, probes no version or authentication, and adds no product-specific setting. A missing platform payload, authentication failure, and other product failures remain local to the attempted delegation. ## Verification -Real composition loads no product Bundle, Codex only, Claude Code only, or both, then crosses that availability with Agent Presets that grant neither tool, either one, or both. It proves the Host registry and model-visible tools reflect those independent decisions, no product process starts during composition, and Preset edits affect only later Sessions. Package Loader and real-product tests separately prove each private runtime, missing-payload failure without host fallback, cancellation, and process-tree quiescence. Keyless ACP snapshots pin the model-visible tool schemas and generic Job controls. +Real composition loads no product Bundle, Codex only, Claude Code only, or both, then crosses that availability with Agent Presets that grant neither tool, either one, or both. It proves the Host registry and model-visible tools reflect those independent decisions, no product process starts during composition, and Preset edits affect only later Sessions. The linked provider and background decisions own private-runtime, failure, teardown, tool-schema, and Job evidence. ## Alternatives considered 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 index 14b7afcf41..9e29d8c0d0 100644 --- 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 @@ -16,11 +16,11 @@ Status: implemented [生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只部分取代本说明先前关于默认包含提供方的选择:base 组合包排除两个提供方,每个提供方包都拥有可直接安装的 Bundle patch。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -两个 Bundle 的可执行文件归属不同。Codex 包锁定官方 wrapper 与六个平台 alias;提供方运行包所声明的 wrapper,再由它选择私有原生载荷。Claude Code 包锁定 Agent SDK 与八个平台包;提供方让 SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令,原生配置与身份验证仍保持权威。加载任一 Bundle 只会完成提供方注册,不会创建产品状态、探测版本或身份验证,也不会新增产品专属设置。平台载荷缺失、身份验证失败和其他产品故障仍局限于发生问题的那次委派。 +每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapper,Claude Code 包则让 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令,原生配置与身份验证仍保持权威。加载任一 Bundle 只会完成提供方注册,不会创建产品状态、探测版本或身份验证,也不会新增产品专属设置。平台载荷缺失、身份验证失败和其他产品故障仍局限于发生问题的那次委派。 ## 验证 -真实组装会覆盖未安装产品 Bundle、仅安装 Codex、仅安装 Claude Code 或两者都安装四种状态,再与不授权工具、只授权其中一个或同时授权两者的 Agent Preset 交叉。测试证明 Host 注册表与模型可见工具会反映这两个独立决策,组装期间不会启动产品进程,而且 Preset 编辑只影响后续 Session。包级 Loader 与真实产品测试分别证明两个私有运行时、载荷缺失时不回退宿主命令、取消和进程树完全停稳。无密钥 ACP(Agent Client Protocol)快照固定模型可见工具 schema 与通用 Job 控制。 +真实组装会覆盖未安装产品 Bundle、仅安装 Codex、仅安装 Claude Code 或两者都安装四种状态,再与不授权工具、只授权其中一个或同时授权两者的 Agent Preset 交叉。测试证明 Host 注册表与模型可见工具会反映这两个独立决策,组装期间不会启动产品进程,而且 Preset 编辑只影响后续 Session。已链接的提供方与后台执行决策分别拥有私有运行时、失败、清理、工具 schema 及 Job 证据。 ## 考虑过的替代方案 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index d497a4f16c..47c711aaee 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -28,26 +28,9 @@ import { CodexAppServerWire } from './wire.ts' export const DEFAULT_DISPOSE_GRACE_MS = 3_000 interface CodexPackageManifest { - readonly bin?: string | Readonly> -} - -/** - * Resolve the official package's declared `codex` bin relative to its manifest. - * @param packageJsonPath - absolute path to the official package manifest. - * @param manifest - parsed manifest carrying the declared bin entry. - * @returns absolute path to the package-local JavaScript wrapper. - */ -export function codexPackageBinPath( - packageJsonPath: string, - manifest: CodexPackageManifest, -): string { - const declared = typeof manifest.bin === 'string' - ? manifest.bin - : manifest.bin?.codex - if (declared === undefined || declared.length === 0) { - throw new Error('@openai/codex does not declare its codex bin') + readonly bin: { + readonly codex: string } - return resolve(dirname(packageJsonPath), declared) } const codexPackageJsonPath = createRequire(import.meta.url).resolve('@openai/codex/package.json') @@ -56,9 +39,9 @@ const codexPackageManifest = JSON.parse( ) as CodexPackageManifest /** Absolute package-local JavaScript wrapper selected by the package manifest. */ -export const CODEX_PACKAGE_BIN = codexPackageBinPath( - codexPackageJsonPath, - codexPackageManifest, +const CODEX_PACKAGE_BIN = resolve( + dirname(codexPackageJsonPath), + codexPackageManifest.bin.codex, ) /** diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index 2e876134a3..c12e96a306 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -7,9 +7,9 @@ import { rmSync, writeFileSync, } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { dirname, join, resolve } from 'node:path' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -18,18 +18,18 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' -import { CODEX_PACKAGE_BIN } from '../src/run.ts' import { startDeepSeekResponsesBridge, type DeepSeekResponsesBridge, } from './deepseek-responses-bridge.ts' const execFileAsync = promisify(execFile) -const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const codexPackageJson = createRequire(import.meta.url).resolve('@openai/codex/package.json') const codexPackage = JSON.parse(readFileSync( - join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + codexPackageJson, 'utf8', -)) as { version: string } +)) as { version: string; bin: { codex: string } } +const codexEntry = resolve(dirname(codexPackageJson), codexPackage.bin.codex) const roots: string[] = [] const contexts: Context[] = [] @@ -106,7 +106,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( return handle }) await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) - const version = await execFileAsync(process.execPath, [CODEX_PACKAGE_BIN, '--version'], { + const version = await execFileAsync(process.execPath, [codexEntry, '--version'], { env: { ...process.env, ...env }, }) expect(codexPackage.version).toBe('0.147.0') diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 8b1181a743..fb9af718e9 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -8,6 +8,7 @@ import { writeFileSync, } from 'node:fs' import { rm } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -19,7 +20,6 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' -import { CODEX_PACKAGE_BIN } from '../src/run.ts' import { startResponsesFixture, type ResponsesBehavior, @@ -29,12 +29,13 @@ import { const execFileAsync = promisify(execFile) const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) const codexBinDir = join(packageRoot, 'node_modules', '.bin') -const codexEntry = CODEX_PACKAGE_BIN -const codexPackageRoot = dirname(dirname(codexEntry)) +const codexPackageJson = createRequire(import.meta.url).resolve('@openai/codex/package.json') const codexPackage = JSON.parse(readFileSync( - join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + codexPackageJson, 'utf8', -)) as { version: string } +)) as { version: string; bin: { codex: string } } +const codexEntry = resolve(dirname(codexPackageJson), codexPackage.bin.codex) +const codexPackageRoot = dirname(dirname(codexEntry)) const roots: string[] = [] const fixtures: ResponsesFixture[] = [] diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index a60ed2f759..96c30ed52b 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import { PassThrough } from 'node:stream' import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' @@ -19,9 +19,7 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { - CODEX_PACKAGE_BIN, codexAppServerArgv, - codexPackageBinPath, DEFAULT_DISPOSE_GRACE_MS, disposeCodexChild, startCodexRun, @@ -295,7 +293,7 @@ describe('task admission and package contracts', () => { const codexPackageJson = fileURLToPath(import.meta.resolve('@openai/codex/package.json')) const codexManifest = JSON.parse(readFileSync(codexPackageJson, 'utf8')) as { version: string - bin: Record + bin: { codex: string } optionalDependencies: Record } expect(codexManifest.version).toBe(CODEX_VERSION) @@ -306,7 +304,12 @@ describe('task admission and package contracts', () => { `npm:@openai/codex@${CODEX_VERSION}-${packageName.slice('@openai/codex-'.length)}`, ]), )) - expect(CODEX_PACKAGE_BIN).toBe(codexPackageBinPath(codexPackageJson, codexManifest)) + expect(codexAppServerArgv()).toEqual([ + process.execPath, + resolve(dirname(codexPackageJson), codexManifest.bin.codex), + 'app-server', + '--stdio', + ]) const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8') for (const packageName of CODEX_PLATFORM_PACKAGES) { @@ -329,23 +332,8 @@ describe('task admission and package contracts', () => { }) it('uses only the official package-declared wrapper for app-server', () => { - expect(codexPackageBinPath( - '/package/node_modules/@openai/codex/package.json', - { bin: 'bin/codex.js' }, - )).toBe('/package/node_modules/@openai/codex/bin/codex.js') - expect(codexPackageBinPath( - '/package/node_modules/@openai/codex/package.json', - { bin: { codex: 'bin/codex.js' } }, - )).toBe('/package/node_modules/@openai/codex/bin/codex.js') - expect(() => codexPackageBinPath('/package/package.json', {})) - .toThrow('does not declare its codex bin') - expect(codexAppServerArgv()).toEqual([ - process.execPath, - CODEX_PACKAGE_BIN, - 'app-server', - '--stdio', - ]) - expect(codexAppServerArgv()).not.toContain('codex') + expect(codexAppServerArgv()[0]).toBe(process.execPath) + expect(codexAppServerArgv().slice(2)).toEqual(['app-server', '--stdio']) }) it('accepts one or more text blocks and rejects empty or non-text tasks', () => { From bc91897adbdd1932ee510ebd5ede3c49f74d9feb Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 04:24:03 +0800 Subject: [PATCH 14/95] test(cli): load product Bundles through Profile --- apps/cli/tests/web-agent-presets.e2e.ts | 45 +++++++++++++++++-------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 5e9680fd13..0e98af0477 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { Context } from '@deepseek-ai/cordis' -import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { boot, healProfilesModuleFallback, loadOverlayPatches, loadProfile } from '@deepseek-ai/dsh-app-boot' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -26,8 +26,8 @@ const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ 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') -const CODEX_PATCH = join(REPO_ROOT, 'packages/subagent/subagent-codex/cordis.patch.yml') -const CLAUDE_CODE_PATCH = join(REPO_ROOT, 'packages/subagent/subagent-claude-code/cordis.patch.yml') +const CODEX_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-codex') +const CLAUDE_CODE_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-claude-code') /** 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.' @@ -49,11 +49,10 @@ async function bootWeb( settingsFile: string, extra: PatchOptions[] = [], profilePackages: readonly string[] = [], + profileBundles?: readonly string[], ): Promise { const storageRoot = join(dirname(settingsFile), 'storages') - const patches: PatchOptions[] = [ - ...loadOverlayPatches('dsh-test', BASE_PATCH), - ...loadOverlayPatches('dsh-test', WEB_PATCH), + const overrides: PatchOptions[] = [ // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it // reads the developer's own document — and since the default preset is a // setting, a stored `agent-presets.default` would decide this file's @@ -128,9 +127,22 @@ async function bootWeb( await mkdir(dirname(link), { recursive: true }) await symlink(packageDir, link, 'junction') } + let bundlePatches: PatchOptions[] = [ + ...loadOverlayPatches('dsh-test', BASE_PATCH), + ...loadOverlayPatches('dsh-test', WEB_PATCH), + ] + if (profileBundles !== undefined) { + await writeFile(join(profileDir, 'package.json'), JSON.stringify({ + private: true, + dependencies: Object.fromEntries(profileBundles.map(name => [name, 'workspace:*'])), + dsh: { profile: { bundles: profileBundles } }, + }, null, 2) + '\n') + const profile = loadProfile('dsh-test', 'spec', INSTALL_ANCHOR, home, { userLayer: false }) + bundlePatches = profile.layers.flatMap(layer => layer.patches) + } const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - return await boot('dsh-test', rootConfig, patches, (bootCtx) => { + return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => { provideCmdline(bootCtx, { args: [], exit: () => {} }) }) } @@ -467,14 +479,15 @@ describe('product Bundle and user-preset intersection', () => { await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'agent.cordis.yml'), composition) } - const patchPath = (product: Product): string => ( - product === 'codex' ? CODEX_PATCH : CLAUDE_CODE_PATCH + const packageDir = (product: Product): string => ( + product === 'codex' ? CODEX_PACKAGE_DIR : CLAUDE_CODE_PACKAGE_DIR + ) + const packageName = (product: Product): string => ( + product === 'codex' + ? '@deepseek-ai/dsh-subagent-codex' + : '@deepseek-ai/dsh-subagent-claude-code' ) - const productPatches = installed.flatMap(product => ( - loadOverlayPatches('dsh-test', patchPath(product)) - )) return await bootWeb(settingsFile, [ - ...productPatches, { id: 'agent-presets', config: { @@ -486,7 +499,11 @@ describe('product Bundle and user-preset intersection', () => { includeUserRoot: false, }, }, - ], installed.map(product => dirname(patchPath(product)))) + ], installed.map(packageDir), [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + ...installed.map(packageName), + ]) } it('composes the intersection of installed Bundles and enabled preset rows', async () => { From d1a767c66b34b7de8c97f56497d8b6594e87956f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 04:32:30 +0800 Subject: [PATCH 15/95] test(infra): isolate Codex alias fixture --- scripts/gen-third-party-notices.spec.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index bd78e6f0fc..363328ca2f 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -387,12 +387,7 @@ describe('official Codex platform payloads', () => { fixtureRoot, 'node_modules/@openai/codex/package.json', ) - const payloadPath = join( - fixtureRoot, - 'node_modules/@openai/codex-darwin-arm64/package.json', - ) mkdirSync(resolve(wrapperPath, '..'), { recursive: true }) - mkdirSync(resolve(payloadPath, '..'), { recursive: true }) writeFileSync(wrapperPath, JSON.stringify({ name: CODEX_PACKAGE, version: '9.8.7', @@ -401,11 +396,18 @@ describe('official Codex platform payloads', () => { '@openai/codex-linux-x64': 'npm:@openai/codex@9.8.7-linux-x64', }, })) - writeFileSync(payloadPath, JSON.stringify({ - name: CODEX_PACKAGE, - version: '9.8.7-darwin-arm64', - license: 'Apache-2.0', - })) + for (const platform of ['darwin-arm64', 'linux-x64']) { + const payloadPath = join( + fixtureRoot, + `node_modules/@openai/codex-${platform}/package.json`, + ) + mkdirSync(resolve(payloadPath, '..'), { recursive: true }) + writeFileSync(payloadPath, JSON.stringify({ + name: CODEX_PACKAGE, + version: `9.8.7-${platform}`, + license: 'Apache-2.0', + })) + } expect(codexDistributionFromInstalledPackage(wrapperPath)).toEqual({ wrapperVersion: '9.8.7', From 4775095aa2b2488a5d179dd56d1a9eb133983aec Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 04:41:50 +0800 Subject: [PATCH 16/95] fix(infra): remove stale Codex dependency hints --- knip.json | 3 --- packages/subagent/subagent-codex/package.json | 1 - 2 files changed, 4 deletions(-) diff --git a/knip.json b/knip.json index 3017292382..b4c9808971 100644 --- a/knip.json +++ b/knip.json @@ -636,9 +636,6 @@ "project": [ "src/**/*.ts", "tests/**/*.ts" - ], - "ignoreDependencies": [ - "@openai/codex" ] }, "packages/subagent/subagent-claude-code": { diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 1d948eb631..8144ff9c4c 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -57,7 +57,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", From cb229c896450b160e95cf6a5cfee5f31cb996e62 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 05:26:12 +0800 Subject: [PATCH 17/95] refactor(infra): keep Codex notices direct --- THIRD_PARTY_NOTICES.md | 16 +-- scripts/gen-third-party-notices.spec.ts | 91 --------------- scripts/gen-third-party-notices.ts | 140 +----------------------- 3 files changed, 2 insertions(+), 245 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b03dad17cd..a62d3ed8ac 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code and Codex platform payload closures. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). @@ -114,20 +114,6 @@ The installed SDK 0.3.220 declares the following optional platform packages. Eac | [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -## Official Codex platform payloads - -The installed `@openai/codex` wrapper 0.147.0 declares the following optional-dependency aliases. Every alias resolves to an official platform-specific `@openai/codex` version that carries the native Codex CLI and its bundled resources; the declared license is verified against the payload installed for the current host. - -| Optional dependency alias | Published package | Version | Declared license | -| --- | --- | --- | --- | -| `@openai/codex-darwin-arm64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-darwin-arm64) | 0.147.0-darwin-arm64 | Apache-2.0 | -| `@openai/codex-darwin-x64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-darwin-x64) | 0.147.0-darwin-x64 | Apache-2.0 | -| `@openai/codex-linux-arm64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-linux-arm64) | 0.147.0-linux-arm64 | Apache-2.0 | -| `@openai/codex-linux-x64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-linux-x64) | 0.147.0-linux-x64 | Apache-2.0 | -| `@openai/codex-win32-arm64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-win32-arm64) | 0.147.0-win32-arm64 | Apache-2.0 | -| `@openai/codex-win32-x64` | [`@openai/codex`](https://www.npmjs.com/package/@openai/codex/v/0.147.0-win32-x64) | 0.147.0-win32-x64 | Apache-2.0 | - - ## Development-only npm dependencies External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 363328ca2f..479a2f13b1 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -4,10 +4,7 @@ import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' import { CLAUDE_AGENT_SDK_PACKAGE, - CODEX_PACKAGE, claudeDistributionFromManifest, - codexDistributionFromManifest, - codexDistributionFromInstalledPackage, collectPythonDependencies, isOwnerAuthorizedRuntime, isPermissive, @@ -334,94 +331,6 @@ describe('official Claude distribution authorization', () => { }) }) -describe('official Codex platform payloads', () => { - it('derives versioned packages from the wrapper aliases', () => { - expect(codexDistributionFromManifest({ - name: CODEX_PACKAGE, - version: '9.8.7', - optionalDependencies: { - '@openai/codex-linux-x64': 'npm:@openai/codex@9.8.7-linux-x64', - '@openai/codex-darwin-arm64': 'npm:@openai/codex@9.8.7-darwin-arm64', - }, - })).toEqual({ - wrapperVersion: '9.8.7', - payloads: [ - { alias: '@openai/codex-darwin-arm64', version: '9.8.7-darwin-arm64' }, - { alias: '@openai/codex-linux-x64', version: '9.8.7-linux-x64' }, - ], - }) - }) - - it('rejects a wrong identity, missing payloads, and non-official aliases', () => { - expect(() => codexDistributionFromManifest({ - name: '@openai/unrelated', - version: '1.0.0', - optionalDependencies: { - '@openai/codex-linux-x64': 'npm:@openai/codex@1.0.0-linux-x64', - }, - })).toThrow(`expected ${CODEX_PACKAGE} manifest`) - expect(() => codexDistributionFromManifest({ - name: CODEX_PACKAGE, - version: '1.0.0', - })).toThrow('declares no optional platform payloads') - expect(() => codexDistributionFromManifest({ - name: CODEX_PACKAGE, - version: '1.0.0', - optionalDependencies: { - '@openai/unrelated': 'npm:@openai/codex@1.0.0-linux-x64', - }, - })).toThrow('outside its platform alias namespace') - expect(() => codexDistributionFromManifest({ - name: CODEX_PACKAGE, - version: '1.0.0', - optionalDependencies: { - '@openai/codex-linux-x64': '1.0.0', - }, - })).toThrow('does not alias an official versioned payload') - }) - - it('resolves installed payload aliases from the wrapper package', () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-notices-codex-wrapper-')) - try { - const wrapperPath = join( - fixtureRoot, - 'node_modules/@openai/codex/package.json', - ) - mkdirSync(resolve(wrapperPath, '..'), { recursive: true }) - writeFileSync(wrapperPath, JSON.stringify({ - name: CODEX_PACKAGE, - version: '9.8.7', - optionalDependencies: { - '@openai/codex-darwin-arm64': 'npm:@openai/codex@9.8.7-darwin-arm64', - '@openai/codex-linux-x64': 'npm:@openai/codex@9.8.7-linux-x64', - }, - })) - for (const platform of ['darwin-arm64', 'linux-x64']) { - const payloadPath = join( - fixtureRoot, - `node_modules/@openai/codex-${platform}/package.json`, - ) - mkdirSync(resolve(payloadPath, '..'), { recursive: true }) - writeFileSync(payloadPath, JSON.stringify({ - name: CODEX_PACKAGE, - version: `9.8.7-${platform}`, - license: 'Apache-2.0', - })) - } - - expect(codexDistributionFromInstalledPackage(wrapperPath)).toEqual({ - wrapperVersion: '9.8.7', - payloads: [ - { alias: '@openai/codex-darwin-arm64', version: '9.8.7-darwin-arm64' }, - { alias: '@openai/codex-linux-x64', version: '9.8.7-linux-x64' }, - ], - }) - } finally { - rmSync(fixtureRoot, { recursive: true, force: true }) - } - }) -}) - describe('manifestPatterns', () => { it('derives globs from the declared members, so a new member area is read', () => { expect(manifestPatterns(['packages/*/*', 'tools/*', 'native/landlock-run', 'native/landlock-run/packages/*'])).toEqual([ diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 314c9b1b28..3362251f1e 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -9,7 +9,6 @@ */ import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' import { resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' @@ -51,9 +50,6 @@ const FIRST_PARTY = new Set([ export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-` const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md' -export const CODEX_PACKAGE = '@openai/codex' -const CODEX_PLATFORM_ALIAS_PREFIX = `${CODEX_PACKAGE}-` -const CODEX_DECLARED_LICENSE = 'Apache-2.0' /** * Whether a non-permissive runtime declaration has an identity-scoped owner @@ -198,18 +194,6 @@ export interface ClaudeDistribution { readonly payloads: ClaudePlatformPayload[] } -/** One optional-dependency alias for an official Codex platform payload. */ -export interface CodexPlatformPayload { - readonly alias: string - readonly version: string -} - -/** Current Codex wrapper and platform payload facts from the official manifest. */ -export interface CodexDistribution { - readonly wrapperVersion: string - readonly payloads: CodexPlatformPayload[] -} - function requiredManifestString( value: string | undefined, field: string, @@ -259,91 +243,6 @@ export function claudeDistributionFromManifest( return { sdkVersion, claudeCodeVersion, payloads } } -/** Derive official Codex platform aliases and published package versions. */ -export function codexDistributionFromManifest( - manifest: VirtualManifest, -): CodexDistribution { - if (manifest.name !== CODEX_PACKAGE) { - throw new Error( - `gen-third-party-notices: expected ${CODEX_PACKAGE} manifest, got ${JSON.stringify(manifest.name)}.`, - ) - } - const wrapperVersion = manifest.version - if (wrapperVersion === undefined || wrapperVersion.length === 0) { - throw new Error(`gen-third-party-notices: ${CODEX_PACKAGE} has no version.`) - } - const entries = Object.entries(manifest.optionalDependencies ?? {}) - if (entries.length === 0) { - throw new Error(`gen-third-party-notices: ${CODEX_PACKAGE} declares no optional platform payloads.`) - } - const payloads = entries.map(([alias, spec]) => { - if (!alias.startsWith(CODEX_PLATFORM_ALIAS_PREFIX)) { - throw new Error( - `gen-third-party-notices: ${CODEX_PACKAGE} optional dependency ${alias} is outside its platform alias namespace.`, - ) - } - const prefix = `npm:${CODEX_PACKAGE}@` - if (!spec.startsWith(prefix) || spec.length === prefix.length) { - throw new Error( - `gen-third-party-notices: ${CODEX_PACKAGE} optional dependency ${alias} does not alias an official versioned payload.`, - ) - } - return { alias, version: spec.slice(prefix.length) } - }).sort((left, right) => left.alias.localeCompare(right.alias)) - return { wrapperVersion, payloads } -} - -function requireManifest( - requireFrom: NodeJS.Require, - name: string, -): VirtualManifest | undefined { - let packageJsonPath: string - try { - packageJsonPath = requireFrom.resolve(`${name}/package.json`) - } catch (error: unknown) { - if (error instanceof Error && 'code' in error && error.code === 'MODULE_NOT_FOUND') { - return undefined - } - throw error - } - return JSON.parse(readFileSync(packageJsonPath, 'utf8')) as VirtualManifest -} - -/** - * Derive and verify the Codex distribution from the wrapper package's own - * Node resolution context. - * @param packageJsonPath - absolute manifest path for the installed wrapper. - * @returns the wrapper version and all declared platform aliases. - */ -export function codexDistributionFromInstalledPackage( - packageJsonPath: string, -): CodexDistribution { - const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as VirtualManifest - const distribution = codexDistributionFromManifest(manifest) - const requireFromWrapper = createRequire(packageJsonPath) - let installedPayloads = 0 - for (const payload of distribution.payloads) { - const installed = requireManifest(requireFromWrapper, payload.alias) - if (installed === undefined) continue - installedPayloads += 1 - if ( - installed.name !== CODEX_PACKAGE - || installed.version !== payload.version - || installed.license !== CODEX_DECLARED_LICENSE - ) { - throw new Error( - `gen-third-party-notices: installed ${payload.alias} does not match its official ${CODEX_PACKAGE}@${payload.version} payload and ${CODEX_DECLARED_LICENSE} license.`, - ) - } - } - if (installedPayloads === 0) { - throw new Error( - 'gen-third-party-notices: no Codex platform payload is installed; install optional dependencies before regenerating.', - ) - } - return distribution -} - /** * Resolve one package's manifest inside a pnpm virtual store. The prefix scan * matches ordinary `@scope+name@version` directory names; pnpm 11 truncates @@ -434,20 +333,6 @@ function collectClaudeDistribution(): ClaudeDistribution { return distribution } -function collectCodexDistribution(): CodexDistribution { - const requireFromProvider = createRequire(resolve( - root, - 'packages/subagent/subagent-codex/package.json', - )) - let packageJsonPath: string - try { - packageJsonPath = requireFromProvider.resolve(`${CODEX_PACKAGE}/package.json`) - } catch { - throw new Error(`gen-third-party-notices: cannot resolve ${CODEX_PACKAGE}; run \`pnpm install\`.`) - } - return codexDistributionFromInstalledPackage(packageJsonPath) -} - /** Normalize a manifest repository/homepage value to a browsable https URL. */ function normalizeRepo(raw: string | undefined): string | undefined { if (raw === undefined || raw === '') return undefined @@ -771,24 +656,6 @@ ${rows.join('\n')} ` } -function renderCodexDistribution( - distribution: CodexDistribution | undefined, -): string { - if (distribution === undefined) return '' - const rows = distribution.payloads.map(payload => ( - `| \`${payload.alias}\` | [\`${CODEX_PACKAGE}\`](https://www.npmjs.com/package/${CODEX_PACKAGE}/v/${payload.version}) | ${payload.version} | ${CODEX_DECLARED_LICENSE} |` - )) - return ` -## Official Codex platform payloads - -The installed \`${CODEX_PACKAGE}\` wrapper ${distribution.wrapperVersion} declares the following optional-dependency aliases. Every alias resolves to an official platform-specific \`${CODEX_PACKAGE}\` version that carries the native Codex CLI and its bundled resources; the declared license is verified against the payload installed for the current host. - -| Optional dependency alias | Published package | Version | Declared license | -| --- | --- | --- | --- | -${rows.join('\n')} -` -} - /** * Render the complete notices document. * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold. @@ -806,10 +673,6 @@ export function render(): string { ) ? collectClaudeDistribution() : undefined - const codexDistribution = runtimeDeps.some(dep => dep.name === CODEX_PACKAGE) - ? collectCodexDistribution() - : undefined - const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) // A copyleft license reaching a shipped surface is a distribution decision, // not a rendering detail; the notices cannot quietly absorb it. @@ -829,7 +692,7 @@ export function render(): string { DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code and Codex platform payload closures. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock). @@ -851,7 +714,6 @@ pnpm applies local patches to the following packages at install time, so shipped ${patchedLines.join('\n')} ${renderClaudeDistribution(claudeDistribution)} -${renderCodexDistribution(codexDistribution)} ## Development-only npm dependencies From ab696d84c20031b08dc7c3d957c9bf0e68654e48 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 05:36:23 +0800 Subject: [PATCH 18/95] docs(subagent): show disabled Claude tool row --- packages/subagent/subagent-claude-code/README.i18n.yaml | 4 ++-- packages/subagent/subagent-claude-code/README.md | 1 + packages/subagent/subagent-claude-code/README.zh.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index eaa28043f7..264ad5f71a 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: 6083d70c3d70e09f55d228faa14156a2212b4aa9 -README.zh.md: f0db9e90eb0588bc8bbc24a8f404815cc7ad2beb +README.md: 1d6910856c098f5efa5b9aa9f6d6ef6e14fbd2b4 +README.zh.md: 8d451bde1d291aedea471178c4a5b274073bc3d8 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 6083d70c3d..1d6910856c 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -53,6 +53,7 @@ Installation controls Host availability, not model permission. Full Agent Preset # A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index f0db9e90eb..8d451bde1d 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -53,6 +53,7 @@ dsh --profile # A copied Agent Preset; remove `disabled` to grant this tool. - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code From ac9351594393fb8e3c3cde9ee2e6c58f3f01633f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 05:49:35 +0800 Subject: [PATCH 19/95] fix(subagent): preserve Codex payload diagnostics --- packages/subagent/subagent-codex/src/run.ts | 37 ++++++++++++++--- .../tests/subagent-codex.spec.ts | 41 ++++++++++++++++++- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 47c711aaee..22fe3a1a34 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -26,6 +26,8 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Bounded stderr tail retained only to recognize the wrapper's payload error. */ +const CODEX_STDERR_TAIL_BYTES = 16 * 1024 interface CodexPackageManifest { readonly bin: { @@ -44,6 +46,21 @@ const CODEX_PACKAGE_BIN = resolve( codexPackageManifest.bin.codex, ) +function missingPayloadDiagnostic(child: SubprocessHandle): string | undefined { + const stderr = child.collected.stderr?.readFrom(0).text + if (stderr === undefined) return undefined + return /Missing optional dependency[^\r\n]*/.exec(stderr)?.[0]?.trim() +} + +function withMissingPayloadDiagnostic( + error: Error, + child: SubprocessHandle, +): Error { + const diagnostic = missingPayloadDiagnostic(child) + if (diagnostic === undefined || error.message.includes(diagnostic)) return error + return new Error(`${error.message}: ${diagnostic}`, { cause: error }) +} + /** * Fixed package-local app-server command, independent of the host `PATH`. * @returns Node, the official wrapper, and the fixed app-server arguments. @@ -136,7 +153,11 @@ export async function startCodexRun( const child = spec.spawn({ argv: codexAppServerArgv(), cwd: spec.cwd, - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + stdio: { + stdin: 'pipe', + stdout: 'pipe', + stderr: { maxBytes: CODEX_STDERR_TAIL_BYTES }, + }, graceMs: spec.disposeGraceMs, env: spec.env, }) @@ -148,9 +169,12 @@ export async function startCodexRun( const disposeProcess = (): Promise => disposeCodexChild(wire, child) const processFailure: Promise = child.done.then( - outcome => Promise.reject(new Error( - 'subagent-codex: app-server exited before the run settled ' - + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, + outcome => Promise.reject(withMissingPayloadDiagnostic( + new Error( + 'subagent-codex: app-server exited before the run settled ' + + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, + ), + child, )), (error: unknown) => Promise.reject(thrown(error)), ) @@ -173,18 +197,19 @@ export async function startCodexRun( await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) + const startupError = withMissingPayloadDiagnostic(thrown(error), child) try { await disposeProcess() } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [startupError, thrown(disposeError)], 'subagent-codex: startup failed and app-server cleanup also failed', ) } if (runAbort.signal.aborted) { throw new Error('subagent-codex: request was aborted before run publication') } - throw thrown(error) + throw startupError } const collectOutput = (): ContentBlock[] => wire.collectOutput() diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 96c30ed52b..476dde2771 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -108,6 +108,7 @@ interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean readonly doneError?: Error + readonly stderr?: string } interface FakeChild { @@ -125,6 +126,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { const fromChild = new PassThrough() const toChild = new PassThrough() const peer = new ProtocolPeer(toChild, fromChild) + const stderrText = options.stderr let exited = false let resolveDone!: (outcome: SubprocessOutcome) => void let rejectDone!: (error: Error) => void @@ -174,7 +176,17 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { stdin: toChild, stdout: fromChild, stderr: undefined, - collected: {}, + collected: stderrText === undefined + ? {} + : { + stderr: { + readFrom: () => ({ + text: stderrText, + nextOffset: Buffer.byteLength(stderrText), + lossy: false, + }), + }, + }, done, terminate, waitForExit, @@ -927,7 +939,11 @@ describe('run lifecycle and quiescence', () => { expect(spawn).toHaveBeenCalledWith({ argv: codexAppServerArgv(), cwd: process.cwd(), - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + stdio: { + stdin: 'pipe', + stdout: 'pipe', + stderr: { maxBytes: 16 * 1024 }, + }, graceMs: DEFAULT_DISPOSE_GRACE_MS, env: { OPENAI_API_KEY: 'fake' }, }) @@ -1051,6 +1067,27 @@ describe('run lifecycle and quiescence', () => { expect(child.terminate).toHaveBeenCalledTimes(1) }) + it('surfaces only the wrapper missing-payload diagnostic during startup', async () => { + const child = fakeChild({ + stderr: [ + 'credential-like unrelated stderr', + 'Error: Missing optional dependency @openai/codex-linux-x64.', + ].join('\n'), + }) + const starting = startCodexRun(request(), runSpec(child)) + child.settle({ exitCode: 1, signal: null }) + + const error: unknown = await starting.then( + () => undefined, + (failure: unknown) => failure, + ) + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) throw new Error('expected startup failure') + expect(error.message).toContain('Missing optional dependency @openai/codex-linux-x64') + expect(error.message).not.toContain('credential-like unrelated stderr') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + it('keeps overlapping runs isolated', async () => { const first = fakeChild() const second = fakeChild() From dbea39a125a787163b7725a1eaf01027abf2ade4 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 06:03:47 +0800 Subject: [PATCH 20/95] fix(subagent): sample Codex diagnostics after cleanup --- packages/subagent/subagent-codex/src/run.ts | 6 +++--- .../tests/subagent-codex.spec.ts | 21 +++++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 22fe3a1a34..0f3503859a 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -197,19 +197,19 @@ export async function startCodexRun( await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) - const startupError = withMissingPayloadDiagnostic(thrown(error), child) + const startupCause = thrown(error) try { await disposeProcess() } catch (disposeError: unknown) { throw new AggregateError( - [startupError, thrown(disposeError)], + [withMissingPayloadDiagnostic(startupCause, child), thrown(disposeError)], 'subagent-codex: startup failed and app-server cleanup also failed', ) } if (runAbort.signal.aborted) { throw new Error('subagent-codex: request was aborted before run publication') } - throw startupError + throw withMissingPayloadDiagnostic(startupCause, child) } const collectOutput = (): ContentBlock[] => wire.collectOutput() diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 476dde2771..b5a1baa1c8 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -108,6 +108,7 @@ interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean readonly doneError?: Error + readonly collectStderr?: boolean readonly stderr?: string } @@ -118,6 +119,7 @@ interface FakeChild { readonly toChild: PassThrough readonly settle: (outcome?: SubprocessOutcome) => void readonly fail: (error: Error) => void + readonly setStderr: (text: string) => void readonly terminate: () => void readonly waitForExit: (signal?: AbortSignal) => Promise } @@ -126,7 +128,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { const fromChild = new PassThrough() const toChild = new PassThrough() const peer = new ProtocolPeer(toChild, fromChild) - const stderrText = options.stderr + let stderrText = options.stderr ?? '' let exited = false let resolveDone!: (outcome: SubprocessOutcome) => void let rejectDone!: (error: Error) => void @@ -176,7 +178,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { stdin: toChild, stdout: fromChild, stderr: undefined, - collected: stderrText === undefined + collected: options.stderr === undefined && options.collectStderr !== true ? {} : { stderr: { @@ -198,6 +200,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { toChild, settle, fail, + setStderr: (text: string): void => { stderrText = text }, terminate, waitForExit, } @@ -1088,6 +1091,20 @@ describe('run lifecycle and quiescence', () => { expect(child.terminate).toHaveBeenCalledTimes(1) }) + it('waits for process settlement before sampling the missing-payload diagnostic', async () => { + const child = fakeChild({ collectStderr: true, exitOnTerminate: false }) + const starting = startCodexRun(request(), runSpec(child)) + child.fromChild.end() + await vi.waitFor(() => { expect(child.terminate).toHaveBeenCalledTimes(1) }) + + child.setStderr('Error: Missing optional dependency @openai/codex-linux-x64.') + child.settle({ exitCode: 1, signal: null }) + + await expect(starting).rejects.toThrow( + 'Missing optional dependency @openai/codex-linux-x64', + ) + }) + it('keeps overlapping runs isolated', async () => { const first = fakeChild() const second = fakeChild() From 02e4100f2a09253dd967ee94c2ff572ffaf0669b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 06:08:31 +0800 Subject: [PATCH 21/95] fix(subagent): normalize Codex payload diagnostics --- packages/subagent/subagent-codex/src/run.ts | 6 +++++- .../subagent/subagent-codex/tests/subagent-codex.spec.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 0f3503859a..3e84b66c8c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -49,7 +49,11 @@ const CODEX_PACKAGE_BIN = resolve( function missingPayloadDiagnostic(child: SubprocessHandle): string | undefined { const stderr = child.collected.stderr?.readFrom(0).text if (stderr === undefined) return undefined - return /Missing optional dependency[^\r\n]*/.exec(stderr)?.[0]?.trim() + const platformPackage = /Missing optional dependency (@openai\/codex-[a-z0-9-]+)/ + .exec(stderr)?.[1] + return platformPackage === undefined + ? undefined + : `Missing optional dependency ${platformPackage}` } function withMissingPayloadDiagnostic( diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index b5a1baa1c8..9d295864e2 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1074,7 +1074,8 @@ describe('run lifecycle and quiescence', () => { const child = fakeChild({ stderr: [ 'credential-like unrelated stderr', - 'Error: Missing optional dependency @openai/codex-linux-x64.', + 'Error: Missing optional dependency @openai/codex-linux-x64. ' + + 'Reinstall Codex: pnpm add -g @openai/codex@latest', ].join('\n'), }) const starting = startCodexRun(request(), runSpec(child)) @@ -1088,6 +1089,8 @@ describe('run lifecycle and quiescence', () => { if (!(error instanceof Error)) throw new Error('expected startup failure') expect(error.message).toContain('Missing optional dependency @openai/codex-linux-x64') expect(error.message).not.toContain('credential-like unrelated stderr') + expect(error.message).not.toContain('Reinstall Codex') + expect(error.message).not.toContain('pnpm add -g') expect(child.terminate).toHaveBeenCalledTimes(1) }) From 1446e36a036cf4d8a551878fc3e2961ec6701125 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 06:45:30 +0800 Subject: [PATCH 22/95] test(subagent): pin Codex payload diagnostic --- packages/subagent/subagent-codex/tests/real-product.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index fb9af718e9..00f824bcf9 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -202,7 +202,7 @@ describe('real @openai/codex 0.147.0 product', () => { ? { SystemRoot: process.env.SystemRoot } : {}, }, - })).rejects.toThrow('Missing optional dependency') + })).rejects.toThrow(/Missing optional dependency @openai\/codex-[a-z0-9-]+/) }, 30_000) it('cancels a real app-server command approval without executing the command', async () => { From 4d03472cd098dc48a630e526ca620f4f37f18a0e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 16:36:32 +0800 Subject: [PATCH 23/95] feat(subagent): add Claude Code non-interactive permission modes --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 2 +- ...ct-subagent-providers-in-shared-host.zh.md | 2 +- ...nt-empty-terminal-message-output.i18n.yaml | 4 +- ...-subagent-empty-terminal-message-output.md | 2 +- ...bagent-empty-terminal-message-output.zh.md | 2 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 4 +- .../2026-06-21-subagent-capability-seam.zh.md | 4 +- ...-07-08-background-subagent-tasks.i18n.yaml | 4 +- .../2026-07-08-background-subagent-tasks.md | 6 +- ...2026-07-08-background-subagent-tasks.zh.md | 6 +- ...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 +- ...bagent-one-shot-background-tasks.i18n.yaml | 4 +- ...duct-subagent-one-shot-background-tasks.md | 12 +- ...t-subagent-one-shot-background-tasks.zh.md | 12 +- ...agent-noninteractive-permissions.i18n.yaml | 6 + ...uct-subagent-noninteractive-permissions.md | 72 +++++++ ...-subagent-noninteractive-permissions.zh.md | 72 +++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 14 +- docs/config-catalog.zh.md | 14 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 9 +- docs/subsystems/subagent.zh.md | 9 +- .../product-subagent-both.cordis.snapshot.yml | 2 + .../product-subagent-both.cordis.yml | 2 + ...gent-result-diagnostic.cordis.snapshot.yml | 29 +++ .../subagent-result-diagnostic.cordis.yml | 17 ++ examples/acp-agent/tests/acp.snapshot.ts | 16 +- .../fixtures/subagent-result-diagnostic.ts | 50 +++++ .../subagent/subagent-claude-code/cordis.yml | 2 + .../input.json | 7 + .../replay.override.json | 42 ++++ .../session.jsonl | 51 +++++ .../stdout.expected.jsonl | 4 + knip.json | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 24 ++- .../subagent-claude-code/README.zh.md | 24 ++- .../subagent-claude-code/src/index.ts | 18 +- .../subagent/subagent-claude-code/src/run.ts | 103 ++++++++- .../tests/messages-fixture.ts | 74 +++++++ .../tests/real-product.spec.ts | 73 ++++++- .../tests/subagent-claude-code.spec.ts | 198 ++++++++++++++++-- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/out-of-process.ts | 42 +++- .../subagent/subagent/src/run-settlement.ts | 12 +- packages/subagent/subagent/src/types.ts | 7 + .../subagent/tests/run-settlement.spec.ts | 59 +++++- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 8 +- packages/subagent/tool-subagent/README.zh.md | 8 +- packages/subagent/tool-subagent/src/index.ts | 23 +- .../tool-subagent/tests/tool-subagent.spec.ts | 78 +++++++ 60 files changed, 1168 insertions(+), 128 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md create mode 100644 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md create mode 100644 examples/acp-agent/subagent-result-diagnostic.cordis.snapshot.yml create mode 100644 examples/acp-agent/subagent-result-diagnostic.cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl 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 index 205757eacc..331a7a8f5d 100644 --- 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 @@ -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-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: a747d641ae112d114912958c289fe00b592e6ea5 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: fef69e8a2d18135cbc9d5f0d80134fa5701bbbd0 +2026-08-10-product-subagent-providers-in-shared-host.md: dd5cd2b3b9c424da1f9f126d4ec9cb1fa4ca7083 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 0d946fa30240a130b27f85709382595cf29f5ead 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 index a747d641ae..dd5cd2b3b9 100644 --- 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 @@ -16,7 +16,7 @@ Product providers remain process-scoped host-plane registrations. The [productio This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract 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 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, or test authentication. It may supply the mounted Provider's deployment configuration, including the Claude Code `permissionMode` owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving that choice into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation. Only a Profile that selects the Claude Code provider carries the Claude Agent SDK's optional platform CLI payload. Production still resolves the host `claude`; the SDK payload remains provider-package installation cost rather than the production executable. 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 index fef69e8a2d..0d946fa302 100644 --- 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 @@ -16,7 +16,7 @@ Status: implemented 本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。 +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的 Claude Code `permissionMode`,但不会把该选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。 只有选择 Claude Code 提供方的 Profile 才会携带 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷。生产环境仍解析宿主提供的 `claude`;这份 SDK 载荷是提供方包的安装成本,而不是生产可执行文件。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml index 612916a290..bca41cb330 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.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-subagent-empty-terminal-message-output.md -2026-08-10-subagent-empty-terminal-message-output.md: 693013f6810005ce02b08bd82f1f6a18511c40fb -2026-08-10-subagent-empty-terminal-message-output.zh.md: 64d61af21f838ef3f515db8af116cbdd74e96179 +2026-08-10-subagent-empty-terminal-message-output.md: 24bab01ad844a5b48e0bf6fe0fc54df6403f4bb7 +2026-08-10-subagent-empty-terminal-message-output.zh.md: ab3488806a4f7c019d783e563c79e06aeeb86f33 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md index 693013f681..24bab01ad8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md @@ -12,7 +12,7 @@ The agent loop appends an empty-content `assistant/message` when a `max-tokens` `dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason. -The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message appends the child's partial text after the stop-reason headline so the parent model receives both the failure and available output. +The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message presents the optional safe Provider diagnostic owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md) after the stop-reason headline and appends the child's partial text afterward. The parent model receives the failure, separate infrastructure detail, and available assistant output without conflating them. ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md index 64d61af21f..ab3488806a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md @@ -12,7 +12,7 @@ Status: implemented `dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。 -前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。 +前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后呈现由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的可选安全提供方诊断,再附上子 agent 的部分文本。父模型会同时收到失败、独立的基础设施说明与已有 assistant 输出,而且不会把它们混为一体。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index e8ece4d624..36fa7f01b2 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-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-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: fa3b4f570bfccdc849a38b3eda16c1c8dd7b1827 -2026-06-21-subagent-capability-seam.zh.md: b25fe64377f98af92dbccb87f755627926975ef2 +2026-06-21-subagent-capability-seam.md: bc84d88d701a5f3018bf00f0ecf8b60750917407 +2026-06-21-subagent-capability-seam.zh.md: baec829a1a01018b490992fa0982bb23e43ba740 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index fa3b4f570b..bc84d88d70 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -54,11 +54,11 @@ Fresh and forked children are separate providers, not a request flag. `dsh-subag ### Child isolation and the parent log -Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output), while child steps and tool calls remain outside the parent log. +Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output, or a failed result with optional provider diagnostic), while child steps and tool calls remain outside the parent log. ### Synchronous collect (first cut) -`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output, and independent result and disposal rejections retain both diagnostics. +`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output; they present the optional safe diagnostic owned by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) separately from partial assistant text. Independent result and disposal rejections remain independently observable. ### Provider selection is config, not model-facing diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index b25fe64377..baec829a1a 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -54,11 +54,11 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 子 agent 隔离与父日志 -每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出),而子 agent 的步骤和工具调用均留在父日志之外。 +每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出,或带可选提供方诊断的失败结果),而子 agent 的步骤和工具调用均留在父日志之外。 ### 同步收集(首版) -`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;结果与 dispose 的拒绝相互独立,且两项诊断信息都会保留。 +`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;它会把由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责的可选安全诊断与部分 assistant 文本分开呈现。结果与 dispose 的拒绝仍可彼此独立地观察。 ### 提供方选择是配置,不面向模型 diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml index 0c0cf829a3..715da62619 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.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-08-background-subagent-tasks.md -2026-07-08-background-subagent-tasks.md: 412ec61dcdecae1a273c5993d25a4a099a22e864 -2026-07-08-background-subagent-tasks.zh.md: 9d108440c992e150ed62edaef8bf860813a471b8 +2026-07-08-background-subagent-tasks.md: 4dcd961ee5a5db925f8f6ad83e97890eaedb8e63 +2026-07-08-background-subagent-tasks.zh.md: dd8ba47e018bdeeaffccca1fdaf52df75728057b diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md index 412ec61dcd..4dcd961ee5 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md @@ -8,7 +8,7 @@ English | [中文](2026-07-08-background-subagent-tasks.zh.md) The [subagent seam](2026-06-21-subagent-capability-seam.md) returns a `SubagentRun`, but the model-facing tool originally collected every run synchronously. Independent, slow delegations therefore held the parent call open or ran serially. -Subagents need the same start, collect, list, stop, ownership, notification, and cleanup behavior as other long-running tools without adopting process-stream semantics. The child session remains the detailed trace; the parent needs the final answer and job status. A background child also outlives its starting tool call, so its cancellation and owner-disposal contracts must be explicit. +Subagents need the same start, collect, list, stop, ownership, notification, and cleanup behavior as other long-running tools without adopting process-stream semantics. The child session remains the detailed trace; the parent needs the final answer or safe failure detail plus job status. A background child also outlives its starting tool call, so its cancellation and owner-disposal contracts must be explicit. ## Decision @@ -16,7 +16,7 @@ Each `dsh-tool-subagent` instance may expose `run_in_background`, controlled by Background subagents use the [generic background job runtime](../architecture/2026-06-20-generic-long-running-tool-runtime.md). Collection, listing, cancellation, completion notices, and prompt guidance come from `job_output`, `job_list`, and `job_kill`; there are no subagent-specific companion tools. -Foreground calls retain their synchronous contract: await provider startup and `run.result`, return final text only for `completed`, map other terminal reasons to an errored tool result, and always dispose the run before returning. +Foreground calls retain their synchronous contract: await provider startup and `run.result`, return final text only for `completed`, map other terminal reasons to an errored tool result with the optional safe diagnostic described by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md), and always dispose the run before returning. For a background call, the tool validates the parent and refuses an already-aborted execution signal before calling `ctx.jobs.start()`. The job runtime preflights the control API and owner cleanup before invoking the producer starter. That starter creates an independent `AbortController` and begins `ctx.subagents.start()`; after the id is returned, the tool-call signal no longer owns the child. @@ -24,7 +24,7 @@ The task registration maps the subagent seam as follows: - `kind` is `subagent`, `label` is the model-supplied description, and `owner` is the parent agent. - `cancel(reason?)` aborts the task-owned controller. The same signal covers pending provider startup and the published run's remaining work. -- `done` awaits provider startup, the child result, and `run.dispose()`. Completed runs return final text, aborted runs become `killed`, and other stop reasons become `failed`. Startup, result, and disposal failures become failed outcomes rather than rejected task promises. +- `done` awaits provider startup, the child result, and `run.dispose()`. Completed runs return final text, aborted runs become `killed`, and other stop reasons become `failed` with the Provider diagnostic when present. Startup, result, and disposal failures become failed outcomes rather than rejected task promises. - `readOutput` is absent. While live, `job_output` returns status only; after settlement, it returns final output idempotently. Intermediate child activity remains in the child session. ## Lifecycle diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md index 9d108440c9..dd8ba47e01 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md @@ -8,7 +8,7 @@ Status: implemented [subagent seam](2026-06-21-subagent-capability-seam.md) 会返回 `SubagentRun`,但原先面向模型的工具会同步收集每一次运行。因此,各自独立的慢速委派要么一直占用父调用,要么按串行方式运行。 -subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放约定。 +subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案或安全失败说明,以及任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放约定。 ## 决策 @@ -16,7 +16,7 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列 后台 subagent 使用[通用后台任务运行时](../architecture/2026-06-20-generic-long-running-tool-runtime.md)。`job_output`、`job_list` 和 `job_kill` 负责收集、列出、取消、完成通知和提示词引导;系统不提供 subagent 专用的配套工具。 -前台调用保留其同步约定:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前释放该运行。 +前台调用保留其同步约定:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果,并在存在时附上由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)描述的可选安全诊断;而且始终在返回前释放该运行。 对于后台调用,工具会验证父级,并在调用 `ctx.jobs.start()` 前拒绝已中止的执行信号。任务运行时会在调用生产者启动器前,预检控制 API 和拥有者清理。该启动器创建独立的 `AbortController` 并启动 `ctx.subagents.start()`;返回 id 之后,工具调用的信号不再拥有该子级。 @@ -24,7 +24,7 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列 - `kind` 为 `subagent`,`label` 为模型提供的描述,`owner` 为父 agent(智能体)。 - `cancel(reason?)` 中止任务自有的控制器。同一个信号同时覆盖尚未完成的提供方启动和已发布 run 的剩余工作。 -- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。 +- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`,并在存在时携带提供方诊断。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。 - `readOutput` 不存在。任务存活期间,`job_output` 只返回状态;结算后,它以幂等方式返回最终输出。中间的子级活动仍保留在子会话中。 ## 生命周期 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 48773ba819..c642b870b8 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: 666945c4d8039874729a7f9da34d9cf82bfd479d -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 35b8dd51f1c45200a495c68f19f055a224123263 +2026-08-04-claude-code-and-codex-subagent-backends.md: d0e48bb2c048351f71687a66a31c8ecdda123328 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 3fd604927c447c24e9047424ab255eb9fd628226 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 666945c4d8..d0e48bb2c0 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 provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, and the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +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 [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile-selected mode and the shared failure diagnostic. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -50,9 +50,9 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp `@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. +The public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. 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. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. -The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted`. +The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. When a permission denial or unattended callback contributes to that failure, the result may additionally carry the bounded, non-assistant diagnostic owned by the non-interactive permissions decision. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without permission detail. Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable. @@ -66,7 +66,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 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 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, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, 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. @@ -82,7 +82,7 @@ The project owner's distribution authorization is scoped to the official `@anthr **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. +**Plugin-managed login, product home, models, settings, sandbox rules, or fine-grained permission policy.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. Claude Code exposes only one native non-interactive mode choice in addition to environment and teardown configuration; it does not mirror product rules or add a human interaction channel. **Continuation, progress, product-native background state, and shared parent context.** The provider payload remains one final answer for one self-contained task. The generic Job layer may add its id, status, notice, collection, and cancellation results, but product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and provider-specific background state need separate user contracts and are not prebuilt. @@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. -Every delegation pays for a fresh product process and independent model context. The product payload reaching the parent is final text only; background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. 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. +Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed Claude Code run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. 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. Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe. 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 35b8dd51f1..3fd604927c 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 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责 Claude Code 的 Profile 模式选择与共享失败诊断。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -50,9 +50,9 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 `@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 失败,而不会等待本提供方不负责的用户界面。 +公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 -只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`。 +只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。当权限拒绝或无人值守回调参与了该失败时,结果还可以携带由非交互权限决策负责的有界、非 assistant 诊断。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带权限说明。 启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。 @@ -66,7 +66,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -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 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -82,7 +82,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。 -**由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 +**由插件管理登录、产品主目录、模型、设置、沙箱规则或细粒度权限策略。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。Claude Code 除环境和清理配置外只公开一个原生非交互模式选择;它不会镜像产品规则,也不会增加人工交互通道。 **续接、进度、产品原生后台状态和共享父级上下文。** 提供方载荷仍是一项自包含任务的一个最终回答。通用 Job 层可以额外提供 id、状态、通知、收集与取消结果,但产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和提供方专属后台状态都需要独立的用户约定,当前实现不会预先构建这些功能。 @@ -90,6 +90,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl 用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 -每次委派都要承担新建产品进程和独立模型上下文的开销。到达父级的产品载荷仍只有最终文本;后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 +每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的 Claude Code 运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index 2310e148a0..b8ef147519 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: b8865cf94852396c32dd6da996bc9f5c2c7d806b -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: bbc18ebb6cf0de1b04f0c2a10ddf51cea282c24e +2026-08-12-product-subagent-one-shot-background-tasks.md: e389c0b8b6587cf699ea3fd30e75531bb6069108 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d424fa9d1ccb1f14fa73e342964e95b7181c8274 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index b8865cf948..e389c0b8b6 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -14,9 +14,9 @@ Exposing background execution must not add a product session, product-specific j Production `dsh` does not install the optional product providers. A Profile that opts in installs and mounts `dsh-subagent-codex`, `dsh-subagent-claude-code`, or both once on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. -The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence. +The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile configuration and diagnostic production. -No provider configuration, service interface, event, wire field, persistence format, or product identifier is added. Foreground and background differ only in which existing consumer waits for the same one-shot run. +This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run. ### Ownership and lifecycle @@ -37,7 +37,7 @@ product tool call | Product selection and exposure | Agent Preset | Bind one fixed tool name to one fixed provider | Enabling one row exposes only that product tool | | Foreground or background choice | `dsh-tool-subagent` | Resolve `run_in_background` under `one-shot` policy | Omission is foreground; explicit `true` returns a Job id | | Job id, state, output, cancellation, and notice | `ctx.jobs` and `dsh-tool-jobs` | Register and present the existing one-shot run | Generic job tools collect or stop the run for the exact parent | -| Native answer and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one process tree | Job settlement and foreground return both wait for disposal | +| Native result, optional diagnostic, and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one process tree | Job settlement and foreground return consume the same result and both wait for disposal | ## Published composition @@ -49,7 +49,7 @@ The ACP product compositions use the same fixed product rows and generic job con ## Verification -The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, cancellation, completion notices, owner disposal, and provider disposal. +The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, shared diagnostic presentation, cancellation, completion notices, owner disposal, and provider disposal. ## Alternatives considered @@ -65,6 +65,6 @@ The Web composition test explicitly mounts both optional providers from the repo ## Consequences -Agents can continue useful work while Codex or Claude Code handles an independent one-shot task, then collect the final answer or cancel it through the same Job controls used by other background producers. Foreground callers retain their existing result and error behavior. +Agents can continue useful work while Codex or Claude Code handles an independent one-shot task, then collect the final answer or cancel it through the same Job controls used by other background producers. Foreground and one-shot background consumers present the same safe Provider diagnostic when a failed result supplies one. -Every product delegation still starts a fresh native process or query, produces final text as its only product payload, and ends with provider disposal and whole-tree exit. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. +Every product delegation still starts a fresh native process or query, produces final assistant text as its only assistant payload, and ends with provider disposal and whole-tree exit. A failed result may separately carry a safe diagnostic. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index bbc18ebb6c..d424fa9d1c 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -14,9 +14,9 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装 `dsh-subagent-codex`、`dsh-subagent-claude-code` 或两者,并在 host plane(宿主平面)各挂载一次。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 -[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳。 +[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责 Claude Code 的 Profile 配置与诊断生产。 -本决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。前台与后台的区别仅在于由哪个现有消费方等待同一个 one-shot 运行。 +本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。 ### 归属与生命周期 @@ -37,7 +37,7 @@ product tool call | 产品选择与公开 | Agent Preset | 把一个固定工具名绑定到一个固定提供方 | 启用一行只会公开对应产品工具 | | 前台或后台选择 | `dsh-tool-subagent` | 按 `one-shot` 策略解析 `run_in_background` | 省略参数时在前台运行;显式传入 `true` 时返回 Job id | | Job id、状态、输出、取消与通知 | `ctx.jobs` 与 `dsh-tool-jobs` | 登记并展示现有 one-shot 运行 | 通用作业工具为准确父级收集或停止运行 | -| 原生答案与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一棵进程树 | Job 结算与前台返回都会等待资源释放 | +| 原生结果、可选诊断与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一棵进程树 | Job 结算与前台返回消费同一结果,且都会等待资源释放 | ## 发布组装 @@ -49,7 +49,7 @@ ACP 产品组装使用相同的固定产品行与通用作业控制工具。其 ## 验证 -Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、取消、完成通知、owner 资源释放与提供方资源释放。 +Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、共享诊断呈现、取消、完成通知、owner 资源释放与提供方资源释放。 ## 曾考虑的替代方案 @@ -65,6 +65,6 @@ Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供 ## 后果 -agent 可以在 Codex 或 Claude Code 处理独立 one-shot 任务时继续推进其他工作,随后通过其他后台 producer 共用的 Job 控制工具收集最终回答或取消运行。前台调用方继续获得既有结果与错误行为。 +agent 可以在 Codex 或 Claude Code 处理独立 one-shot 任务时继续推进其他工作,随后通过其他后台 producer 共用的 Job 控制工具收集最终回答或取消运行。若失败结果提供了安全的提供方诊断,前台与一次性后台消费方会呈现同一内容。 -每次产品委托仍会启动一个全新的原生进程或 query,把最终文本作为唯一产品载荷,并以提供方资源释放和整棵进程树退出结束。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 +每次产品委托仍会启动一个全新的原生进程或 query,把最终 assistant 文本作为唯一 assistant 载荷,并以提供方资源释放和整棵进程树退出结束。失败结果可以另行携带安全诊断。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml new file mode 100644 index 0000000000..42e26c2f7e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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-15-product-subagent-noninteractive-permissions.md +2026-08-15-product-subagent-noninteractive-permissions.md: f382bc7ad058fefd8001da6181824fc9b6f767d4 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: 76cf53c7c9af791db6e54a8b779a7284187d0716 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md new file mode 100644 index 0000000000..f382bc7ad0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -0,0 +1,72 @@ +# Agent Note: Claude Code subagents use Profile-selected non-interactive permissions + +Status: implemented + +English | [中文](2026-08-15-product-subagent-noninteractive-permissions.zh.md) + +## Problem + +The [Claude Code product provider](2026-08-04-claude-code-and-codex-subagent-backends.md) runs without a human interface. Native permission prompts, user dialogs, or MCP elicitation therefore cannot wait for a person, but relying on the product's ambient default can still select an interactive mode. A deployment also needs to choose broader native modes without giving the parent model or one tool call a way to raise its own authority. + +A failed product run previously reached the [subagent seam](2026-06-21-subagent-capability-seam.md) only as a stop reason. Logs could retain the product error, but the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not distinguish a permission refusal from another failure. Reusing assistant output for that fact would misattribute infrastructure detail to the child model. + +## Decision + +The Claude Code Provider owns one Profile-level `permissionMode` value. It defaults to `dontAsk` and accepts only the native non-interactive modes supported by the pinned Agent SDK: + +| Value | Native behavior | +| --- | --- | +| `dontAsk` | Deny operations that are not already authorized instead of prompting. | +| `acceptEdits` | Accept edits; deny any remaining permission prompt through the unattended callback. | +| `auto` | Let Claude Code's native classifier allow or deny permission requests. | +| `plan` | Use Claude Code's planning-only mode without tool execution. | +| `bypassPermissions` | Set the SDK's explicit dangerous confirmation and bypass permission checks. | + +The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. + +Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. + +### Failure diagnostic + +`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. + +Claude Code records only the effective mode, request category, unattended decision, and a fixed safe reason. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`. + +The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior. + +### Ownership and lifecycle + +| Fact or resource | Owner | Observable behavior | +| --- | --- | --- | +| Profile permission choice | Claude Code Provider Config | Invalid, interactive, or unknown values fail during configuration. | +| Permission and sandbox semantics | Claude Code and its Agent SDK | The Provider passes one native mode and does not mirror product policy. | +| Interaction decisions and safe diagnostic | One Claude Code run | Concurrent runs keep independent mode, callback, and diagnostic state. | +| Diagnostic type and byte limit | `dsh-subagent` | Consumers receive a bounded optional field separate from assistant output. | +| Foreground and Job presentation | `dsh-tool-subagent` and the generic Job runtime | Scheduling choice does not change the underlying failure fact. | +| Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent whole-tree disposal. | + +## Verification + +Package tests pin every allowed and rejected Config value, the exact SDK option mapping, bypass confirmation, callback terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, and disposal behavior. The real Agent SDK/CLI fixture proves that the default overrides an interactive native setting, denies an out-of-workspace write with safe diagnostic detail, executes an explicit bypass write only inside suite-owned temporary storage, and leaves the full process tree quiescent. Loader composition proves a non-default mode can be published without starting either product, and the keyless ACP snapshot records the same diagnostic in a foreground tool error and one-shot `job_output` while the model-facing product tool schema contains no permission parameter. + +## Alternatives considered + +**Use the product's ambient permission default.** A native setting may select an interactive mode and make unattended behavior deployment-dependent. The Provider must choose a non-interactive mode explicitly for every query. + +**Put permission mode in the model-facing tool or each start request.** That would let task content select authority and would duplicate a Profile deployment decision on every call. + +**Copy Claude settings or map the parent Harness sandbox.** The products do not share one permission vocabulary. Mirroring their state would create a second authority and obscure the native sandbox consequences of `auto` and bypass modes. + +**Forward prompts to a parent, Web client, or CLI.** The one-shot product run has no owned human-interaction lifecycle. Adding one would require durable request identity, routing, cancellation, and timeout semantics beyond this decision. + +**Return raw product errors, stderr, or tool inputs.** Those values can contain commands, paths, workspace data, environment values, or credentials. A fixed safe diagnostic keeps the failure actionable without exposing the product transcript. + +**Store a separate Job diagnostic.** The Job is only a scheduling adapter for the same `SubagentRun`; a second field would let foreground and background failure meanings drift. + +## Consequences + +Profiles can select Claude Code's native restricted, automatic, planning, edit-accepting, or bypass behavior before the Provider starts, while the safe default never asks a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences. + +Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement. + +The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Codex and other Providers remain valid without producing a diagnostic or exposing a permission-mode Config. diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md new file mode 100644 index 0000000000..76cf53c7c9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -0,0 +1,72 @@ +# Agent Note: Claude Code subagent 使用 Profile 选择的非交互权限 + +Status: implemented + +[English](2026-08-15-product-subagent-noninteractive-permissions.md) | 中文 + +## Problem + +[Claude Code 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)在没有人工界面的情况下运行。因此,原生权限提示、用户对话或 MCP elicitation 不能等待人员响应,但依赖产品环境中的默认值仍可能选择交互模式。部署也需要选择更宽松的原生模式,同时不能让父模型或单次工具调用提升自身权限。 + +失败的产品运行此前只能把终止原因送入 [subagent seam](2026-06-21-subagent-capability-seam.md)。日志可以保留产品错误,但前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.md)无法区分权限拒绝和其他失败。若复用 assistant 输出承载该事实,则会把基础设施说明错误归因给子模型。 + +## Decision + +Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支持的原生非交互模式: + +| 值 | 原生行为 | +| --- | --- | +| `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 | +| `acceptEdits` | 接受编辑;其余权限提示由无人值守回调拒绝。 | +| `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 | +| `plan` | 使用 Claude Code 的仅规划模式,不执行工具。 | +| `bypassPermissions` | 设置 SDK 的显式危险确认并跳过权限检查。 | + +提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 + +每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 + +### 失败诊断 + +`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。 + +Claude Code 只记录有效模式、请求类别、无人值守决定与固定的安全原因。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 + +前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。 + +### 所有权与生命周期 + +| 事实或资源 | Owner | 可观察行为 | +| --- | --- | --- | +| Profile 权限选择 | Claude Code 提供方 Config | 配置阶段会拒绝无效、交互式或未知值。 | +| 权限与沙箱语义 | Claude Code 及其 Agent SDK | 提供方传入一个原生模式,不镜像产品策略。 | +| 交互决定与安全诊断 | 单次 Claude Code 运行 | 并发运行分别拥有独立的模式、回调与诊断状态。 | +| 诊断类型与字节上限 | `dsh-subagent` | 消费方收到与 assistant 输出分离的有界可选字段。 | +| 前台与 Job 呈现 | `dsh-tool-subagent` 和通用 Job 运行时 | 调度选择不会改变底层失败事实。 | +| 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的完整进程树资源释放。 | + +## Verification + +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 选项映射、bypass 确认、回调终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail 和资源释放行为。真实 Agent SDK/CLI fixture 证明默认值会覆盖交互式原生设置,越出工作区的写入会被拒绝并返回安全诊断,显式 bypass 写入只会发生在测试拥有的临时存储中,而且完整进程树会完全停稳。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录同一诊断如何出现在前台工具错误与一次性 `job_output` 中,同时面向模型的产品工具 schema 不包含权限参数。 + +## Alternatives considered + +**使用产品环境中的权限默认值。** 原生设置可能选择交互模式,使无人值守行为依赖部署环境。提供方必须为每次 query 显式选择非交互模式。 + +**把权限模式放入面向模型的工具或每次 start 请求。** 这会让任务内容选择权限,并在每次调用中重复一个 Profile 部署决定。 + +**复制 Claude 设置或映射父级 Harness 沙箱。** 各产品并不共享同一套权限词汇。镜像这些状态会创建第二个权威,并掩盖 `auto` 与 bypass 模式的原生沙箱后果。 + +**把提示转发给父 agent、Web 客户端或 CLI。** 一次性产品运行没有由其拥有的人工交互生命周期。新增该能力需要持久请求身份、路由、取消与 timeout 语义,超出本决策范围。 + +**返回原始产品错误、stderr 或工具输入。** 这些值可能包含命令、路径、工作区数据、环境值或凭证。固定的安全诊断既保留可操作性,也不会暴露产品 transcript。 + +**单独保存 Job 诊断。** Job 只是同一 `SubagentRun` 的调度适配器;第二个字段会让前台和后台的失败含义发生漂移。 + +## Consequences + +Profile 可以在提供方启动前选择 Claude Code 原生的受限、自动、仅规划、编辑放行或 bypass 行为,而安全默认值绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。 + +权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。 + +本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。Codex 与其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ef4931f765..921934f9ac 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: 82f6d26c79d32c6952f3bc11c96fa1c2ddceecdc -config-catalog.zh.md: 958d3115447db37de248bbf30b0744308ff8dbb8 +config-catalog.md: 8294c2187f2b80fbf36787c784ad8b73a16206c1 +config-catalog.zh.md: f35392a5b005212067c9b593b7fa2818202466dd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 82f6d26c79..8294c2187f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2081,19 +2081,29 @@ Source: [`packages/subagent/subagent-acp/src/index.ts:27`](../packages/subagent/ Requires: `subagents` · `subprocess` ```ts config-catalog -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } + +/** Profile-selectable non-interactive Claude Code permission mode. */ +export type ClaudeCodePermissionMode = + | 'dontAsk' + | 'acceptEdits' + | 'auto' + | 'plan' + | 'bypassPermissions' ``` -Source: [`packages/subagent/subagent-claude-code/src/index.ts:32`](../packages/subagent/subagent-claude-code/src/index.ts) +Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 958d311544..f35392a5b0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2083,19 +2083,29 @@ export type PermissionPolicy = 'allow' | 'reject' 需要:`subagents` · `subprocess` ```ts config-catalog -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } + +/** Profile-selectable non-interactive Claude Code permission mode. */ +export type ClaudeCodePermissionMode = + | 'dontAsk' + | 'acceptEdits' + | 'auto' + | 'plan' + | 'bypassPermissions' ``` -来源:[`packages/subagent/subagent-claude-code/src/index.ts:32`](../packages/subagent/subagent-claude-code/src/index.ts) +来源:[`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index a86dc8de4a..0770ded7f6 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: a683a679e6017351540ee4b73adc74375ef0a1d6 -subagent.zh.md: 61391cd297c0eb14f4c0d8eac4539b551cb60bda +subagent.md: 9a21cecce9144c3aa4c268d753c0aeff5f3ac178 +subagent.zh.md: 4a487fd655f45622bedaa224bad332d6c9ae3ace diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index a683a679e6..9a21cecce9 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -307,7 +307,7 @@ type SubagentDescendantListEntry = SubagentListEntry & { ## The terminal result: `SubagentResult` -The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. +The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A provider may attach a safe, non-assistant `diagnostic` to a non-`completed` result; the provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads and limits the complete value to 4096 UTF-8 bytes before consumers present it separately from `output`. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. ```ts type-equiv /** @@ -330,6 +330,13 @@ interface SubagentResult { * schema-agnostic. */ readonly structured?: unknown + /** + * Provider-authored, non-assistant failure detail for a non-`completed` + * result. Providers keep this text free of tool inputs, file contents, + * environment values, credentials, and raw protocol payloads, and limit it + * to 4096 UTF-8 bytes. Consumers present it separately from {@link output}. + */ + readonly diagnostic?: string /** Why the run ended. A non-`completed` reason means `output` may be partial. */ readonly stopReason: SubagentStopReason } diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 61391cd297..4a487fd655 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -307,7 +307,7 @@ type SubagentDescendantListEntry = SubagentListEntry & { ## 终态结果:`SubagentResult` -单次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 +单次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。提供方可以为非 `completed` 结果附带安全且不属于 assistant 内容的 `diagnostic`;在消费方将它与 `output` 分开呈现前,提供方会排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整值限制在 4096 个 UTF-8 字节以内。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 ```ts type-equiv /** @@ -330,6 +330,13 @@ interface SubagentResult { * schema-agnostic. */ readonly structured?: unknown + /** + * Provider-authored, non-assistant failure detail for a non-`completed` + * result. Providers keep this text free of tool inputs, file contents, + * environment values, credentials, and raw protocol payloads, and limit it + * to 4096 UTF-8 bytes. Consumers present it separately from {@link output}. + */ + readonly diagnostic?: string /** Why the run ended. A non-`completed` reason means `output` may be partial. */ readonly stopReason: SubagentStopReason } diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml index 0f8760cb91..39f464a6b7 100644 --- a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -22,6 +22,8 @@ name: '@deepseek-ai/dsh-subagent-codex' - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' + config: + permissionMode: acceptEdits - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml index 6a75bec332..6c5154fc6b 100644 --- a/examples/acp-agent/product-subagent-both.cordis.yml +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -11,6 +11,8 @@ name: '@deepseek-ai/dsh-subagent-codex' - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' + config: + permissionMode: acceptEdits - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/subagent-result-diagnostic.cordis.snapshot.yml b/examples/acp-agent/subagent-result-diagnostic.cordis.snapshot.yml new file mode 100644 index 0000000000..563f7d6864 --- /dev/null +++ b/examples/acp-agent/subagent-result-diagnostic.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless twin of subagent-result-diagnostic.cordis.yml: keep the same test +# provider/tool and replace only the external model adapter. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - 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-result-diagnostic + name: './tests/fixtures/subagent-result-diagnostic.ts' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: snapshot-diagnostic + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true diff --git a/examples/acp-agent/subagent-result-diagnostic.cordis.yml b/examples/acp-agent/subagent-result-diagnostic.cordis.yml new file mode 100644 index 0000000000..c82531c0e9 --- /dev/null +++ b/examples/acp-agent/subagent-result-diagnostic.cordis.yml @@ -0,0 +1,17 @@ +# Test-only product-shaped composition: mount a deterministic provider behind +# the same one-shot tool schema as the public Codex example. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-result-diagnostic + name: './tests/fixtures/subagent-result-diagnostic.ts' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: snapshot-diagnostic + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index db4a2b5d2f..0ee4fc179c 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -65,6 +65,9 @@ const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( ) 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 PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG = fileURLToPath( + new URL('../subagent-result-diagnostic.cordis.yml', import.meta.url), +) const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -145,7 +148,7 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, pinsHeader: true, - headerClass: 'product-subagent-codex', + headerClass: 'product-subagent-result-diagnostic', configPath: PRODUCT_SUBAGENT_CODEX_CONFIG, }, { @@ -157,6 +160,17 @@ const SCENARIOS: Scenario[] = [ systemPromptSource: 'product-subagent-codex', configPath: PRODUCT_SUBAGENT_BOTH_CONFIG, }, + { + name: 'product-subagent-result-diagnostic', + hasModelTurn: true, + recorded: false, + overridden: true, + pinsHeader: true, + headerClass: 'product-subagent-codex', + systemPromptSource: 'product-subagent-codex', + toolSchemasSource: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG, + }, { name: 'session-title-after-turn', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts new file mode 100644 index 0000000000..f381bf3a81 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts @@ -0,0 +1,50 @@ +/** Deterministic provider for model-visible foreground and Job diagnostic snapshots. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + NO_START_CAPABILITIES, + type ResolvedSubagentStartRequest, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { SessionId } from '@deepseek-ai/dsh-session' + +export const name = 'subagent-result-diagnostic' +export const inject = ['subagents'] + +const DIAGNOSTIC = 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt' + +class DiagnosticProvider implements SubagentProvider { + readonly name = 'snapshot-diagnostic' + readonly capabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + private starts = 0 + + async start(request: ResolvedSubagentStartRequest) { + if (request.signal.aborted) { + throw new Error('snapshot diagnostic provider start aborted') + } + const index = this.starts++ + if (index > 1) { + throw new Error('snapshot diagnostic provider expected exactly two starts') + } + return { + id: SessionId(index === 0 + ? '00000000-0000-4000-8000-0000000000d1' + : '00000000-0000-4000-8000-0000000000d2'), + localAgent: undefined, + result: Promise.resolve({ + output: index === 0 + ? [{ type: 'text' as const, text: 'partial assistant text' }] + : [], + diagnostic: DIAGNOSTIC, + stopReason: 'error' as const, + }), + dispose: async () => {}, + } + } +} + +/** Register the fixed snapshot provider under the public product provider name. */ +export function apply(ctx: Context): void { + ctx.subagents.registerProvider(new DiagnosticProvider()) +} diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml index 45b62f880f..2e08c0036d 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml @@ -14,6 +14,8 @@ - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' + config: + permissionMode: acceptEdits - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json new file mode 100644 index 0000000000..b75f1d9580 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json new file mode 100644 index 0000000000..6fbff83b8c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json @@ -0,0 +1,42 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "PARENT_OBSERVED_DIAGNOSTICS" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "PARENT_OBSERVED_DIAGNOSTICS" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl new file mode 100644 index 0000000000..f1c5ffe374 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl @@ -0,0 +1,51 @@ +{"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":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"}]}} +{"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":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"},"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":"Use subagent_codex in the foreground","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":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}} +{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}} +{"type":"assistant/chunk","seq":12,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"92e33995-2f02-4ad5-aec1-9df82cf4d583"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}} +{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"4e84e7b3-40c1-488e-b119-45e8bd7ce448"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1786781990613,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1786781990618,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":22,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"2fb444e2-7a52-4963-988e-b1ecbc3744d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}} +{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"}]}} +{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_diagnostic_background"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"3377f724-b4a7-4ce1-bed7-774f174917d6"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":1786781990627,"data":{"turn":1,"step":2}} +{"type":"agent/inbox/spliced","seq":29,"time":1786781990627,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":30,"time":1786781990632,"data":{"turn":1,"step":3}} +{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f43f988b-bc08-4811-8671-8edc0613f0d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} +{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]"}],"isError":false}],"role":"user","id":"6785120f-ae46-48d0-9f3f-d6cd1e6fc5d7"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1786781990640,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":41,"time":1786781990645,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}} +{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}} +{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"step/end","seq":48,"time":1786781990650,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":49,"time":1786781990650,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl new file mode 100644 index 0000000000..83e4ef4368 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/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":"PARENT_OBSERVED_DIAGNOSTICS"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/knip.json b/knip.json index 3017292382..8f4b21a97f 100644 --- a/knip.json +++ b/knip.json @@ -52,6 +52,7 @@ "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-result-diagnostic.ts", "acp-agent/tests/fixtures/subagent-report-fence.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/workspace-context-compaction.ts", diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5a812806da..c312f5d30e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4127,7 +4127,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentResult', - declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}', + declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly diagnostic?: string;\n readonly stopReason: SubagentStopReason;\n}', }, { name: 'SubagentRun', diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index a165540575..f7286cee7d 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: 1a0d6e32b8610769dcc5d8342a4fe88d0c884085 -README.zh.md: 78dab14e5eaddc06ccd07b69dc952a09380e0428 +README.md: c74c092d58d7853cedee3c5f326467a5036c50fb +README.zh.md: e87f03399e6ffd926d0ea25c6f84339ba8c94d6f diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 1a0d6e32b8..c74c092d58 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, 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. +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 either the strict final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -14,9 +14,9 @@ Local cancellation wins the result race and maps to `aborted`. `dispose()` is id ## Native settings and interaction -The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. +The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks. -Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own. +Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. ## Capabilities and context @@ -27,8 +27,17 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | +| `permissionMode` | `dontAsk` | Native non-interactive permission policy fixed for every run from this Provider instance. | | `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. | +| `permissionMode` value | Native behavior | +|---|---| +| `dontAsk` | Deny operations that are not already authorized instead of prompting. | +| `acceptEdits` | Accept file edits; any remaining permission prompt is denied by the unattended callback. | +| `auto` | Let Claude Code's native classifier allow or deny permission requests. | +| `plan` | Run Claude Code in its native planning-only mode without tool execution. | +| `bypassPermissions` | Explicitly set the SDK's dangerous confirmation and bypass permission checks. | + 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. Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and mount it once on the host plane; loading the provider starts no Claude process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. @@ -39,6 +48,7 @@ The standalone composition below shows the complete explicit capability. A Profi - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' config: + permissionMode: acceptEdits env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY @@ -69,7 +79,7 @@ The project owner's identity-scoped distribution authorization covers the offici #### What the model sees -The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation. +The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd; its model, system instructions, tools, sandbox, and authentication come from the host's native Claude settings and product installation, while the Provider's Profile configuration fixes the query's non-interactive permission mode. #### Token effect @@ -83,7 +93,7 @@ Independent of the parent request cache. Reuse depends only on Claude Code's own #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session. #### Token effect @@ -99,7 +109,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **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. -- **Product payload is final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime. +- **No human interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending. +- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, while generic Job ids, notices, and status come from the shared job runtime. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 78dab14e5e..e87f03399e 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,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回严格的最终答案或安全的失败说明。 ## 启动与所有权 @@ -14,9 +14,9 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 原生设置与交互 -提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。 +提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。 -每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 ## 能力与上下文 @@ -27,8 +27,17 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | +| `permissionMode` | `dontAsk` | 为该提供方实例的每次运行固定原生非交互权限策略。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | +| `permissionMode` 值 | 原生行为 | +|---|---| +| `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 | +| `acceptEdits` | 接受文件编辑;其余权限提示由无人值守回调拒绝。 | +| `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 | +| `plan` | 使用 Claude Code 原生的仅规划模式,不执行工具。 | +| `bypassPermissions` | 显式设置 SDK 的危险确认并跳过权限检查。 | + 生产环境从子进程执行世界清除凭证后的 `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` 等普通环境变量仍会被继承。 生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Claude 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 @@ -39,6 +48,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' config: + permissionMode: acceptEdits env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY @@ -69,7 +79,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK #### 模型看到的内容 -Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。 +Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自宿主机原生 Claude 设置与产品安装,而提供方的 Profile 配置会固定该 query 的非交互权限模式。 #### 对 token 的影响 @@ -83,7 +93,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案,或者在结果未完成时看到消费方给出的原样错误。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案与状态,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -99,7 +109,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 - **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 - **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 -- **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 -- **产品载荷仅包含最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部;通用 Job id、通知与状态来自共享作业运行时。 +- **没有人工交互路径**:`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。 +- **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index ccd150b746..3894369fc6 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -18,8 +18,11 @@ import { type SubagentProvider, } from '@deepseek-ai/dsh-subagent' import { + CLAUDE_CODE_PERMISSION_MODES, + DEFAULT_CLAUDE_CODE_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, startClaudeCodeRun, + type ClaudeCodePermissionMode, type ClaudeCodeRunSpec, } from './run.ts' @@ -28,19 +31,23 @@ export const inject = ['subagents', 'subprocess'] /* jscpd:ignore-start -- sibling product providers intentionally expose the * same two deployment-owned fields without adding a shared config owner. */ -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } export const Config: z = z.object({ env: z.dict(z.string()).default({}), + permissionMode: z.union([...CLAUDE_CODE_PERMISSION_MODES]) + .default(DEFAULT_CLAUDE_CODE_PERMISSION_MODE), disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) @@ -78,6 +85,7 @@ class ClaudeCodeProvider implements SubagentProvider { parentCwd, ), executable, + permissionMode: this.config.permissionMode, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), @@ -94,10 +102,14 @@ class ClaudeCodeProvider implements SubagentProvider { /** * Register the fixed `claude-code` provider. * @param ctx - context carrying shared subagent and subprocess services. - * @param config - explicit child environment and disposal grace. + * @param config - permission mode, child environment, and disposal grace. */ export function apply(ctx: Context, config: Config): void { - const resolved = config as ResolvedConfig + const resolved: ResolvedConfig = { + env: config.env as Record, + permissionMode: config.permissionMode ?? DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + disposeGraceMs: config.disposeGraceMs as number, + } assertPositiveFinite( 'subagent-claude-code', 'disposeGraceMs', diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 6c1e0a8dbf..ffcac2bedf 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -38,6 +38,37 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Profile-selectable non-interactive Claude Code permission mode. */ +export type ClaudeCodePermissionMode = + | 'dontAsk' + | 'acceptEdits' + | 'auto' + | 'plan' + | 'bypassPermissions' + +/** Claude Code permission modes that cannot wait for a human response. */ +export const CLAUDE_CODE_PERMISSION_MODES = [ + 'dontAsk', + 'acceptEdits', + 'auto', + 'plan', + 'bypassPermissions', +] as const satisfies readonly ClaudeCodePermissionMode[] + +/** Safe default for unattended Claude Code runs. */ +export const DEFAULT_CLAUDE_CODE_PERMISSION_MODE: ClaudeCodePermissionMode = 'dontAsk' + +const SUPPORTED_UNATTENDED_DIALOG_KINDS = ['refusal_fallback_prompt'] + +function unattendedDiagnostic( + mode: ClaudeCodePermissionMode, + request: 'tool permission' | 'MCP elicitation' | 'user dialog', + decision: 'denied' | 'declined' | 'cancelled', + reason: string, +): string { + return `Claude Code unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}` +} + /* jscpd:ignore-start -- sibling providers intentionally keep product-private * run inputs and error normalization instead of adding a shared lifecycle owner. */ /** Fully resolved inputs for one official Claude Agent SDK query. */ @@ -46,6 +77,8 @@ export interface ClaudeCodeRunSpec { readonly cwd: string /** Exact native Claude Code executable resolved from the host PATH. */ readonly executable: string + /** Profile-selected native non-interactive permission mode. */ + readonly permissionMode: ClaudeCodePermissionMode /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -107,13 +140,19 @@ export function successfulResult(message: SDKResultMessage): string { * Consume the complete SDK stream and require one strict success plus normal * iterator completion. * @param query - published official SDK query. + * @param onPermissionDenied - records a safe fact when the SDK reports native denial. * @returns the completed shared result. */ export async function consumeClaudeQuery( query: AsyncIterable, + onPermissionDenied?: () => void, ): Promise { let answer: string | undefined for await (const message of query) { + if (message.type === 'system' && message.subtype === 'permission_denied') { + onPermissionDenied?.() + continue + } if (message.type !== 'result') continue answer = successfulResult(message) } @@ -172,12 +211,14 @@ export async function disposeClaudeCodeChild( * @param spec - Workspace, environment, process service, and disposal policy. * @param controller - per-run cancellation owner. * @param capture - receives the real managed child synchronously from the SDK hook. + * @param captureDiagnostic - receives safe facts from unattended interaction callbacks. * @returns options that inherit native settings while disabling persistence and user questions. */ export function claudeQueryOptions( spec: ClaudeCodeRunSpec, controller: AbortController, capture: (child: SubprocessHandle) => void, + captureDiagnostic: (diagnostic: string) => void, ): Options { return { abortController: controller, @@ -186,6 +227,42 @@ export function claudeQueryOptions( env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, disallowedTools: ['AskUserQuestion'], + permissionMode: spec.permissionMode, + ...spec.permissionMode === 'bypassPermissions' + ? { allowDangerouslySkipPermissions: true } + : { + canUseTool: () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'tool permission', + 'denied', + 'the provider does not request human approval', + )) + return Promise.resolve({ + behavior: 'deny' as const, + message: 'This unattended Claude Code subagent cannot request human approval.', + }) + }, + }, + onElicitation: () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'MCP elicitation', + 'declined', + 'the provider does not collect interactive MCP input', + )) + return Promise.resolve({ action: 'decline' }) + }, + onUserDialog: () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'user dialog', + 'cancelled', + 'the provider does not render blocking dialogs', + )) + return Promise.resolve({ behavior: 'cancelled' as const }) + }, + supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS, spawnClaudeCodeProcess: (options: SpawnOptions) => { const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs)) capture(child) @@ -220,12 +297,21 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let query: Query | undefined + let diagnostic: string | undefined + const captureDiagnostic = (value: string): void => { + diagnostic = value + } try { query = officialQuery({ prompt, - options: claudeQueryOptions(spec, controller, (captured) => { - child = captured - }), + options: claudeQueryOptions( + spec, + controller, + (captured) => { + child = captured + }, + captureDiagnostic, + ), }) if (child === undefined || child.pid <= 0) { throw new Error( @@ -258,7 +344,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') } @@ -268,8 +353,16 @@ export async function startClaudeCodeRun( const publishedQuery = query const publishedChild = child const result = settleRunResult({ - attempt: () => consumeClaudeQuery(publishedQuery), + attempt: () => consumeClaudeQuery(publishedQuery, () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'tool permission', + 'denied', + 'Claude Code denied the request before an interactive prompt', + )) + }), collectOutput: () => [], + collectDiagnostic: () => diagnostic, cancelled: () => controller.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts index d8a04cf953..accab2951b 100644 --- a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -4,6 +4,12 @@ import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'nod export type MessagesBehavior = | { readonly kind: 'complete'; readonly text: string } | { readonly kind: 'hold' } + | { + readonly kind: 'tool-use' + readonly toolName: string + readonly input: Record + readonly finalText?: string + } /** One recorded Anthropic Messages request. */ interface RecordedMessagesRequest { @@ -81,6 +87,67 @@ function complete( response.end() } +function toolUse( + response: ServerResponse, + body: Record, + toolName: string, + input: Record, +): void { + const model = typeof body.model === 'string' ? body.model : 'fixture-model' + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }) + event(response, 'message_start', { + type: 'message_start', + message: { + id: 'msg_dsh_fixture_tool_use', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 7, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + event(response, 'content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_dsh_fixture', + name: toolName, + input: {}, + }, + }) + event(response, 'content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(input), + }, + }) + event(response, 'content_block_stop', { + type: 'content_block_stop', + index: 0, + }) + event(response, 'message_delta', { + type: 'message_delta', + delta: { stop_reason: 'tool_use', stop_sequence: null }, + usage: { output_tokens: 1 }, + }) + event(response, 'message_stop', { type: 'message_stop' }) + response.end() +} + /** * Start a loopback-only Anthropic Messages SSE fixture. * @param behavior - the single response behavior for this fixture. @@ -118,6 +185,13 @@ export async function startMessagesFixture( requestStartedResolve() if (behavior.kind === 'complete') { complete(response, body, behavior.text) + } else if (behavior.kind === 'tool-use' && requests.length === 1) { + toolUse(response, body, behavior.toolName, behavior.input) + } else if ( + behavior.kind === 'tool-use' + && behavior.finalText !== undefined + ) { + complete(response, body, behavior.finalText) } // A hold deliberately leaves the response pending until client abort. }) 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 f6767817c8..cf0f837424 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -23,6 +24,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' +import type { ClaudeCodePermissionMode } from '../src/run.ts' import { startMessagesFixture, type MessagesBehavior, @@ -122,7 +124,10 @@ interface RealHarness { readonly executable: string } -async function realHarness(behavior: MessagesBehavior): Promise<{ +async function realHarness( + behavior: MessagesBehavior, + permissionMode?: ClaudeCodePermissionMode, +): Promise<{ readonly harness: RealHarness readonly fixture: MessagesFixture }> { @@ -144,7 +149,10 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ } writeFileSync( join(claudeConfig, 'settings.json'), - `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, + `${JSON.stringify({ + model: settingsModel, + permissions: { defaultMode: 'default' }, + }, null, 2)}\n`, ) const fixture = await startMessagesFixture(behavior) fixtures.push(fixture) @@ -177,7 +185,11 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ handles.push(handle) return handle }) - await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 }) + await ctx.plugin(claudeCode, { + env, + ...permissionMode === undefined ? {} : { permissionMode }, + disposeGraceMs: 3_000, + }) const parent = { id: 'real-parent', session: { header: { cwd: workspace } }, @@ -294,6 +306,61 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 await expectQuiescent(harness.handles) }) + it('overrides interactive settings, denies a write, and returns a safe diagnostic', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-denied-target-')) + roots.push(root) + const target = join(root, 'denied.txt') + const { harness } = await realHarness({ + kind: 'tool-use', + toolName: 'Write', + input: { + file_path: target, + content: 'SECRET_TOKEN must not reach the diagnostic', + }, + }) + const run = await startRequest(harness, 'Write the requested fixture file.') + await vi.waitFor(() => { + expect(observedSdkMessages.some(message => + message.type === 'system' + && message.subtype === 'permission_denied')).toBe(true) + }, { timeout: 30_000 }) + expect(existsSync(target)).toBe(false) + harness.handles[0]!.terminate() + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain(target) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + await run.dispose() + await expectQuiescent(harness.handles) + }) + + it('runs an explicitly selected bypass write in the isolated workspace', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-bypass-target-')) + roots.push(root) + const target = join(root, 'bypass.txt') + const { harness } = await realHarness({ + kind: 'tool-use', + toolName: 'Write', + input: { + file_path: target, + content: 'bypass write completed', + }, + finalText: 'write complete', + }, 'bypassPermissions') + const run = await startRequest(harness, 'Write the requested fixture file.') + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'write complete' }], + stopReason: 'completed', + }) + expect(readFileSync(target, 'utf8')).toBe('bypass write completed') + await run.dispose() + await expectQuiescent(harness.handles) + }) + it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => { const { harness, fixture } = await realHarness({ kind: 'hold' }) const controller = new AbortController() 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 a3df59a74f..595b9386e7 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 @@ -3,6 +3,7 @@ import type { Options, Query, SDKMessage, + SDKPermissionDeniedMessage, SDKResultMessage, SpawnOptions, } from '@anthropic-ai/claude-agent-sdk' @@ -36,6 +37,8 @@ import { sdkEnvironmentOverlay, } from '../src/process.ts' import { + CLAUDE_CODE_PERMISSION_MODES, + DEFAULT_CLAUDE_CODE_PERMISSION_MODE, claudeQueryOptions, consumeClaudeQuery, disposeClaudeCodeChild, @@ -189,6 +192,20 @@ function failure( } as SDKResultMessage } +function permissionDenied(): SDKPermissionDeniedMessage { + return { + type: 'system', + subtype: 'permission_denied', + tool_name: 'Bash', + tool_use_id: 'tool-secret', + decision_reason_type: 'mode', + decision_reason: 'contains /private/secret.txt', + message: 'command with SECRET_TOKEN was denied', + uuid: '00000000-0000-4000-8000-000000000001', + session_id: 'session-secret', + } +} + function queryFrom( messages: readonly SDKMessage[], after?: Error, @@ -249,6 +266,7 @@ function fakeRun( const spec: ClaudeCodeRunSpec = { cwd: '/workspace', executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: { ANTHROPIC_API_KEY: 'fake-key' }, disposeGraceMs: 5, spawn: (spawnSpec) => { @@ -325,6 +343,27 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('accepts only the five fixed non-interactive permission modes', () => { + expect(claudeCode.Config({}).permissionMode) + .toBe(DEFAULT_CLAUDE_CODE_PERMISSION_MODE) + for (const permissionMode of CLAUDE_CODE_PERMISSION_MODES) { + expect(claudeCode.Config({ permissionMode }).permissionMode) + .toBe(permissionMode) + } + for (const permissionMode of ['default', 'interactive', 'future-mode']) { + expect(() => claudeCode.Config({ permissionMode } as never)).toThrow() + } + }) + + it('resolves the safe permission default when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) + expect(ctx.subagents.getProvider('claude-code')).toBeDefined() + await ctx.fiber.dispose() + }) + it('starts through the registered provider with its resolved config and diagnostics', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) @@ -341,6 +380,7 @@ describe('task admission and package contracts', () => { CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config', HOME: '/private/tmp/dsh-claude-code-unit-home', }, + permissionMode: 'auto', disposeGraceMs: 29, }) @@ -377,6 +417,7 @@ describe('task admission and package contracts', () => { ) expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable) .toBe('/native/claude') + expect(queryMock.mock.calls[0]?.[0].options.permissionMode).toBe('auto') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: process.cwd(), graceMs: 29, @@ -530,16 +571,18 @@ describe('official spawn projection', () => { }) describe('query options and result mapping', () => { - it('builds the fixed unattended options over the scrubbed environment', () => { + it('builds the fixed unattended options over the scrubbed environment', async () => { vi.stubEnv('HOST_VISIBLE', 'visible') vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak') vi.stubEnv('DSH_INTERNAL', 'must-not-leak') const child = fakeChild() const spawn = vi.fn(() => child.handle) const captured: SubprocessHandle[] = [] + const diagnostics: string[] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', executable: '/native/claude', + permissionMode: 'acceptEdits', env: { HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', @@ -548,9 +591,14 @@ describe('query options and result mapping', () => { spawn, } const controller = new AbortController() - const options = claudeQueryOptions(spec, controller, (value) => { - captured.push(value) - }) + const options = claudeQueryOptions( + spec, + controller, + (value) => { + captured.push(value) + }, + value => diagnostics.push(value), + ) expect(options).toMatchObject({ abortController: controller, @@ -558,22 +606,55 @@ describe('query options and result mapping', () => { pathToClaudeCodeExecutable: '/native/claude', persistSession: false, disallowedTools: ['AskUserQuestion'], + permissionMode: 'acceptEdits', + supportedDialogKinds: ['refusal_fallback_prompt'], }) + expect(options).not.toHaveProperty('allowDangerouslySkipPermissions') expect(options.env).toMatchObject({ HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', }) expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN') expect(options.env).not.toHaveProperty('DSH_INTERNAL') - for (const omitted of [ - 'settingSources', - 'canUseTool', - 'onElicitation', - 'onUserDialog', - 'supportedDialogKinds', - ]) { - expect(options).not.toHaveProperty(omitted) - } + expect(options).not.toHaveProperty('settingSources') + + const callbackSignal = new AbortController().signal + await expect(options.canUseTool!( + 'Bash', + { command: 'cat /private/secret.txt', token: 'SECRET_TOKEN' }, + { + signal: callbackSignal, + toolUseID: 'tool-1', + requestId: 'request-1', + blockedPath: '/private/secret.txt', + decisionReason: 'SECRET_TOKEN in /private/secret.txt', + }, + )).resolves.toEqual({ + behavior: 'deny', + message: 'This unattended Claude Code subagent cannot request human approval.', + }) + await expect(options.onElicitation!( + { + serverName: 'private-server', + message: 'enter SECRET_TOKEN', + requestedSchema: { secret: true }, + }, + { signal: callbackSignal }, + )).resolves.toEqual({ action: 'decline' }) + await expect(options.onUserDialog!( + { + dialogKind: 'refusal_fallback_prompt', + payload: { path: '/private/secret.txt', token: 'SECRET_TOKEN' }, + }, + { signal: callbackSignal }, + )).resolves.toEqual({ behavior: 'cancelled' }) + expect(diagnostics).toEqual([ + 'Claude Code unattended decision (mode: acceptEdits; request: tool permission; decision: denied): the provider does not request human approval', + 'Claude Code unattended decision (mode: acceptEdits; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', + 'Claude Code unattended decision (mode: acceptEdits; request: user dialog; decision: cancelled): the provider does not render blocking dialogs', + ]) + expect(diagnostics.join('\n')).not.toContain('SECRET_TOKEN') + expect(diagnostics.join('\n')).not.toContain('/private/secret.txt') const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions()) expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess) @@ -585,6 +666,29 @@ describe('query options and result mapping', () => { })) }) + it.each(CLAUDE_CODE_PERMISSION_MODES)( + 'maps the %s mode and only confirms the dangerous bypass', + (permissionMode) => { + const child = fakeChild() + const options = claudeQueryOptions({ + cwd: '/workspace', + executable: '/native/claude', + permissionMode, + env: {}, + disposeGraceMs: 17, + spawn: () => child.handle, + }, new AbortController(), () => {}, () => {}) + expect(options.permissionMode).toBe(permissionMode) + if (permissionMode === 'bypassPermissions') { + expect(options.allowDangerouslySkipPermissions).toBe(true) + expect(options).not.toHaveProperty('canUseTool') + } else { + expect(options).not.toHaveProperty('allowDangerouslySkipPermissions') + expect(options.canUseTool).toBeTypeOf('function') + } + }, + ) + it('accepts only a non-error success with a non-blank final result', () => { expect(successfulResult(success('exact final'))).toBe('exact final') expect(() => successfulResult(success('answer', true))) @@ -614,6 +718,16 @@ describe('query options and result mapping', () => { await expect(consumeClaudeQuery( queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), )).rejects.toThrow('ended without a result') + + const onPermissionDenied = vi.fn() + await expect(consumeClaudeQuery(queryFrom([ + permissionDenied(), + success('after denial'), + ]), onPermissionDenied)).resolves.toEqual({ + output: [{ type: 'text', text: 'after denial' }], + stopReason: 'completed', + }) + expect(onPermissionDenied).toHaveBeenCalledOnce() }) }) @@ -667,6 +781,62 @@ describe('run publication, cancellation, and settlement', () => { } }) + it('attaches a safe diagnostic when a permission denial precedes failure', async () => { + const fixture = fakeRun([ + permissionDenied(), + failure('error_during_execution'), + ]) + const run = await startClaudeCodeRun(request(), fixture.spec) + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + expect(result.diagnostic).not.toContain('/private/secret.txt') + await run.dispose() + }) + + it('omits captured diagnostics on success and isolates concurrent runs', async () => { + const children = [fakeChild(), fakeChild()] + let childIndex = 0 + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + executable: '/native/claude', + permissionMode: 'dontAsk', + env: {}, + disposeGraceMs: 5, + spawn: () => children[childIndex++]!.handle, + } + queryMock.mockImplementation(({ prompt, options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return prompt === 'denied then completed' + ? queryFrom([permissionDenied(), success('completed answer')]) + : queryFrom([failure('error_during_execution')]) + }) + + const [completed, failed] = await Promise.all([ + startClaudeCodeRun( + request([{ type: 'text', text: 'denied then completed' }]), + spec, + ), + startClaudeCodeRun( + request([{ type: 'text', text: 'unrelated failure' }]), + spec, + ), + ]) + await expect(completed.result).resolves.toEqual({ + output: [{ type: 'text', text: 'completed answer' }], + stopReason: 'completed', + }) + await expect(failed.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + await Promise.all([completed.dispose(), failed.dispose()]) + }) + it('fails closed when iteration rejects after a result', async () => { const fixture = fakeRun( [success('partial final')], @@ -704,6 +874,7 @@ describe('run publication, cancellation, and settlement', () => { const spec: ClaudeCodeRunSpec = { cwd: '/workspace', executable: '/native/claude', + permissionMode: 'dontAsk', env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, @@ -755,6 +926,7 @@ describe('run publication, cancellation, and settlement', () => { { cwd: '/workspace', executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: {}, disposeGraceMs: 5, spawn: () => child.handle, diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 6d443fc9ba..6d136cf2d0 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: ed4a9123a2dfa5b2fa5abc67f4513547feb3d140 -README.zh.md: 3ad2ee5738a210a776d1f0b2746dcbd21d46144c +README.md: 161159264ffadf32cc769d65f19caf6d74dc862d +README.zh.md: 561206ae56684329ca54b1a524b224a73e4f30b3 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index ed4a9123a2..161159264f 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -64,7 +64,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat `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`. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract). +`SubagentRun.result` resolves to `{ output, structured?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal result contract). A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 3ad2ee5738..561206ae56 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -64,7 +64,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且在任何失败路径上都必须取消、回滚并使尚未发布的资源完全停稳。兑现后,run 的所有权转移给调用方;调用方必须在每条路径上调用 `dispose()`。剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index d049dba2be..3da8c3bd28 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -16,6 +16,31 @@ import { isAbsolute, resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from './types.ts' +/** Maximum UTF-8 size of {@link SubagentResult.diagnostic}. */ +export const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 + +const DIAGNOSTIC_TRUNCATION_SUFFIX = '\n[diagnostic truncated]' +const utf8Encoder = new TextEncoder() +const utf8Decoder = new TextDecoder() + +/** + * Limit provider-authored failure detail without splitting a UTF-8 sequence. + * @param diagnostic - safe diagnostic text produced by the provider. + * @returns the original text, or a visibly truncated value within the limit. + */ +export function limitSubagentDiagnostic(diagnostic: string): string { + const bytes = utf8Encoder.encode(diagnostic) + if (bytes.byteLength <= MAX_SUBAGENT_DIAGNOSTIC_BYTES) return diagnostic + + const suffixBytes = utf8Encoder.encode(DIAGNOSTIC_TRUNCATION_SUFFIX).byteLength + let prefixBytes = MAX_SUBAGENT_DIAGNOSTIC_BYTES - suffixBytes + while (((bytes[prefixBytes] as number) & 0b1100_0000) === 0b1000_0000) { + prefixBytes -= 1 + } + return utf8Decoder.decode(bytes.subarray(0, prefixBytes)) + + DIAGNOSTIC_TRUNCATION_SUFFIX +} + /** * The capability advertisement of an out-of-process backend: NONE. A child in * another process cannot honor parent-enforced start features @@ -134,6 +159,8 @@ export interface RunResultSettlement { attempt: () => Promise /** Snapshot the provider exposes when cancellation or failure wins settlement. */ collectOutput: () => ContentBlock[] + /** Snapshot safe provider-authored detail when a failure wins settlement. */ + collectDiagnostic?: (() => string | undefined) | undefined /** Whether local cancellation settled before the attempt's outcome is observed. */ cancelled: () => boolean /** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */ @@ -168,7 +195,20 @@ export async function settleRunResult(parts: RunResultSettlement): Promise { it.each([ @@ -62,4 +67,56 @@ describe('outcome mapping helpers', () => { detail: 'Error: result failed; dispose failed: Error: reap failed', }) }) + + it('keeps provider diagnostics separate in failed background outcomes', async () => { + await expect(settleRun({ + id: SessionId('child-diagnostic'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'partial assistant text' }], + diagnostic: 'Claude Code denied a tool request', + stopReason: 'error', + }), + dispose: () => Promise.resolve(), + })).resolves.toEqual({ + status: 'failed', + detail: 'error; diagnostic: Claude Code denied a tool request', + }) + }) + + it('bounds multibyte diagnostics and marks truncation', async () => { + const exact = 'x'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + expect(limitSubagentDiagnostic(exact)).toBe(exact) + + const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + const limited = limitSubagentDiagnostic(oversized) + expect(Buffer.byteLength(limited, 'utf8')) + .toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + expect(limited.endsWith('[diagnostic truncated]')).toBe(true) + expect(limited).not.toContain('\uFFFD') + + const controller = new AbortController() + const result = await settleRunResult({ + attempt: async () => { throw new Error('provider failed') }, + collectOutput: () => [], + collectDiagnostic: () => oversized, + cancelled: () => false, + signal: controller.signal, + onAbort: () => {}, + }) + expect(result.stopReason).toBe('error') + expect(result.diagnostic).toBe(limited) + + await expect(settleRunResult({ + attempt: async () => { throw new Error('provider failed') }, + collectOutput: () => [{ type: 'text', text: 'partial' }], + collectDiagnostic: () => { throw new Error('collector failed') }, + cancelled: () => false, + signal: controller.signal, + onAbort: () => {}, + })).resolves.toEqual({ + output: [{ type: 'text', text: 'partial' }], + stopReason: 'error', + }) + }) }) diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 41f712ba34..b5e6ebf724 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-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/tool-subagent/README.md -README.md: 9d7ed2e364f6a9dff26a1c9006535f898bdaabcc -README.zh.md: 8650ee35588c2615e4d6c016cb672030ee2e8194 +README.md: 28e6213b903ffffa7934e244b2a74ada519b32b2 +README.zh.md: deae0f0ff9e19b627a04704eccf4b8874f34068f diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 9d7ed2e364..28e6213b90 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -8,9 +8,9 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. -A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics. +A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text. Abort, refusal, token limit, and other failures become errored tool results whose message contains the stop-reason headline, an optional provider-authored `SubagentResult.diagnostic`, and then any preserved partial assistant text. The diagnostic remains separate from `SubagentResult.output`, so a truncated answer is never reported as success or confused with infrastructure detail. If result collection and disposal both reject, the errored result preserves both failures. -`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', jobId }`, rendered as `started background subagent job `, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md). +`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', jobId }`, rendered as `started background subagent job `, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices; a failed Task keeps the stop reason and the same optional provider diagnostic in its detail. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -51,7 +51,7 @@ Prefix-stable while provider instances, names, descriptions, and schemas are unc #### What the model sees -The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: `. Intermediate child steps stay out of the parent. +The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: `, followed by a safe provider diagnostic when present and then any partial assistant text. Intermediate child steps stay out of the parent. #### Token effect @@ -65,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent job ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output. +Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent job ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices; failed status detail includes the provider diagnostic when the result supplied one. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 8650ee3558..deae0f0ff9 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -8,9 +8,9 @@ 每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 -前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子 agent 保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 +前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本。中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息依次包含终止原因标题、可选的提供方 `SubagentResult.diagnostic`,以及子 agent 保留下来的部分 assistant 文本。诊断与 `SubagentResult.output` 保持分离,因此被截断的回答不会被报告为成功,也不会与基础设施说明混淆。如果结果收集与 dispose(资源释放)都 reject,出错结果会保留两项失败。 -`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job `,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。 +`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job `,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知;失败 Task 的 detail 会保留终止原因与同一份可选提供方诊断。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -51,7 +51,7 @@ #### 模型看到的内容 -调用会保留描述和提示词。成功时只包含子 agent 的最终文本;其他结果变为 `Error: `。子 agent 中间步骤不会进入父级。 +调用会保留描述和提示词。成功时只包含子 agent 的最终文本;其他结果会变为 `Error: <终止原因>`,随后在存在时附上安全的提供方诊断,再附上任何部分 assistant 文本。子 agent 中间步骤不会进入父级。 #### Token 影响 @@ -65,7 +65,7 @@ #### 模型看到的内容 -在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent job `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。 +在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent job `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知;若结果携带提供方诊断,失败状态的 detail 会包含它。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 711ae5a7f4..86d00c6d0b 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -142,18 +142,25 @@ function stopReasonError(result: SubagentResult): string | undefined { } /** - * Append the child's preserved partial answer to a stop-reason error so a - * truncated or cancelled child's real text still reaches the parent model. + * Append provider-authored failure detail and the child's preserved partial + * answer to a stop-reason error, keeping diagnostic text separate from the + * child's assistant output. * @param error - the stop-reason headline. - * @param output - the child's selected output (`SubagentResult.output`). - * @returns the headline, extended with the partial text when any exists. + * @param result - the child's terminal result. + * @returns the headline, diagnostic, and partial text that are present. */ -function withPartialText(error: string, output: ContentBlock[]): string { - const text = output +function withDiagnosticAndPartialText(error: string, result: SubagentResult): string { + const diagnostic = result.diagnostic === undefined + ? '' + : `\nDiagnostic: ${result.diagnostic}` + const text = result.output .filter((block): block is Extract => block.type === 'text') .map(block => block.text) .join('') - return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}` + const partial = text.length === 0 + ? '' + : `\nPartial output before the run ended:\n${text}` + return `${error}${diagnostic}${partial}` } type ForegroundToolResult = { @@ -173,7 +180,7 @@ async function settleForegroundRun(run: SubagentRun): Promise { expect(text(result)).toContain('scripted subagent reply') }) + it('renders provider diagnostics before preserved partial assistant output', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + ctx.subagents.registerProvider({ + name: 'diagnostic', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => ({ + id: SessionId('diagnostic-child'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'partial assistant text' }], + diagnostic: 'Claude Code denied a tool request', + stopReason: 'error', + }), + dispose: async () => {}, + }), + }) + await ctx.plugin(tool, { provider: 'diagnostic', maxDepth: 'provider-managed' }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toBe( + 'Error: subagent run failed\n' + + 'Diagnostic: Claude Code denied a tool request\n' + + 'Partial output before the run ended:\npartial assistant text', + ) + }) + it('registers under a configurable toolName so multiple providers can coexist', async () => { // The defining multi-provider use case: two loads, two distinct tool names, // each bound to a different provider — the tool registry rejects duplicate @@ -852,6 +883,53 @@ describe('dsh-tool-subagent background mode', () => { expect(text(again)).toBe('background answer\n[status: completed]') }) + it('preserves provider diagnostics in one-shot background failure detail', async () => { + const ctx = await backgroundSetup({ provider: 'mock' }) + const parent = ownerAgent(ctx, 'sess-parent') + ctx.subagents.registerProvider({ + name: 'diagnostic-background', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => ({ + id: SessionId('diagnostic-background-child'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'not background output' }], + diagnostic: 'Claude Code cancelled an unattended dialog', + stopReason: 'error', + }), + dispose: async () => {}, + }), + }) + tool.apply(ctx, { + provider: 'diagnostic-background', + toolName: 'subagent_diagnostic_background', + backgroundMode: 'one-shot', + maxDepth: 'provider-managed', + }) + + const started = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('diagnostic-background-start'), + name: 'subagent_diagnostic_background', + arguments: { description: 'd', prompt: 'p', run_in_background: true }, + agent: parent, + }) + expect(text(started)).toBe('started background subagent job subagent-1') + + const output = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('diagnostic-background-output'), + name: 'job_output', + arguments: { job_id: 'subagent-1', wait: true }, + agent: parent, + }) + expect(text(output)).toBe( + '(no new output)\n' + + '[status: failed, error; diagnostic: Claude Code cancelled an unattended dialog]', + ) + }) + it('fails loud when the tasks runtime is not loaded', async () => { const ctx = await setup({ provider: 'mock' }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }) From 62da706b64b15b31a0a960d8987d6cc9d93950d3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 17:46:13 +0800 Subject: [PATCH 24/95] fix(subagent): address Claude permission review findings --- ...agent-noninteractive-permissions.i18n.yaml | 4 +- ...uct-subagent-noninteractive-permissions.md | 4 +- ...-subagent-noninteractive-permissions.zh.md | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 14 ++--- docs/config-catalog.zh.md | 14 ++--- examples/acp-agent/tests/acp.snapshot.ts | 5 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 4 +- .../subagent-claude-code/README.zh.md | 4 +- .../subagent-claude-code/src/index.ts | 7 ++- .../subagent/subagent-claude-code/src/run.ts | 34 +++++++----- .../tests/real-product.spec.ts | 17 ++++++ .../tests/subagent-claude-code.spec.ts | 28 ++++++++++ .../subagent/subagent/src/out-of-process.ts | 17 +++--- .../subagent/tests/run-settlement.spec.ts | 39 ++++++-------- .../tool-subagent/tests/scripted-provider.ts | 18 +++++-- .../tool-subagent/tests/tool-subagent.spec.ts | 52 ++++--------------- 18 files changed, 148 insertions(+), 125 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 42e26c2f7e..477c20bdc4 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: f382bc7ad058fefd8001da6181824fc9b6f767d4 -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 76cf53c7c9af791db6e54a8b779a7284187d0716 +2026-08-15-product-subagent-noninteractive-permissions.md: d4d29d982e5eb2a06f7cb710860ce72c506c4ade +2026-08-15-product-subagent-noninteractive-permissions.zh.md: 3431465e6240e169dd8d240628d651348ac029b7 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index f382bc7ad0..d4d29d982e 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -19,12 +19,12 @@ The Claude Code Provider owns one Profile-level `permissionMode` value. It defau | `dontAsk` | Deny operations that are not already authorized instead of prompting. | | `acceptEdits` | Accept edits; deny any remaining permission prompt through the unattended callback. | | `auto` | Let Claude Code's native classifier allow or deny permission requests. | -| `plan` | Use Claude Code's planning-only mode without tool execution. | +| `plan` | Use planning mode, deny execution approval, and return the completed plan as the final answer. | | `bypassPermissions` | Set the SDK's explicit dangerous confirmation and bypass permission checks. | The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. -Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. +Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; in plan mode, `ExitPlanMode` receives a fixed denial that tells the model to return the completed plan without executing it. MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. ### Failure diagnostic diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 76cf53c7c9..3431465e62 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -19,12 +19,12 @@ Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认 | `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 | | `acceptEdits` | 接受编辑;其余权限提示由无人值守回调拒绝。 | | `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 | -| `plan` | 使用 Claude Code 的仅规划模式,不执行工具。 | +| `plan` | 使用规划模式,拒绝执行审批,并把完整计划作为最终答案返回。 | | `bypassPermissions` | 设置 SDK 的显式危险确认并跳过权限检查。 | 提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 -每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 +每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;在 plan 模式下,`ExitPlanMode` 会收到一项固定拒绝,要求模型返回完整计划且不得执行。MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 ### 失败诊断 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 921934f9ac..afd9934e6c 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: 8294c2187f2b80fbf36787c784ad8b73a16206c1 -config-catalog.zh.md: f35392a5b005212067c9b593b7fa2818202466dd +config-catalog.md: 1c78a854366e9bcd4633c56fcf31f0c6a55cefb1 +config-catalog.zh.md: cb70da0ede446aabfada3e93bc3d23c73ccb8271 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8294c2187f..1c78a85436 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2088,19 +2088,19 @@ export interface Config { * credential-scrubbed parent environment. */ env?: Record - /** Native non-interactive permission mode fixed for this Provider instance. */ + /** + * Native non-interactive mode fixed for this Provider instance. Defaults to + * `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier, + * `plan` returns a plan without approving execution, and + * `bypassPermissions` explicitly skips permission checks. + */ permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } /** Profile-selectable non-interactive Claude Code permission mode. */ -export type ClaudeCodePermissionMode = - | 'dontAsk' - | 'acceptEdits' - | 'auto' - | 'plan' - | 'bypassPermissions' +export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f35392a5b0..cb70da0ede 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2090,19 +2090,19 @@ export interface Config { * credential-scrubbed parent environment. */ env?: Record - /** Native non-interactive permission mode fixed for this Provider instance. */ + /** + * Native non-interactive mode fixed for this Provider instance. Defaults to + * `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier, + * `plan` returns a plan without approving execution, and + * `bypassPermissions` explicitly skips permission checks. + */ permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } /** Profile-selectable non-interactive Claude Code permission mode. */ -export type ClaudeCodePermissionMode = - | 'dontAsk' - | 'acceptEdits' - | 'auto' - | 'plan' - | 'bypassPermissions' +export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` 来源:[`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 0ee4fc179c..e56029545f 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -148,7 +148,7 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, pinsHeader: true, - headerClass: 'product-subagent-result-diagnostic', + headerClass: 'product-subagent-codex', configPath: PRODUCT_SUBAGENT_CODEX_CONFIG, }, { @@ -165,10 +165,7 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, overridden: true, - pinsHeader: true, headerClass: 'product-subagent-codex', - systemPromptSource: 'product-subagent-codex', - toolSchemasSource: 'product-subagent-codex', configPath: PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG, }, { diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index f7286cee7d..28fc01c965 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: c74c092d58d7853cedee3c5f326467a5036c50fb -README.zh.md: e87f03399e6ffd926d0ea25c6f84339ba8c94d6f +README.md: e7c5debddfdc740802d7bc25c2a863c7de287d07 +README.zh.md: 9e68b5f3f3824ffc3913fdba159c95b5c94353c9 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index c74c092d58..e7c5debddf 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -16,7 +16,7 @@ Local cancellation wins the result race and maps to `aborted`. `dispose()` is id The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks. -Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. +Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. In plan mode, the `ExitPlanMode` approval is denied with a fixed instruction to return the completed plan as the final answer without executing it. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. ## Capabilities and context @@ -35,7 +35,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `dontAsk` | Deny operations that are not already authorized instead of prompting. | | `acceptEdits` | Accept file edits; any remaining permission prompt is denied by the unattended callback. | | `auto` | Let Claude Code's native classifier allow or deny permission requests. | -| `plan` | Run Claude Code in its native planning-only mode without tool execution. | +| `plan` | Run in native planning mode, deny execution approval, and return the completed plan as the final answer. | | `bypassPermissions` | Explicitly set the SDK's dangerous confirmation and bypass permission checks. | 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. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index e87f03399e..9e68b5f3f3 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -16,7 +16,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。 -每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。在 plan 模式下,`ExitPlanMode` 审批会被拒绝,同时用固定指令要求模型把完整计划作为最终答案返回且不得执行。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 ## 能力与上下文 @@ -35,7 +35,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 | | `acceptEdits` | 接受文件编辑;其余权限提示由无人值守回调拒绝。 | | `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 | -| `plan` | 使用 Claude Code 原生的仅规划模式,不执行工具。 | +| `plan` | 使用原生规划模式,拒绝执行审批,并把完整计划作为最终答案返回。 | | `bypassPermissions` | 显式设置 SDK 的危险确认并跳过权限检查。 | 生产环境从子进程执行世界清除凭证后的 `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` 等普通环境变量仍会被继承。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 3894369fc6..4960e54def 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -38,7 +38,12 @@ export interface Config { * credential-scrubbed parent environment. */ env?: Record - /** Native non-interactive permission mode fixed for this Provider instance. */ + /** + * Native non-interactive mode fixed for this Provider instance. Defaults to + * `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier, + * `plan` returns a plan without approving execution, and + * `bypassPermissions` explicitly skips permission checks. + */ permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index ffcac2bedf..0134c09086 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -38,14 +38,6 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Profile-selectable non-interactive Claude Code permission mode. */ -export type ClaudeCodePermissionMode = - | 'dontAsk' - | 'acceptEdits' - | 'auto' - | 'plan' - | 'bypassPermissions' - /** Claude Code permission modes that cannot wait for a human response. */ export const CLAUDE_CODE_PERMISSION_MODES = [ 'dontAsk', @@ -53,16 +45,21 @@ export const CLAUDE_CODE_PERMISSION_MODES = [ 'auto', 'plan', 'bypassPermissions', -] as const satisfies readonly ClaudeCodePermissionMode[] +] as const satisfies readonly NonNullable[] + +/** Profile-selectable non-interactive Claude Code permission mode. */ +export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] /** Safe default for unattended Claude Code runs. */ export const DEFAULT_CLAUDE_CODE_PERMISSION_MODE: ClaudeCodePermissionMode = 'dontAsk' -const SUPPORTED_UNATTENDED_DIALOG_KINDS = ['refusal_fallback_prompt'] +const SUPPORTED_UNATTENDED_DIALOG_KINDS = [ + 'refusal_fallback_prompt', +] satisfies NonNullable function unattendedDiagnostic( mode: ClaudeCodePermissionMode, - request: 'tool permission' | 'MCP elicitation' | 'user dialog', + request: 'tool permission' | 'plan approval' | 'MCP elicitation' | 'user dialog', decision: 'denied' | 'declined' | 'cancelled', reason: string, ): string { @@ -231,7 +228,19 @@ export function claudeQueryOptions( ...spec.permissionMode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : { - canUseTool: () => { + canUseTool: (toolName) => { + if (spec.permissionMode === 'plan' && toolName === 'ExitPlanMode') { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'plan approval', + 'denied', + 'the provider returns the plan without approving execution', + )) + return Promise.resolve({ + behavior: 'deny' as const, + message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.', + }) + } captureDiagnostic(unattendedDiagnostic( spec.permissionMode, 'tool permission', @@ -344,6 +353,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') } 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 cf0f837424..c97f18481f 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -361,6 +361,23 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 await expectQuiescent(harness.handles) }) + it('returns the completed plan without approving execution', async () => { + const { harness, fixture } = await realHarness({ + kind: 'tool-use', + toolName: 'ExitPlanMode', + input: {}, + finalText: 'PLAN_ONLY_RESULT', + }, 'plan') + const run = await startRequest(harness, 'Design the fixture change without implementing it.') + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'PLAN_ONLY_RESULT' }], + stopReason: 'completed', + }) + expect(fixture.requests).toHaveLength(2) + await run.dispose() + await expectQuiescent(harness.handles) + }) + it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => { const { harness, fixture } = await realHarness({ kind: 'hold' }) const controller = new AbortController() 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 595b9386e7..f0e1f4a1e1 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 @@ -689,6 +689,34 @@ describe('query options and result mapping', () => { }, ) + it('returns a plan without approving ExitPlanMode execution', async () => { + const child = fakeChild() + const diagnostics: string[] = [] + const options = claudeQueryOptions({ + cwd: '/workspace', + executable: '/native/claude', + permissionMode: 'plan', + env: {}, + disposeGraceMs: 17, + spawn: () => child.handle, + }, new AbortController(), () => {}, value => diagnostics.push(value)) + await expect(options.canUseTool!( + 'ExitPlanMode', + {}, + { + signal: new AbortController().signal, + toolUseID: 'exit-plan', + requestId: 'exit-plan-request', + }, + )).resolves.toEqual({ + behavior: 'deny', + message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.', + }) + expect(diagnostics).toEqual([ + 'Claude Code unattended decision (mode: plan; request: plan approval; decision: denied): the provider returns the plan without approving execution', + ]) + }) + it('accepts only a non-error success with a non-blank final result', () => { expect(successfulResult(success('exact final'))).toBe('exact final') expect(() => successfulResult(success('answer', true))) diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index 3da8c3bd28..abb6dd50e7 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -17,7 +17,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from './types.ts' /** Maximum UTF-8 size of {@link SubagentResult.diagnostic}. */ -export const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 +const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 const DIAGNOSTIC_TRUNCATION_SUFFIX = '\n[diagnostic truncated]' const utf8Encoder = new TextEncoder() @@ -28,7 +28,7 @@ const utf8Decoder = new TextDecoder() * @param diagnostic - safe diagnostic text produced by the provider. * @returns the original text, or a visibly truncated value within the limit. */ -export function limitSubagentDiagnostic(diagnostic: string): string { +function limitSubagentDiagnostic(diagnostic: string): string { const bytes = utf8Encoder.encode(diagnostic) if (bytes.byteLength <= MAX_SUBAGENT_DIAGNOSTIC_BYTES) return diagnostic @@ -195,15 +195,10 @@ export async function settleRunResult(parts: RunResultSettlement): Promise { it.each([ ['completed', { status: 'completed', output: 'partial' }], @@ -86,16 +86,18 @@ describe('outcome mapping helpers', () => { it('bounds multibyte diagnostics and marks truncation', async () => { const exact = 'x'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) - expect(limitSubagentDiagnostic(exact)).toBe(exact) - const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) - const limited = limitSubagentDiagnostic(oversized) - expect(Buffer.byteLength(limited, 'utf8')) - .toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES) - expect(limited.endsWith('[diagnostic truncated]')).toBe(true) - expect(limited).not.toContain('\uFFFD') - const controller = new AbortController() + const exactResult = await settleRunResult({ + attempt: async () => { throw new Error('provider failed') }, + collectOutput: () => [], + collectDiagnostic: () => exact, + cancelled: () => false, + signal: controller.signal, + onAbort: () => {}, + }) + expect(exactResult.diagnostic).toBe(exact) + const result = await settleRunResult({ attempt: async () => { throw new Error('provider failed') }, collectOutput: () => [], @@ -104,19 +106,12 @@ describe('outcome mapping helpers', () => { signal: controller.signal, onAbort: () => {}, }) + const limited = result.diagnostic ?? '' + expect(Buffer.byteLength(limited, 'utf8')) + .toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + expect(limited.endsWith('[diagnostic truncated]')).toBe(true) + expect(limited).not.toContain('\uFFFD') expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe(limited) - - await expect(settleRunResult({ - attempt: async () => { throw new Error('provider failed') }, - collectOutput: () => [{ type: 'text', text: 'partial' }], - collectDiagnostic: () => { throw new Error('collector failed') }, - cancelled: () => false, - signal: controller.signal, - onAbort: () => {}, - })).resolves.toEqual({ - output: [{ type: 'text', text: 'partial' }], - stopReason: 'error', - }) }) }) diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index 0be724a8bb..c0da403cd4 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -27,6 +27,8 @@ export interface Config { reply?: string /** Terminal result reason. */ stopReason?: SubagentStopReason + /** Safe non-assistant detail for a non-completed result. */ + diagnostic?: string /** Start-time features advertised by the provider. */ capabilities?: Partial /** Whether tool descriptions say the child inherits completed turns. */ @@ -65,11 +67,17 @@ class ScriptedSubagentProvider implements SubagentProvider { throw new Error('scripted subagent start aborted before publication') } - const resultFor = (): SubagentResult => ({ - output, - ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, - stopReason: state.cancelled ? 'aborted' : stopReason, - }) + const resultFor = (): SubagentResult => { + const terminal = state.cancelled ? 'aborted' : stopReason + return { + output, + ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, + ...this.config.diagnostic !== undefined && terminal !== 'completed' + ? { diagnostic: this.config.diagnostic } + : {}, + stopReason: terminal, + } + } const gate = Promise.resolve(this.config.onStart?.(request)) const result = gate.then(() => new Promise((resolve) => { setTimeout(() => { resolve(resultFor()) }, 0) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index d9ff83c080..1ee5e40228 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -186,26 +186,11 @@ describe('dsh-tool-subagent', () => { }) it('renders provider diagnostics before preserved partial assistant output', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - ctx.subagents.registerProvider({ - name: 'diagnostic', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: async () => ({ - id: SessionId('diagnostic-child'), - localAgent: undefined, - result: Promise.resolve({ - output: [{ type: 'text', text: 'partial assistant text' }], - diagnostic: 'Claude Code denied a tool request', - stopReason: 'error', - }), - dispose: async () => {}, - }), + const ctx = await setup({ provider: 'mock' }, { + reply: 'partial assistant text', + diagnostic: 'Claude Code denied a tool request', + stopReason: 'error', }) - await ctx.plugin(tool, { provider: 'diagnostic', maxDepth: 'provider-managed' }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(result.isError).toBe(true) @@ -884,34 +869,17 @@ describe('dsh-tool-subagent background mode', () => { }) it('preserves provider diagnostics in one-shot background failure detail', async () => { - const ctx = await backgroundSetup({ provider: 'mock' }) + const ctx = await backgroundSetup({ provider: 'mock' }, { + reply: 'not background output', + diagnostic: 'Claude Code cancelled an unattended dialog', + stopReason: 'error', + }) const parent = ownerAgent(ctx, 'sess-parent') - ctx.subagents.registerProvider({ - name: 'diagnostic-background', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: async () => ({ - id: SessionId('diagnostic-background-child'), - localAgent: undefined, - result: Promise.resolve({ - output: [{ type: 'text', text: 'not background output' }], - diagnostic: 'Claude Code cancelled an unattended dialog', - stopReason: 'error', - }), - dispose: async () => {}, - }), - }) - tool.apply(ctx, { - provider: 'diagnostic-background', - toolName: 'subagent_diagnostic_background', - backgroundMode: 'one-shot', - maxDepth: 'provider-managed', - }) const started = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('diagnostic-background-start'), - name: 'subagent_diagnostic_background', + name: 'subagent', arguments: { description: 'd', prompt: 'p', run_in_background: true }, agent: parent, }) From 7eb203069c9995ba94b2808bafa64fdcda87274d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 18:07:08 +0800 Subject: [PATCH 25/95] feat(subagent): add Codex non-interactive permission modes --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 2 +- ...ct-subagent-providers-in-shared-host.zh.md | 2 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 16 +- ...ude-code-and-codex-subagent-backends.zh.md | 16 +- ...bagent-one-shot-background-tasks.i18n.yaml | 4 +- ...duct-subagent-one-shot-background-tasks.md | 2 +- ...t-subagent-one-shot-background-tasks.zh.md | 2 +- ...agent-noninteractive-permissions.i18n.yaml | 4 +- ...uct-subagent-noninteractive-permissions.md | 40 +- ...-subagent-noninteractive-permissions.zh.md | 40 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 12 +- docs/config-catalog.zh.md | 12 +- .../product-subagent-both.cordis.snapshot.yml | 2 + .../product-subagent-both.cordis.yml | 2 + ...product-subagent-codex.cordis.snapshot.yml | 2 + .../product-subagent-codex.cordis.yml | 2 + .../subagent/subagent-codex/cordis.yml | 2 + .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 28 +- packages/subagent/subagent-codex/README.zh.md | 28 +- packages/subagent/subagent-codex/src/index.ts | 18 +- packages/subagent/subagent-codex/src/run.ts | 55 ++- packages/subagent/subagent-codex/src/wire.ts | 161 +++++++- .../subagent-codex/tests/real-product.spec.ts | 77 +++- .../subagent-codex/tests/responses-fixture.ts | 6 + .../tests/subagent-codex.spec.ts | 382 +++++++++++++++++- 29 files changed, 824 insertions(+), 109 deletions(-) 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 index 331a7a8f5d..84752e27ff 100644 --- 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 @@ -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-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: dd5cd2b3b9c424da1f9f126d4ec9cb1fa4ca7083 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: 0d946fa30240a130b27f85709382595cf29f5ead +2026-08-10-product-subagent-providers-in-shared-host.md: 452ff1cca7e4e5f91f8c35092761ebe83f3ff174 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: a62bf6faa3c9bba5326da1de20ecbc2946c02bcc 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 index dd5cd2b3b9..452ff1cca7 100644 --- 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 @@ -16,7 +16,7 @@ Product providers remain process-scoped host-plane registrations. The [productio This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract 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, or test authentication. It may supply the mounted Provider's deployment configuration, including the Claude Code `permissionMode` owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving that choice into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation. +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, or test authentication. It may supply each mounted Provider's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation. Only a Profile that selects the Claude Code provider carries the Claude Agent SDK's optional platform CLI payload. Production still resolves the host `claude`; the SDK payload remains provider-package installation cost rather than the production executable. 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 index 0d946fa302..a62bf6faa3 100644 --- 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 @@ -16,7 +16,7 @@ Status: implemented 本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的 Claude Code `permissionMode`,但不会把该选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。 +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供每个已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。 只有选择 Claude Code 提供方的 Profile 才会携带 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷。生产环境仍解析宿主提供的 `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 c642b870b8..597b078939 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: d0e48bb2c048351f71687a66a31c8ecdda123328 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 3fd604927c447c24e9047424ab255eb9fd628226 +2026-08-04-claude-code-and-codex-subagent-backends.md: 49c3e3fc6a99cae23b606f5a680320307c79d08c +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dc3a0737b9cfe00a850697ca482fad2743105058 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 d0e48bb2c0..49c3e3fc6a 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 @@ -34,15 +34,15 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product ## Codex provider -`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. +`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Installation, login, `CODEX_HOME`, model selection, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision. -Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. +Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; a permission-related error may additionally carry the shared safe diagnostic. This version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted` without permission detail. -For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. +For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. -An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Result failure and teardown failure stay independently observable. Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. @@ -62,7 +62,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies both fixed one-shot tools expose optional background scheduling alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. +The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. 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. @@ -82,7 +82,7 @@ The project owner's distribution authorization is scoped to the official `@anthr **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, sandbox rules, or fine-grained permission policy.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. Claude Code exposes only one native non-interactive mode choice in addition to environment and teardown configuration; it does not mirror product rules or add a human interaction channel. +**Plugin-managed login, product home, models, settings, sandbox rules, or fine-grained permission policy.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. Each product exposes only one native non-interactive mode choice in addition to environment and teardown configuration; neither Provider mirrors product rules or adds a human interaction channel. **Continuation, progress, product-native background state, and shared parent context.** The provider payload remains one final answer for one self-contained task. The generic Job layer may add its id, status, notice, collection, and cancellation results, but product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and provider-specific background state need separate user contracts and are not prebuilt. @@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. -Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed Claude Code run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. 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. +Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. 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. Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe. 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 3fd604927c..dc3a0737b9 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 @@ -34,15 +34,15 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product ## Codex 提供方 -`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。安装、登录、`CODEX_HOME`、模型选择、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。 -发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 +发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;权限相关错误可以额外携带共享安全诊断。本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`,且不附带权限说明。 -对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 +对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 -若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。结果失败与清理失败仍可彼此独立地观察。 Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 @@ -62,7 +62,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,在同一个上下文中验证两个固定一次性工具会与通用 Job 控制工具一起公开可选后台调度,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 @@ -82,7 +82,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。 -**由插件管理登录、产品主目录、模型、设置、沙箱规则或细粒度权限策略。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。Claude Code 除环境和清理配置外只公开一个原生非交互模式选择;它不会镜像产品规则,也不会增加人工交互通道。 +**由插件管理登录、产品主目录、模型、设置、沙箱规则或细粒度权限策略。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。两个产品除环境和清理配置外都只公开一个原生非交互模式选择;任一提供方都不会镜像产品规则或增加人工交互通道。 **续接、进度、产品原生后台状态和共享父级上下文。** 提供方载荷仍是一项自包含任务的一个最终回答。通用 Job 层可以额外提供 id、状态、通知、收集与取消结果,但产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和提供方专属后台状态都需要独立的用户约定,当前实现不会预先构建这些功能。 @@ -90,6 +90,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl 用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 -每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的 Claude Code 运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 +每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index b8ef147519..b6cad9b39f 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: e389c0b8b6587cf699ea3fd30e75531bb6069108 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d424fa9d1ccb1f14fa73e342964e95b7181c8274 +2026-08-12-product-subagent-one-shot-background-tasks.md: 9aeccfadbad0d8f44ac2c294c4008b672f855027 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 74a0a614847543aff5f88cc5696246a76f2bb72f diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index e389c0b8b6..9aeccfadba 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -49,7 +49,7 @@ The ACP product compositions use the same fixed product rows and generic job con ## Verification -The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, shared diagnostic presentation, cancellation, completion notices, owner disposal, and provider disposal. +The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, shared diagnostic presentation, cancellation, completion notices, owner disposal, and provider disposal. The two real product-provider suites independently prove that their native permission failures enter that same shared result before either scheduling path consumes it. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index d424fa9d1c..74a0a61484 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -49,7 +49,7 @@ ACP 产品组装使用相同的固定产品行与通用作业控制工具。其 ## 验证 -Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、共享诊断呈现、取消、完成通知、owner 资源释放与提供方资源释放。 +Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、共享诊断呈现、取消、完成通知、owner 资源释放与提供方资源释放。两个真实产品提供方测试套件还会分别证明各自的原生权限失败先进入同一个共享结果,再由任一调度路径消费。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 42e26c2f7e..9f54af3439 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: f382bc7ad058fefd8001da6181824fc9b6f767d4 -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 76cf53c7c9af791db6e54a8b779a7284187d0716 +2026-08-15-product-subagent-noninteractive-permissions.md: 3615f2b719522bab0eafe59ed8335fff7c2b3cb1 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: bd8b7fadd48bb67ca17dc7db2568f8107d3993e6 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index f382bc7ad0..3615f2b719 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code subagents use Profile-selected non-interactive permissions +# Agent Note: Product subagents use Profile-selected non-interactive permissions Status: implemented @@ -6,13 +6,17 @@ English | [中文](2026-08-15-product-subagent-noninteractive-permissions.zh.md) ## Problem -The [Claude Code product provider](2026-08-04-claude-code-and-codex-subagent-backends.md) runs without a human interface. Native permission prompts, user dialogs, or MCP elicitation therefore cannot wait for a person, but relying on the product's ambient default can still select an interactive mode. A deployment also needs to choose broader native modes without giving the parent model or one tool call a way to raise its own authority. +The [Claude Code and Codex product providers](2026-08-04-claude-code-and-codex-subagent-backends.md) run without a human interface. Native permission prompts, user dialogs, or MCP elicitation therefore cannot wait for a person, but relying on either product's ambient default can still select an interactive mode. A deployment also needs to choose broader native modes without giving the parent model or one tool call a way to raise its own authority. A failed product run previously reached the [subagent seam](2026-06-21-subagent-capability-seam.md) only as a stop reason. Logs could retain the product error, but the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not distinguish a permission refusal from another failure. Reusing assistant output for that fact would misattribute infrastructure detail to the child model. ## Decision -The Claude Code Provider owns one Profile-level `permissionMode` value. It defaults to `dontAsk` and accepts only the native non-interactive modes supported by the pinned Agent SDK: +Each product Provider owns its own Profile-level `permissionMode` value. The two Config fields deliberately use the products' native names rather than a shared restricted/automatic/full abstraction. The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. + +### Claude Code + +Claude Code defaults to `dontAsk` and accepts only the native non-interactive modes supported by the pinned Agent SDK: | Value | Native behavior | | --- | --- | @@ -22,15 +26,27 @@ The Claude Code Provider owns one Profile-level `permissionMode` value. It defau | `plan` | Use Claude Code's planning-only mode without tool execution. | | `bypassPermissions` | Set the SDK's explicit dangerous confirmation and bypass permission checks. | -The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. +The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. +### Codex + +Codex defaults to `never` and accepts the three native non-interactive modes exposed by Codex 0.147.0. The Provider starts the fixed app-server command, then maps the selected mode into official `thread/start` fields because CLI-global permission flags do not configure threads created later by an app-server client: + +| Value | `thread/start` fields | Native behavior | +| --- | --- | --- | +| `never` | `approvalPolicy: never`; sandbox omitted | Never prompt; execution failures return to the model under the native sandbox. | +| `approve-for-me` | `approvalPolicy: on-request`, `approvalsReviewer: auto_review`, `sandbox: workspace-write` | Route permission requests through Codex automatic review. | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`, `sandbox: danger-full-access` | Skip approval and sandbox enforcement. | + +The Provider overrides only those thread fields. `CODEX_HOME`, project configuration, model/provider selection, MCP, hooks, skills, authentication, and sandbox facts not selected by the mode remain native Codex state. The wire still denies any unexpected approval, permission, user-input, or MCP request rather than opening a dynamic allow path. + ### Failure diagnostic `SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. -Claude Code records only the effective mode, request category, unattended decision, and a fixed safe reason. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`. +Each product records only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`. The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior. @@ -38,16 +54,16 @@ The foreground consumer presents the stop-reason headline, then the optional dia | Fact or resource | Owner | Observable behavior | | --- | --- | --- | -| Profile permission choice | Claude Code Provider Config | Invalid, interactive, or unknown values fail during configuration. | -| Permission and sandbox semantics | Claude Code and its Agent SDK | The Provider passes one native mode and does not mirror product policy. | -| Interaction decisions and safe diagnostic | One Claude Code run | Concurrent runs keep independent mode, callback, and diagnostic state. | +| Profile permission choice | Each product Provider Config | Invalid, interactive, or unknown values fail during configuration. | +| Permission and sandbox semantics | Claude Code Agent SDK or Codex app-server | Each Provider passes one native mode and does not mirror product policy. | +| Interaction decisions and safe diagnostic | One product run | Concurrent runs keep independent mode, protocol, and diagnostic state. | | Diagnostic type and byte limit | `dsh-subagent` | Consumers receive a bounded optional field separate from assistant output. | | Foreground and Job presentation | `dsh-tool-subagent` and the generic Job runtime | Scheduling choice does not change the underlying failure fact. | | Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent whole-tree disposal. | ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK option mapping, bypass confirmation, callback terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, and disposal behavior. The real Agent SDK/CLI fixture proves that the default overrides an interactive native setting, denies an out-of-workspace write with safe diagnostic detail, executes an explicit bypass write only inside suite-owned temporary storage, and leaves the full process tree quiescent. Loader composition proves a non-default mode can be published without starting either product, and the keyless ACP snapshot records the same diagnostic in a foreground tool error and one-shot `job_output` while the model-facing product tool schema contains no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared diagnostic presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered @@ -55,7 +71,7 @@ Package tests pin every allowed and rejected Config value, the exact SDK option **Put permission mode in the model-facing tool or each start request.** That would let task content select authority and would duplicate a Profile deployment decision on every call. -**Copy Claude settings or map the parent Harness sandbox.** The products do not share one permission vocabulary. Mirroring their state would create a second authority and obscure the native sandbox consequences of `auto` and bypass modes. +**Copy product settings or map the parent Harness sandbox.** The products do not share one permission vocabulary. Mirroring their state would create a second authority and obscure the native sandbox consequences of automatic and bypass modes. **Forward prompts to a parent, Web client, or CLI.** The one-shot product run has no owned human-interaction lifecycle. Adding one would require durable request identity, routing, cancellation, and timeout semantics beyond this decision. @@ -65,8 +81,8 @@ Package tests pin every allowed and rejected Config value, the exact SDK option ## Consequences -Profiles can select Claude Code's native restricted, automatic, planning, edit-accepting, or bypass behavior before the Provider starts, while the safe default never asks a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences. +Profiles can select each product's native restricted, automatic, planning/edit-accepting where supported, or bypass behavior before the Provider starts, while both safe defaults never ask a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences. Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement. -The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Codex and other Providers remain valid without producing a diagnostic or exposing a permission-mode Config. +The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Other Providers remain valid without producing a diagnostic or exposing a permission-mode Config. diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 76cf53c7c9..bd8b7fadd4 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code subagent 使用 Profile 选择的非交互权限 +# Agent Note: 产品 subagent 使用 Profile 选择的非交互权限 Status: implemented @@ -6,13 +6,17 @@ Status: implemented ## Problem -[Claude Code 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)在没有人工界面的情况下运行。因此,原生权限提示、用户对话或 MCP elicitation 不能等待人员响应,但依赖产品环境中的默认值仍可能选择交互模式。部署也需要选择更宽松的原生模式,同时不能让父模型或单次工具调用提升自身权限。 +[Claude Code 与 Codex 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)都在没有人工界面的情况下运行。因此,原生权限提示、用户对话或 MCP elicitation 不能等待人员响应,但依赖任一产品环境中的默认值仍可能选择交互模式。部署也需要选择更宽松的原生模式,同时不能让父模型或单次工具调用提升自身权限。 失败的产品运行此前只能把终止原因送入 [subagent seam](2026-06-21-subagent-capability-seam.md)。日志可以保留产品错误,但前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.md)无法区分权限拒绝和其他失败。若复用 assistant 输出承载该事实,则会把基础设施说明错误归因给子模型。 ## Decision -Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支持的原生非交互模式: +每个产品提供方分别拥有自己的 Profile 级 `permissionMode` 值。两个 Config 字段有意使用各产品的原生名称,而不是共享的受限/自动/完全抽象。提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。 + +### Claude Code + +Claude Code 默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支持的原生非交互模式: | 值 | 原生行为 | | --- | --- | @@ -22,15 +26,27 @@ Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认 | `plan` | 使用 Claude Code 的仅规划模式,不执行工具。 | | `bypassPermissions` | 设置 SDK 的显式危险确认并跳过权限检查。 | -提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 +提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 +### Codex + +Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交互模式。提供方启动固定的 app-server 命令,再把所选模式映射为官方 `thread/start` 字段,因为 CLI 全局权限 flag 不会配置之后由 app-server 客户端创建的线程: + +| 值 | `thread/start` 字段 | 原生行为 | +| --- | --- | --- | +| `never` | `approvalPolicy: never`;省略 sandbox | 永不弹出提示;执行失败会在原生 sandbox 下返回模型。 | +| `approve-for-me` | `approvalPolicy: on-request`、`approvalsReviewer: auto_review`、`sandbox: workspace-write` | 由 Codex 自动评审权限请求。 | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`、`sandbox: danger-full-access` | 跳过审批与 sandbox。 | + +提供方只覆盖这些线程字段。`CODEX_HOME`、项目配置、模型/provider 选择、MCP、hook、skill、身份验证,以及模式未选择的 sandbox 事实仍属于 Codex 原生状态。wire 仍会拒绝任何意外到达的审批、权限、用户输入或 MCP 请求,而不会开放动态 allow 通道。 + ### 失败诊断 `SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。 -Claude Code 只记录有效模式、请求类别、无人值守决定与固定的安全原因。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 +每个产品都只记录有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。 @@ -38,16 +54,16 @@ Claude Code 只记录有效模式、请求类别、无人值守决定与固定 | 事实或资源 | Owner | 可观察行为 | | --- | --- | --- | -| Profile 权限选择 | Claude Code 提供方 Config | 配置阶段会拒绝无效、交互式或未知值。 | -| 权限与沙箱语义 | Claude Code 及其 Agent SDK | 提供方传入一个原生模式,不镜像产品策略。 | -| 交互决定与安全诊断 | 单次 Claude Code 运行 | 并发运行分别拥有独立的模式、回调与诊断状态。 | +| Profile 权限选择 | 各产品提供方 Config | 配置阶段会拒绝无效、交互式或未知值。 | +| 权限与沙箱语义 | Claude Code Agent SDK 或 Codex app-server | 各提供方传入一个原生模式,不镜像产品策略。 | +| 交互决定与安全诊断 | 单次产品运行 | 并发运行分别拥有独立的模式、协议与诊断状态。 | | 诊断类型与字节上限 | `dsh-subagent` | 消费方收到与 assistant 输出分离的有界可选字段。 | | 前台与 Job 呈现 | `dsh-tool-subagent` 和通用 Job 运行时 | 调度选择不会改变底层失败事实。 | | 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的完整进程树资源释放。 | ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 选项映射、bypass 确认、回调终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail 和资源释放行为。真实 Agent SDK/CLI fixture 证明默认值会覆盖交互式原生设置,越出工作区的写入会被拒绝并返回安全诊断,显式 bypass 写入只会发生在测试拥有的临时存储中,而且完整进程树会完全停稳。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录同一诊断如何出现在前台工具错误与一次性 `job_output` 中,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录共享诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered @@ -55,7 +71,7 @@ Claude Code 只记录有效模式、请求类别、无人值守决定与固定 **把权限模式放入面向模型的工具或每次 start 请求。** 这会让任务内容选择权限,并在每次调用中重复一个 Profile 部署决定。 -**复制 Claude 设置或映射父级 Harness 沙箱。** 各产品并不共享同一套权限词汇。镜像这些状态会创建第二个权威,并掩盖 `auto` 与 bypass 模式的原生沙箱后果。 +**复制产品设置或映射父级 Harness 沙箱。** 各产品并不共享同一套权限词汇。镜像这些状态会创建第二个权威,并掩盖自动模式与 bypass 模式的原生沙箱后果。 **把提示转发给父 agent、Web 客户端或 CLI。** 一次性产品运行没有由其拥有的人工交互生命周期。新增该能力需要持久请求身份、路由、取消与 timeout 语义,超出本决策范围。 @@ -65,8 +81,8 @@ Claude Code 只记录有效模式、请求类别、无人值守决定与固定 ## Consequences -Profile 可以在提供方启动前选择 Claude Code 原生的受限、自动、仅规划、编辑放行或 bypass 行为,而安全默认值绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。 +Profile 可以在提供方启动前选择各产品原生的受限、自动、在产品支持时仅规划/编辑放行,或 bypass 行为,而两个安全默认值都绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。 权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。 -本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。Codex 与其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。 +本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 921934f9ac..4fab8e561c 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: 8294c2187f2b80fbf36787c784ad8b73a16206c1 -config-catalog.zh.md: f35392a5b005212067c9b593b7fa2818202466dd +config-catalog.md: 8cdfc06094c75792e7f906e905ae617df8af2848 +config-catalog.zh.md: bb31b54a914ecafd6d29cbf43cfccecd31fdcb39 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8294c2187f..8cdfc06094 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2112,19 +2112,27 @@ Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/s Requires: `subagents` · `subprocess` ```ts config-catalog -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: CodexPermissionMode /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } + +/** Profile-selectable non-interactive Codex permission mode. */ +export type CodexPermissionMode = + | 'never' + | 'approve-for-me' + | 'dangerously-bypass-approvals-and-sandbox' ``` -Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts) +Source: [`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f35392a5b0..bb31b54a91 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2114,19 +2114,27 @@ export type ClaudeCodePermissionMode = 需要:`subagents` · `subprocess` ```ts config-catalog -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: CodexPermissionMode /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } + +/** Profile-selectable non-interactive Codex permission mode. */ +export type CodexPermissionMode = + | 'never' + | 'approve-for-me' + | 'dangerously-bypass-approvals-and-sandbox' ``` -来源:[`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts) +来源:[`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml index 39f464a6b7..c4af1894e1 100644 --- a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -20,6 +20,8 @@ - id: deepseek-v4-pro - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' config: diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml index 6c5154fc6b..837fea1f75 100644 --- a/examples/acp-agent/product-subagent-both.cordis.yml +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -9,6 +9,8 @@ - insert: - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' config: diff --git a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml index 69171c7dbf..83383814c9 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml @@ -20,6 +20,8 @@ - id: deepseek-v4-pro - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/product-subagent-codex.cordis.yml b/examples/acp-agent/product-subagent-codex.cordis.yml index 2a95679e14..be399023b0 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.yml @@ -9,6 +9,8 @@ - insert: - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index 6afe2b888d..fd015839ff 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -11,6 +11,8 @@ - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index da14b8ff30..22f8e3c291 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: 848d170585710b682fa4ce331010fce7080de673 -README.zh.md: 34e9105e6a78bc16f16997c7df89d4f6412eb50c +README.md: 645479474599eb4cb72c0bf73838a6341c98adb7 +README.zh.md: 1e9d21882b4c84312ea60eff3510bd2295d5334e diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 848d170585..6454794745 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -2,17 +2,17 @@ English | [中文](README.zh.md) -This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, 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 `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns either the selected final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. -For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. +For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. The wire records only the effective mode, request category, decision, and fixed safe reason. It also recognizes declined command/file items and `sandboxError` terminals. Codex 0.147.0 writes some early `never` rejections and sandbox violations only to structured stderr, so the Provider pipes stderr, forwards it unchanged to the host, and matches two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. -Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. A permission-related error may additionally carry the bounded, non-assistant `SubagentResult.diagnostic`; successful and locally cancelled runs omit it. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Result failure and independent teardown failure remain separate. ## Capabilities and context @@ -23,9 +23,16 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | +| `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this Provider instance. | | `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 `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. +| `permissionMode` value | `thread/start` fields | Native behavior | +|---|---|---| +| `never` | `approvalPolicy: never`; sandbox omitted | Never ask for approval; execution failures return to the model under the native sandbox. | +| `approve-for-me` | `approvalPolicy: on-request`, `approvalsReviewer: auto_review`, `sandbox: workspace-write` | Route permission requests through Codex automatic review without a human. | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`, `sandbox: danger-full-access` | Skip approval and sandbox enforcement; this value must be selected explicitly. | + +Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The Provider overrides only the selected thread approval/reviewer/sandbox fields; all other `CODEX_HOME`, project, model, provider, MCP, hook, skill, and account settings remain native. 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. Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. @@ -35,6 +42,7 @@ The standalone composition below shows the complete explicit capability. A Profi - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' config: + permissionMode: approve-for-me env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY @@ -55,7 +63,7 @@ The standalone composition below shows the complete explicit capability. A Profi ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. Real-product coverage proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, safe diagnostics exclude raw commands and paths, and every wrapper/native process exits. ## Model Experience @@ -63,7 +71,7 @@ The production wire intentionally implements only the app-server methods require #### What the model sees -The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration. +The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd; its model, system instructions, tools, and authentication come from the native Codex installation and configuration, while the Provider's Profile configuration fixes the thread's non-interactive approval and sandbox mode. #### Token effect @@ -77,7 +85,7 @@ Independent of the parent request cache. Reuse depends only on Codex's own provi #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session. #### Token effect @@ -92,7 +100,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. -- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. -- **Product payload is final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime. +- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy. +- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, commentary, intermediate messages, tool traffic, usage, raw stderr, and workspace diffs remain outside the parent Session, while generic Job ids, notices, and status come from the shared job runtime. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 34e9105e6a..1e9d21882b 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -2,17 +2,17 @@ [English](README.md) | 中文 -本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回选定的最终答案或安全失败说明。 ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 -对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 +对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。wire 只记录有效模式、请求类别、决定与固定的安全原因,也会识别被拒绝的命令/文件 item 和 `sandboxError` 终态。Codex 0.147.0 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe stderr、原样转发给 Host,并在每次运行的有界尾缓冲中匹配两个固定签名;原始 stderr 不会进入诊断。 -本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。权限相关错误可以额外携带有界、非 assistant 的 `SubagentResult.diagnostic`;成功和本地取消不会附带它。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。结果失败与独立的清理失败仍彼此分离。 ## 能力与上下文 @@ -23,9 +23,16 @@ | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | +| `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 +| `permissionMode` 值 | `thread/start` 字段 | 原生行为 | +|---|---|---| +| `never` | `approvalPolicy: never`;省略 sandbox | 永不请求审批;执行失败会在原生 sandbox 下返回模型。 | +| `approve-for-me` | `approvalPolicy: on-request`、`approvalsReviewer: auto_review`、`sandbox: workspace-write` | 由 Codex 自动评审权限请求,不等待人工。 | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`、`sandbox: danger-full-access` | 跳过审批与 sandbox;必须显式选择该值。 | + +生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。提供方只覆盖选定线程的 approval/reviewer/sandbox 字段;其他 `CODEX_HOME`、项目、模型、provider、MCP、hook、skill 与账户设置仍由原生机制负责。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 @@ -35,6 +42,7 @@ - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' config: + permissionMode: approve-for-me env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY @@ -55,7 +63,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。真实产品覆盖会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。 ## 模型体验 @@ -63,7 +71,7 @@ #### 模型看到的内容 -Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。 +Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具和身份验证来自原生 Codex 安装与配置,而提供方的 Profile 配置会固定该线程的非交互审批与沙箱模式。 #### 对 token 的影响 @@ -77,7 +85,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案与状态,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -92,7 +100,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 -- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 -- **产品载荷仅包含最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部;通用 Job id、通知与状态来自共享作业运行时。 +- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。 +- **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、过程说明、中间消息、工具通信、用量信息、原始 stderr 和工作区差异不会进入父会话,通用 Job id、通知与状态来自共享作业运行时。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 3b1bbec799..9624824791 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -18,27 +18,34 @@ import { type SubagentProvider, } from '@deepseek-ai/dsh-subagent' import { + CODEX_PERMISSION_MODES, + DEFAULT_CODEX_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, startCodexRun, + type CodexPermissionMode, type CodexRunSpec, } from './run.ts' export const name = 'subagent-codex' export const inject = ['subagents', 'subprocess'] -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: CodexPermissionMode /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } export const Config: z = z.object({ env: z.dict(z.string()).default({}), + permissionMode: z.union([...CODEX_PERMISSION_MODES]) + .default(DEFAULT_CODEX_PERMISSION_MODE), disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) @@ -67,6 +74,7 @@ class CodexProvider implements SubagentProvider { undefined, parentCwd, ), + permissionMode: this.config.permissionMode, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), @@ -83,10 +91,14 @@ class CodexProvider implements SubagentProvider { /** * Register the fixed `codex` provider. * @param ctx - context carrying shared subagent and subprocess services. - * @param config - explicit child environment and disposal grace. + * @param config - permission mode, child environment, and disposal grace. */ export function apply(ctx: Context, config: Config): void { - const resolved = config as ResolvedConfig + const resolved: ResolvedConfig = { + env: config.env as Record, + permissionMode: config.permissionMode ?? DEFAULT_CODEX_PERMISSION_MODE, + disposeGraceMs: config.disposeGraceMs as number, + } assertPositiveFinite( 'subagent-codex', 'disposeGraceMs', diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index ebce244f3b..1c596b806a 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -24,6 +24,22 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Profile-selectable non-interactive Codex permission mode. */ +export type CodexPermissionMode = + | 'never' + | 'approve-for-me' + | 'dangerously-bypass-approvals-and-sandbox' + +/** Codex CLI permission modes that cannot wait for a human response. */ +export const CODEX_PERMISSION_MODES = [ + 'never', + 'approve-for-me', + 'dangerously-bypass-approvals-and-sandbox', +] as const satisfies readonly CodexPermissionMode[] + +/** Safe default for unattended Codex runs. */ +export const DEFAULT_CODEX_PERMISSION_MODE: CodexPermissionMode = 'never' + /** * Resolve the fixed app-server command for a platform. * @@ -45,6 +61,8 @@ export function codexAppServerArgv( export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ readonly cwd: string + /** Profile-selected native non-interactive permission mode. */ + readonly permissionMode: CodexPermissionMode /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -125,7 +143,7 @@ export async function startCodexRun( const child = spec.spawn({ argv: codexAppServerArgv(), cwd: spec.cwd, - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: spec.disposeGraceMs, env: spec.env, }) @@ -133,8 +151,27 @@ export async function startCodexRun( const wire = new CodexAppServerWire( child.stdout as NonNullable, child.stdin as NonNullable, + spec.permissionMode, ) - const disposeProcess = (): Promise => disposeCodexChild(wire, child) + const onStderr = (chunk: Buffer | string): void => { + process.stderr.write(chunk) + wire.observeStderr(chunk.toString()) + } + const stderrFailure = Promise.withResolvers() + const onStderrError = (error: Error): void => { + stderrFailure.reject(error) + } + void stderrFailure.promise.catch(() => {}) + child.stderr?.on('data', onStderr) + child.stderr?.on('error', onStderrError) + const disposeProcess = async (): Promise => { + try { + await disposeCodexChild(wire, child) + } finally { + child.stderr?.off('data', onStderr) + child.stderr?.off('error', onStderrError) + } + } const processFailure: Promise = child.done.then( outcome => Promise.reject(new Error( @@ -158,8 +195,16 @@ export async function startCodexRun( try { wire.start() - await Promise.race([wire.initialize(request.signal), processFailure]) - await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) + await Promise.race([ + wire.initialize(request.signal), + processFailure, + stderrFailure.promise, + ]) + await Promise.race([ + wire.startThread(spec.cwd, request.signal), + processFailure, + stderrFailure.promise, + ]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) try { @@ -181,8 +226,10 @@ export async function startCodexRun( attempt: () => Promise.race([ wire.runTurn(texts, runAbort.signal), processFailure, + stderrFailure.promise, ]), collectOutput, + collectDiagnostic: () => wire.collectDiagnostic(), cancelled: () => runAbort.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index bc00ff0acf..c4274c8b2c 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -11,9 +11,42 @@ import type { Readable, Writable } from 'node:stream' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentResult } from '@deepseek-ai/dsh-subagent' import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' +import type { CodexPermissionMode } from './run.ts' type JsonObject = Record +const THREAD_PERMISSION_PARAMS: Readonly> = { + never: { approvalPolicy: 'never' }, + 'approve-for-me': { + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: 'workspace-write', + }, + 'dangerously-bypass-approvals-and-sandbox': { + approvalPolicy: 'never', + sandbox: 'danger-full-access', + }, +} + +const STDERR_PERMISSION_SIGNATURES = [ + { + text: 'approval policy is Never; reject command', + request: 'command execution', + decision: 'denied', + reason: 'Codex rejected an escalation because the selected policy never asks for approval', + }, + { + text: 'recorded sandbox violation:', + request: 'sandbox execution', + decision: 'failed', + reason: 'Codex reported a sandbox violation', + }, +] as const + +const STDERR_SIGNATURE_TAIL_CHARS = Math.max( + ...STDERR_PERMISSION_SIGNATURES.map(signature => signature.text.length), +) - 1 + function object(value: unknown, label: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) @@ -47,6 +80,24 @@ function isContextWindowExceeded(turn: JsonObject): boolean { && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded' } +function isSandboxFailure(turn: JsonObject): boolean { + if (turn.status !== 'failed') return false + const error = turn.error + return error !== null + && typeof error === 'object' + && !Array.isArray(error) + && (error as JsonObject).codexErrorInfo === 'sandboxError' +} + +function unattendedDiagnostic( + mode: CodexPermissionMode, + request: 'command approval' | 'file approval' | 'permission grant' | 'user input' | 'MCP elicitation' | 'command execution' | 'file change' | 'sandbox execution', + decision: 'cancelled' | 'declined' | 'denied' | 'empty response' | 'failed', + reason: string, +): string { + return `Codex unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}` +} + function thrown(value: unknown): Error { /* v8 ignore next -- typed protocol and stream failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -93,11 +144,14 @@ export class CodexAppServerWire { }> = [] private lastFinalAnswer: string | undefined private lastUnphasedAnswer: string | undefined + private diagnostic: string | undefined + private stderrTail = '' private closed = false constructor( private readonly input: Readable, output: Writable, + private readonly permissionMode: CodexPermissionMode = 'never', ) { this.transport = new JsonRpcLineTransport(input, output) // Fatal protocol state can arrive after the current guarded operation has @@ -154,6 +208,7 @@ export class CodexAppServerWire { const response = object(await this.guarded(this.transport.request('thread/start', { cwd, ephemeral: true, + ...THREAD_PERMISSION_PARAMS[this.permissionMode], }, signal), signal), 'thread/start response') const thread = object(response.thread, 'thread/start thread') const id = string(thread.id, 'thread/start thread id') @@ -191,8 +246,18 @@ export class CodexAppServerWire { return { output: this.collectOutput(), stopReason: 'max-tokens' } } if (status !== 'completed') { + const sandboxFailure = isSandboxFailure(terminal) + if (sandboxFailure) { + this.recordDiagnostic( + 'sandbox execution', + 'failed', + 'Codex reported a sandbox failure', + ) + } const detail = status === 'failed' - ? `: ${JSON.stringify(terminal.error)}` + ? sandboxFailure + ? ': sandboxError' + : ': error' : '' throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) } @@ -226,6 +291,36 @@ export class CodexAppServerWire { : [] } + /** + * The latest safe unattended permission fact observed for this run. + * @returns provider-authored diagnostic text, when one was observed. + */ + collectDiagnostic(): string | undefined { + return this.diagnostic + } + + /** + * Observe product stderr while retaining only enough tail to recognize fixed + * permission signatures. The raw text is never copied into the diagnostic. + * @param chunk - one decoded stderr chunk already forwarded to the host. + */ + observeStderr(chunk: string): void { + const observed = `${this.stderrTail}${chunk}` + let latestIndex = -1 + let latest: (typeof STDERR_PERMISSION_SIGNATURES)[number] | undefined + for (const signature of STDERR_PERMISSION_SIGNATURES) { + const index = observed.lastIndexOf(signature.text) + if (index > latestIndex) { + latestIndex = index + latest = signature + } + } + if (latest !== undefined) { + this.recordDiagnostic(latest.request, latest.decision, latest.reason) + } + this.stderrTail = observed.slice(-STDERR_SIGNATURE_TAIL_CHARS) + } + /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ close(): void { if (this.closed) return @@ -291,21 +386,67 @@ export class CodexAppServerWire { } } + private recordDiagnostic( + request: Parameters[1], + decision: Parameters[2], + reason: string, + ): void { + this.diagnostic = unattendedDiagnostic( + this.permissionMode, + request, + decision, + reason, + ) + } + private handleServerRequest(method: string, params: JsonObject): Promise { try { switch (method) { case 'item/commandExecution/requestApproval': + this.validateRunIds(params) + { + const decision = unattendedDecision(params) + this.recordDiagnostic( + 'command approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/fileChange/requestApproval': this.validateRunIds(params) - return Promise.resolve({ decision: unattendedDecision(params) }) + { + const decision = unattendedDecision(params) + this.recordDiagnostic( + 'file approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/permissions/requestApproval': this.validateRunIds(params) + this.recordDiagnostic( + 'permission grant', + 'denied', + 'the provider grants no additional turn permissions', + ) return Promise.resolve({ permissions: {}, scope: 'turn' }) case 'item/tool/requestUserInput': this.validateRunIds(params) + this.recordDiagnostic( + 'user input', + 'empty response', + 'the provider does not collect interactive answers', + ) return Promise.resolve({ answers: {} }) case 'mcpServer/elicitation/request': this.validateRunIds(params, true) + this.recordDiagnostic( + 'MCP elicitation', + 'declined', + 'the provider does not collect interactive MCP input', + ) return Promise.resolve({ action: 'decline', content: null, _meta: null }) default: throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`) @@ -340,6 +481,22 @@ export class CodexAppServerWire { } if (id !== this.turnId) return const item = object(params.item, 'item/completed item') + if (item.type === 'commandExecution' && item.status === 'declined') { + this.recordDiagnostic( + 'command execution', + 'declined', + 'Codex declined the command under the selected permission mode', + ) + return + } + if (item.type === 'fileChange' && item.status === 'declined') { + this.recordDiagnostic( + 'file change', + 'declined', + 'Codex declined the file change under the selected permission mode', + ) + return + } if (item.type !== 'agentMessage') return const text = typeof item.text === 'string' ? item.text diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 551d6db765..a060d1555d 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -18,6 +18,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' +import type { CodexPermissionMode } from '../src/run.ts' import { startResponsesFixture, type ResponsesBehavior, @@ -53,7 +54,10 @@ interface RealHarness { readonly workspace: string } -async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ +async function realHarness( + script: readonly ResponsesBehavior[], + permissionMode?: CodexPermissionMode, +): Promise<{ readonly harness: RealHarness readonly fixture: ResponsesFixture }> { @@ -106,7 +110,11 @@ async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ handles.push(handle) return handle }) - await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + await ctx.plugin(codex, { + env, + ...permissionMode === undefined ? {} : { permissionMode }, + disposeGraceMs: 2_000, + }) const parent = { id: 'real-parent', session: { header: { cwd: workspace } }, @@ -141,12 +149,12 @@ function responseInputTexts(body: Record): string[] { } describe('real @openai/codex 0.147.0 product', () => { - it('passes the exact task and fake authentication to local Responses and returns exact text', async () => { + it('starts approve-for-me through the real app-server and returns exact text', async () => { const sentinel = 'REAL_CODEX_SENTINEL_0_147_0' const task = 'Return the fixture sentinel exactly.' const { harness, fixture } = await realHarness([ { kind: 'complete', text: sentinel }, - ]) + ], 'approve-for-me') expect(codexPackage.version).toBe('0.147.0') const version = await execFileAsync(process.execPath, [codexEntry, '--version'], { env: { ...process.env, ...harness.env }, @@ -173,7 +181,7 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) - it('cancels a real app-server command approval without executing the command', async () => { + it('overrides on-request with never and reports a denied command safely', async () => { const command = process.platform === 'win32' ? 'cmd /c type nul > approval-side-effect' : 'touch approval-side-effect' @@ -200,6 +208,11 @@ describe('real @openai/codex 0.147.0 product', () => { kind: 'advertisedFunctionCall', choices: commandCalls, }, + { + kind: 'error', + status: 400, + message: 'fixture terminal failure after permission denial', + }, ]) const sideEffect = join(harness.workspace, 'approval-side-effect') const run = await harness.ctx.subagents.start('codex', { @@ -207,14 +220,20 @@ describe('real @openai/codex 0.147.0 product', () => { parent: harness.parent, signal: new AbortController().signal, }) - await expect(run.result).resolves.toEqual({ - output: [], - stopReason: 'error', - }) + const result = await run.result + expect(result.output).toEqual([]) + expect(result.stopReason).toBe('error') + expect([ + 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure', + 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + ]).toContain(result.diagnostic) + expect(result.diagnostic).not.toContain(command) + expect(result.diagnostic).not.toContain(harness.workspace) await run.dispose() expect(existsSync(sideEffect)).toBe(false) - expect(fixture.requests).toHaveLength(1) + expect(fixture.requests).toHaveLength(2) const tools = fixture.requests[0]!.body.tools as Array> expect(commandCalls.some(call => tools.some(tool => ( tool.type === 'function' && tool.name === call.name @@ -225,6 +244,44 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) + it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => { + const sideEffect = 'bypass-side-effect' + const command = process.platform === 'win32' + ? `cmd /c echo bypass>${sideEffect}` + : `printf bypass > ${sideEffect}` + const commandCalls = [ + { + name: 'exec_command', + arguments: { + cmd: command, + }, + }, + { + name: 'shell_command', + arguments: { + command, + }, + }, + ] as const + const { harness } = await realHarness([ + { kind: 'advertisedFunctionCall', choices: commandCalls }, + { kind: 'complete', text: 'bypass complete' }, + ], 'dangerously-bypass-approvals-and-sandbox') + const target = join(harness.workspace, sideEffect) + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Create the fixture side effect.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'bypass complete' }], + stopReason: 'completed', + }) + expect(readFileSync(target, 'utf8').trim()).toBe('bypass') + await run.dispose() + await expectQuiescent(harness.handles) + }, 60_000) + it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { const { harness, fixture } = await realHarness([{ kind: 'hold' }]) const controller = new AbortController() diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index 2b6e5868ae..c2ef18d803 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -17,6 +17,7 @@ interface RecordedResponsesRequest { /** Behavior consumed by one Responses request. */ export type ResponsesBehavior = | { readonly kind: 'complete'; readonly text: string } + | { readonly kind: 'error'; readonly status: number; readonly message: string } | { readonly kind: 'functionCall' readonly name: string @@ -275,6 +276,11 @@ export async function startResponsesFixture( response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } })) return } + if (behavior.kind === 'error') { + response.writeHead(behavior.status, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: behavior.message } })) + return + } response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 37b2e9ff0b..b09e2ce46c 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -15,6 +15,8 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { + CODEX_PERMISSION_MODES, + DEFAULT_CODEX_PERMISSION_MODE, codexAppServerArgv, DEFAULT_DISPOSE_GRACE_MS, disposeCodexChild, @@ -101,6 +103,7 @@ interface FakeChild { readonly peer: ProtocolPeer readonly fromChild: PassThrough readonly toChild: PassThrough + readonly stderr: PassThrough readonly settle: (outcome?: SubprocessOutcome) => void readonly fail: (error: Error) => void readonly terminate: () => void @@ -110,6 +113,7 @@ interface FakeChild { function fakeChild(options: FakeChildOptions = {}): FakeChild { const fromChild = new PassThrough() const toChild = new PassThrough() + const stderr = new PassThrough() const peer = new ProtocolPeer(toChild, fromChild) let exited = false let resolveDone!: (outcome: SubprocessOutcome) => void @@ -159,7 +163,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { pid: options.pid ?? 1234, stdin: toChild, stdout: fromChild, - stderr: undefined, + stderr, collected: {}, done, terminate, @@ -170,6 +174,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { peer, fromChild, toChild, + stderr, settle, fail, terminate, @@ -183,6 +188,7 @@ function runSpec( ): CodexRunSpec { return { cwd: process.cwd(), + permissionMode: DEFAULT_CODEX_PERMISSION_MODE, env: {}, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: () => child.handle, @@ -260,7 +266,10 @@ function turnCompleted( } describe('task admission and package contracts', () => { - it('resolves the fixed app-server command through the Windows npm shim boundary', () => { + it('keeps the app-server command fixed on POSIX and Windows', () => { + expect(codexAppServerArgv('linux')).toEqual([ + 'codex', 'app-server', '--stdio', + ]) expect(codexAppServerArgv('win32')).toEqual([ 'cmd.exe', '/d', @@ -270,7 +279,6 @@ describe('task admission and package contracts', () => { 'app-server', '--stdio', ]) - expect(codexAppServerArgv('linux')).toEqual(['codex', 'app-server', '--stdio']) }) it('accepts one or more text blocks and rejects empty or non-text tasks', () => { @@ -314,6 +322,61 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('accepts only the three fixed non-interactive permission modes', () => { + expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE) + for (const permissionMode of CODEX_PERMISSION_MODES) { + expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode) + } + for (const permissionMode of ['on-request', 'untrusted', 'future-mode']) { + expect(() => codex.Config({ permissionMode } as never)).toThrow() + } + }) + + it('resolves the safe permission default when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) + expect(ctx.subagents.getProvider('codex')).toBeDefined() + await ctx.fiber.dispose() + }) + + it.each([ + ['never', { approvalPolicy: 'never' }], + ['approve-for-me', { + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: 'workspace-write', + }], + ['dangerously-bypass-approvals-and-sandbox', { + approvalPolicy: 'never', + sandbox: 'danger-full-access', + }], + ] as const)('maps %s to the official thread/start fields', async (permissionMode, expected) => { + const child = fakeChild() + const wire = new CodexAppServerWire( + child.handle.stdout!, + child.handle.stdin!, + permissionMode, + ) + wire.start() + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) + await initializing + await child.peer.nextMethod('initialized') + const starting = wire.startThread('/workspace', new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ + cwd: '/workspace', + ephemeral: true, + ...expected, + }) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await starting + wire.close() + }) + it('requires a parent session cwd without suggesting unsupported config', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) @@ -386,7 +449,11 @@ describe('CodexAppServerWire', () => { const starting = wire.startThread('/workspace', new AbortController().signal) const threadStart = await child.peer.nextMethod('thread/start') - expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true }) + expect(threadStart.params).toEqual({ + cwd: '/workspace', + ephemeral: true, + approvalPolicy: 'never', + }) child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) await starting @@ -586,11 +653,15 @@ describe('CodexAppServerWire', () => { threadId: 'thread-1', turnId: 'turn-1', availableDecisions: ['decline', 'cancel'], + command: 'cat /private/secret.txt', }, }) expect(await child.peer.nextResponse('command')).toMatchObject({ result: { decision: 'cancel' }, }) + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + ) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() @@ -604,30 +675,35 @@ describe('CodexAppServerWire', () => { availableDecisions: ['decline'], }, result: { decision: 'decline' }, + diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval', }, { id: 'file-default', method: 'item/fileChange/requestApproval', params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { decision: 'decline' }, + diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval', }, { id: 'permissions', method: 'item/permissions/requestApproval', params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { permissions: {}, scope: 'turn' }, + diagnostic: 'Codex unattended decision (mode: never; request: permission grant; decision: denied): the provider grants no additional turn permissions', }, { id: 'user-input', method: 'item/tool/requestUserInput', params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] }, result: { answers: {} }, + diagnostic: 'Codex unattended decision (mode: never; request: user input; decision: empty response): the provider does not collect interactive answers', }, { id: 'mcp', method: 'mcpServer/elicitation/request', params: { threadId: 'thread-1', turnId: null }, result: { action: 'decline', content: null, _meta: null }, + diagnostic: 'Codex unattended decision (mode: never; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', }, ] as const for (const serverRequest of requests) { @@ -635,8 +711,133 @@ describe('CodexAppServerWire', () => { expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({ result: serverRequest.result, }) + expect(wire.collectDiagnostic()).toBe(serverRequest.diagnostic) } + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('records only a safe diagnostic for an explicit sandbox failure', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'failed at /private/secret.txt with SECRET_TOKEN', + additionalDetails: 'raw command payload', + codexErrorInfo: 'sandboxError', + })) + await expect(result).rejects.toThrow('status failed') + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure', + ) + expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + wire.close() + }) + + it('records a declined command item without retaining its payload', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'commandExecution', + status: 'declined', + command: 'cat /private/secret.txt', + }, + }, + }, + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: 'other', + }), + ) + await expect(result).rejects.toThrow('status failed') + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode', + ) + expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + wire.close() + }) + + it('recognizes large, split, and ordered stderr signatures without retaining raw text', () => { + const first = fakeChild() + const largeWire = new CodexAppServerWire( + first.handle.stdout!, + first.handle.stdin!, + 'never', + ) + largeWire.observeStderr( + `SECRET_TOKEN approval policy is Never; reject command${'x'.repeat(2_048)}`, + ) + expect(largeWire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + ) + expect(largeWire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + + const second = fakeChild() + const splitWire = new CodexAppServerWire( + second.handle.stdout!, + second.handle.stdin!, + 'never', + ) + splitWire.observeStderr('SECRET_TOKEN approval policy is Ne') + splitWire.observeStderr('ver; reject command — /private/secret.txt') + expect(splitWire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + ) + expect(splitWire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + expect(splitWire.collectDiagnostic()).not.toContain('/private/secret.txt') + + const third = fakeChild() + const orderedWire = new CodexAppServerWire( + third.handle.stdout!, + third.handle.stdin!, + 'dangerously-bypass-approvals-and-sandbox', + ) + orderedWire.observeStderr( + 'approval policy is Never; reject command; recorded sandbox violation: path=/private/secret.txt', + ) + expect(orderedWire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: sandbox execution; decision: failed): Codex reported a sandbox violation', + ) + expect(orderedWire.collectDiagnostic()).not.toContain('/private/secret.txt') + }) + + it('does not reapply an old stderr signature after a newer request diagnostic', async () => { + const { child, wire } = await initializeWire() + wire.observeStderr('approval policy is Never; reject command') + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send({ + id: 'file-approval', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline'], + }, + }) + await child.peer.nextResponse('file-approval') + expect(wire.collectDiagnostic()).toContain('request: file approval') + wire.observeStderr('later benign stderr') + expect(wire.collectDiagnostic()).toContain('request: file approval') child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) wire.close() @@ -864,7 +1065,7 @@ describe('run lifecycle and quiescence', () => { expect(spawn).toHaveBeenCalledWith({ argv: codexAppServerArgv(), cwd: process.cwd(), - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: DEFAULT_DISPOSE_GRACE_MS, env: { OPENAI_API_KEY: 'fake' }, }) @@ -929,6 +1130,73 @@ describe('run lifecycle and quiescence', () => { await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) await run.dispose() } + { + const child = fakeChild() + const { run, turnStart } = await publishRun(child, undefined, { + onError: (error) => { errors.push(error.message) }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.stderr.emit('error', new Error('stderr broke')) + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + expect(errors.at(-1)).toContain('stderr broke') + await run.dispose() + expect(child.stderr.listenerCount('error')).toBe(0) + } + }) + + it('attaches a safe permission diagnostic when a published run fails', async () => { + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send({ + id: 'approval-diagnostic', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + command: 'cat /private/secret.txt', + }, + }) + expect(await child.peer.nextResponse('approval-diagnostic')).toMatchObject({ + result: { decision: 'cancel' }, + }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: 'other', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + stopReason: 'error', + }) + await run.dispose() + }) + + it('forwards stderr while extracting only a fixed safe permission signature', async () => { + const child = fakeChild() + const forwarded: string[] = [] + const write = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + forwarded.push(String(chunk)) + return true + }) + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.stderr.write('SECRET_TOKEN approval policy is Ne') + child.stderr.write('ver; reject command — /private/secret.txt') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + expect(forwarded.join('')).toContain('SECRET_TOKEN') + await run.dispose() + expect(child.stderr.listenerCount('data')).toBe(0) + write.mockRestore() }) it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { @@ -939,6 +1207,7 @@ describe('run lifecycle and quiescence', () => { request(undefined, controller.signal), { cwd: process.cwd(), + permissionMode: DEFAULT_CODEX_PERMISSION_MODE, env: {}, disposeGraceMs: 10, spawn, @@ -952,6 +1221,14 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(initialize, null) await expect(starting).rejects.toThrow('invalid initialize response') expect(child.terminate).toHaveBeenCalledTimes(1) + + const stderrChild = fakeChild() + const stderrStarting = startCodexRun(request(), runSpec(stderrChild)) + await stderrChild.peer.nextMethod('initialize') + stderrChild.stderr.emit('error', new Error('startup stderr broke')) + await expect(stderrStarting).rejects.toThrow('startup stderr broke') + expect(stderrChild.terminate).toHaveBeenCalledTimes(1) + expect(stderrChild.stderr.listenerCount('error')).toBe(0) }) it('rolls back an abort that wins immediately after thread creation', async () => { @@ -965,6 +1242,11 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ + cwd: process.cwd(), + ephemeral: true, + approvalPolicy: 'never', + }) child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) controller.abort('startup race') await expect(starting).rejects.toThrow('aborted before run publication') @@ -1012,6 +1294,55 @@ describe('run lifecycle and quiescence', () => { await Promise.all(runs.map(entry => entry.run.dispose())) }) + it('isolates permission modes and diagnostics across overlapping runs', async () => { + const first = await publishRun(fakeChild(), undefined, { + permissionMode: 'never', + }) + const second = await publishRun(fakeChild(), undefined, { + permissionMode: 'dangerously-bypass-approvals-and-sandbox', + }) + first.child.peer.respond(first.turnStart, { turn: { id: 'turn-never' } }) + second.child.peer.respond(second.turnStart, { turn: { id: 'turn-bypass' } }) + await nextTask() + first.child.peer.send({ + id: 'never-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-never', + availableDecisions: ['cancel'], + }, + }) + second.child.peer.send({ + id: 'bypass-elicitation', + method: 'mcpServer/elicitation/request', + params: { threadId: 'thread-1', turnId: null }, + }) + await Promise.all([ + first.child.peer.nextResponse('never-approval'), + second.child.peer.nextResponse('bypass-elicitation'), + ]) + first.child.peer.send(turnCompleted('failed', 'turn-never', 'thread-1', { + message: 'first failure', + codexErrorInfo: 'other', + })) + second.child.peer.send(turnCompleted('failed', 'turn-bypass', 'thread-1', { + message: 'second failure', + codexErrorInfo: 'other', + })) + await expect(first.run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + stopReason: 'error', + }) + await expect(second.run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', + stopReason: 'error', + }) + await Promise.all([first.run.dispose(), second.run.dispose()]) + }) + it('uses the registered provider config and logs flattened errors', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) @@ -1024,6 +1355,7 @@ describe('run lifecycle and quiescence', () => { }) as typeof ctx.logger.warn await ctx.plugin(codex, { env: { OPENAI_API_KEY: 'fake' }, + permissionMode: 'approve-for-me', disposeGraceMs: 25, }) const starting = ctx.subagents.start('codex', { @@ -1035,20 +1367,50 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ + cwd: process.cwd(), + ephemeral: true, + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: 'workspace-write', + }) child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) const run = await starting - await child.peer.nextMethod('turn/start') - child.settle({ exitCode: 1, signal: null }) - await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send({ + id: 'provider-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + command: 'cat /private/secret.txt', + }, + }) + await child.peer.nextResponse('provider-approval') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: 'other', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval', + stopReason: 'error', + }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + argv: ['codex', 'app-server', '--stdio'], env: { OPENAI_API_KEY: 'fake' }, graceMs: 25, cwd: process.cwd(), })) expect(warnings).toEqual([ - expect.stringContaining('subagent-codex: child run failed (error):'), + expect.stringContaining('subagent-codex: child run failed (error): subagent-codex: Codex turn ended with status failed: error'), ]) - await run.dispose().catch(() => {}) + expect(warnings.join('\n')).not.toContain('SECRET_TOKEN') + expect(warnings.join('\n')).not.toContain('/private/secret.txt') + await run.dispose() await ctx.fiber.dispose() }) }) From a3deb9aa5ed874a638d728a3cc7b8bf86a66281b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 18:15:53 +0800 Subject: [PATCH 26/95] fix(subagent): keep Claude plan mode non-executing --- ...agent-noninteractive-permissions.i18n.yaml | 4 ++-- ...uct-subagent-noninteractive-permissions.md | 2 +- ...-subagent-noninteractive-permissions.zh.md | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 ++-- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent/subagent-claude-code/src/run.ts | 20 ++++------------ .../tests/real-product.spec.ts | 10 ++++++-- .../tests/subagent-claude-code.spec.ts | 23 ++++++------------- 9 files changed, 28 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 477c20bdc4..75cd0ef2da 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: d4d29d982e5eb2a06f7cb710860ce72c506c4ade -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 3431465e6240e169dd8d240628d651348ac029b7 +2026-08-15-product-subagent-noninteractive-permissions.md: 9ab4887dda61895161392e7ff3aee164e765ee26 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: f6d7b438fb0e9b501640be96c298f8690d1313d3 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index d4d29d982e..9ab4887dda 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -24,7 +24,7 @@ The Claude Code Provider owns one Profile-level `permissionMode` value. It defau The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. -Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; in plan mode, `ExitPlanMode` receives a fixed denial that tells the model to return the completed plan without executing it. MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. +Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; plan mode also places `ExitPlanMode` in `disallowedTools`, so native allow rules cannot switch the unattended query back to execution. MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. ### Failure diagnostic diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 3431465e62..f6d7b438fb 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -24,7 +24,7 @@ Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认 提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 -每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;在 plan 模式下,`ExitPlanMode` 会收到一项固定拒绝,要求模型返回完整计划且不得执行。MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 +每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;plan 模式还会把 `ExitPlanMode` 放入 `disallowedTools`,因此原生 allow 规则无法把无人值守 query 切回执行模式。MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 ### 失败诊断 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 28fc01c965..0185afd2de 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: e7c5debddfdc740802d7bc25c2a863c7de287d07 -README.zh.md: 9e68b5f3f3824ffc3913fdba159c95b5c94353c9 +README.md: be3b2262addc487e545fed1f792600a9a5ca24c0 +README.zh.md: 7ea1b8ca7243790afd387b04d776088cea012718 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index e7c5debddf..be3b2262ad 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -16,7 +16,7 @@ Local cancellation wins the result race and maps to `aborted`. `dispose()` is id The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks. -Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. In plan mode, the `ExitPlanMode` approval is denied with a fixed instruction to return the completed plan as the final answer without executing it. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. +Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. ## Capabilities and context diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 9e68b5f3f3..7ea1b8ca72 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -16,7 +16,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。 -每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。在 plan 模式下,`ExitPlanMode` 审批会被拒绝,同时用固定指令要求模型把完整计划作为最终答案返回且不得执行。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 ## 能力与上下文 diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 0134c09086..82dcfb4eb4 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -59,7 +59,7 @@ const SUPPORTED_UNATTENDED_DIALOG_KINDS = [ function unattendedDiagnostic( mode: ClaudeCodePermissionMode, - request: 'tool permission' | 'plan approval' | 'MCP elicitation' | 'user dialog', + request: 'tool permission' | 'MCP elicitation' | 'user dialog', decision: 'denied' | 'declined' | 'cancelled', reason: string, ): string { @@ -223,24 +223,14 @@ export function claudeQueryOptions( pathToClaudeCodeExecutable: spec.executable, env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, - disallowedTools: ['AskUserQuestion'], + disallowedTools: spec.permissionMode === 'plan' + ? ['AskUserQuestion', 'ExitPlanMode'] + : ['AskUserQuestion'], permissionMode: spec.permissionMode, ...spec.permissionMode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : { - canUseTool: (toolName) => { - if (spec.permissionMode === 'plan' && toolName === 'ExitPlanMode') { - captureDiagnostic(unattendedDiagnostic( - spec.permissionMode, - 'plan approval', - 'denied', - 'the provider returns the plan without approving execution', - )) - return Promise.resolve({ - behavior: 'deny' as const, - message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.', - }) - } + canUseTool: () => { captureDiagnostic(unattendedDiagnostic( spec.permissionMode, 'tool permission', 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 c97f18481f..a2e7111ece 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -127,6 +127,7 @@ interface RealHarness { async function realHarness( behavior: MessagesBehavior, permissionMode?: ClaudeCodePermissionMode, + nativeAllow: readonly string[] = [], ): Promise<{ readonly harness: RealHarness readonly fixture: MessagesFixture @@ -151,7 +152,10 @@ async function realHarness( join(claudeConfig, 'settings.json'), `${JSON.stringify({ model: settingsModel, - permissions: { defaultMode: 'default' }, + permissions: { + defaultMode: 'default', + ...nativeAllow.length === 0 ? {} : { allow: nativeAllow }, + }, }, null, 2)}\n`, ) const fixture = await startMessagesFixture(behavior) @@ -367,13 +371,15 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 toolName: 'ExitPlanMode', input: {}, finalText: 'PLAN_ONLY_RESULT', - }, 'plan') + }, 'plan', ['ExitPlanMode']) const run = await startRequest(harness, 'Design the fixture change without implementing it.') await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'PLAN_ONLY_RESULT' }], stopReason: 'completed', }) expect(fixture.requests).toHaveLength(2) + expect(JSON.stringify(fixture.requests[1]?.body.messages)) + .toContain('ExitPlanMode exists but is not enabled in this context') await run.dispose() await expectQuiescent(harness.handles) }) 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 f0e1f4a1e1..b5be0987ca 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 @@ -679,6 +679,9 @@ describe('query options and result mapping', () => { spawn: () => child.handle, }, new AbortController(), () => {}, () => {}) expect(options.permissionMode).toBe(permissionMode) + expect(options.disallowedTools).toEqual(permissionMode === 'plan' + ? ['AskUserQuestion', 'ExitPlanMode'] + : ['AskUserQuestion']) if (permissionMode === 'bypassPermissions') { expect(options.allowDangerouslySkipPermissions).toBe(true) expect(options).not.toHaveProperty('canUseTool') @@ -689,9 +692,8 @@ describe('query options and result mapping', () => { }, ) - it('returns a plan without approving ExitPlanMode execution', async () => { + it('disallows ExitPlanMode before native plan-mode allow rules', () => { const child = fakeChild() - const diagnostics: string[] = [] const options = claudeQueryOptions({ cwd: '/workspace', executable: '/native/claude', @@ -699,21 +701,10 @@ describe('query options and result mapping', () => { env: {}, disposeGraceMs: 17, spawn: () => child.handle, - }, new AbortController(), () => {}, value => diagnostics.push(value)) - await expect(options.canUseTool!( + }, new AbortController(), () => {}, () => {}) + expect(options.disallowedTools).toEqual([ + 'AskUserQuestion', 'ExitPlanMode', - {}, - { - signal: new AbortController().signal, - toolUseID: 'exit-plan', - requestId: 'exit-plan-request', - }, - )).resolves.toEqual({ - behavior: 'deny', - message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.', - }) - expect(diagnostics).toEqual([ - 'Claude Code unattended decision (mode: plan; request: plan approval; decision: denied): the provider returns the plan without approving execution', ]) }) From a016e17393d43ca57a0c41884805698a45a232f6 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 18:39:30 +0800 Subject: [PATCH 27/95] test(subagent): cover Codex permission branches --- .../tests/subagent-codex.spec.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index b09e2ce46c..be7e0eb8f6 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -666,6 +666,17 @@ describe('CodexAppServerWire', () => { child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() const requests = [ + { + id: 'command-decline', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline'], + }, + result: { decision: 'decline' }, + diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: declined): the provider does not grant interactive approval', + }, { id: 'file', method: 'item/fileChange/requestApproval', @@ -677,6 +688,17 @@ describe('CodexAppServerWire', () => { result: { decision: 'decline' }, diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval', }, + { + id: 'file-cancel', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + }, + result: { decision: 'cancel' }, + diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: cancelled): the provider does not grant interactive approval', + }, { id: 'file-default', method: 'item/fileChange/requestApproval', @@ -742,11 +764,29 @@ describe('CodexAppServerWire', () => { wire.close() }) - it('records a declined command item without retaining its payload', async () => { + it('records declined command and file items without retaining their payloads', async () => { const { child, wire } = await initializeWire() const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'commandExecution', + status: 'declined', + command: 'cat /private/secret.txt', + }, + }, + }) + await nextTask() + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode', + ) + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + child.peer.send( { method: 'item/completed', @@ -754,9 +794,9 @@ describe('CodexAppServerWire', () => { threadId: 'thread-1', turnId: 'turn-1', item: { - type: 'commandExecution', + type: 'fileChange', status: 'declined', - command: 'cat /private/secret.txt', + patch: 'SECRET_TOKEN in /private/secret.txt', }, }, }, @@ -767,7 +807,7 @@ describe('CodexAppServerWire', () => { ) await expect(result).rejects.toThrow('status failed') expect(wire.collectDiagnostic()).toBe( - 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode', + 'Codex unattended decision (mode: never; request: file change; decision: declined): Codex declined the file change under the selected permission mode', ) expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN') expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') From 34db64d90d77ea8c5646c5c93ea5a6451fcd851a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 19:02:04 +0800 Subject: [PATCH 28/95] refactor(subagent): simplify Codex permission runtime --- packages/subagent/subagent-codex/src/run.ts | 20 ++---- packages/subagent/subagent-codex/src/wire.ts | 2 +- .../tests/subagent-codex.spec.ts | 68 ++++++++++++------- 3 files changed, 51 insertions(+), 39 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 1c596b806a..086b6a588b 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -157,11 +157,10 @@ export async function startCodexRun( process.stderr.write(chunk) wire.observeStderr(chunk.toString()) } - const stderrFailure = Promise.withResolvers() - const onStderrError = (error: Error): void => { - stderrFailure.reject(error) + const onStderrError = (): void => { + // Stderr observation is auxiliary. JSON-RPC and child.done remain the + // only terminal authorities if the diagnostic stream itself fails. } - void stderrFailure.promise.catch(() => {}) child.stderr?.on('data', onStderr) child.stderr?.on('error', onStderrError) const disposeProcess = async (): Promise => { @@ -195,16 +194,8 @@ export async function startCodexRun( try { wire.start() - await Promise.race([ - wire.initialize(request.signal), - processFailure, - stderrFailure.promise, - ]) - await Promise.race([ - wire.startThread(spec.cwd, request.signal), - processFailure, - stderrFailure.promise, - ]) + await Promise.race([wire.initialize(request.signal), processFailure]) + await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) try { @@ -226,7 +217,6 @@ export async function startCodexRun( attempt: () => Promise.race([ wire.runTurn(texts, runAbort.signal), processFailure, - stderrFailure.promise, ]), collectOutput, collectDiagnostic: () => wire.collectDiagnostic(), diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index c4274c8b2c..9fe5036d53 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -151,7 +151,7 @@ export class CodexAppServerWire { constructor( private readonly input: Readable, output: Writable, - private readonly permissionMode: CodexPermissionMode = 'never', + private readonly permissionMode: CodexPermissionMode, ) { this.transport = new JsonRpcLineTransport(input, output) // Fatal protocol state can arrive after the current guarded operation has diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index be7e0eb8f6..a50b9b488a 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -182,6 +182,14 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { } } +function defaultWire(child: FakeChild): CodexAppServerWire { + return new CodexAppServerWire( + child.handle.stdout!, + child.handle.stdin!, + DEFAULT_CODEX_PERMISSION_MODE, + ) +} + function runSpec( child: FakeChild, overrides: Partial = {}, @@ -201,7 +209,7 @@ async function initializeWire(): Promise<{ readonly wire: CodexAppServerWire }> { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const initializing = wire.initialize(new AbortController().signal) const initialize = await child.peer.nextMethod('initialize') @@ -426,7 +434,7 @@ describe('task admission and package contracts', () => { describe('CodexAppServerWire', () => { it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) expect(wire.collectOutput()).toEqual([]) wire.start() @@ -540,7 +548,7 @@ describe('CodexAppServerWire', () => { it('rejects invalid handshake, thread, and turn response shapes', async () => { { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) const frame = await child.peer.nextMethod('initialize') @@ -550,7 +558,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.startThread('/workspace', new AbortController().signal) const frame = await child.peer.nextMethod('thread/start') @@ -1031,7 +1039,7 @@ describe('CodexAppServerWire', () => { it('rejects pending work on abort, EOF, and stream error', async () => { { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const controller = new AbortController() controller.abort('pre-aborted') @@ -1041,7 +1049,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const controller = new AbortController() const pending = wire.initialize(controller.signal) @@ -1052,7 +1060,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) await child.peer.nextMethod('initialize') @@ -1062,7 +1070,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) await child.peer.nextMethod('initialize') @@ -1072,7 +1080,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) await child.peer.nextMethod('initialize') @@ -1172,13 +1180,14 @@ describe('run lifecycle and quiescence', () => { } { const child = fakeChild() - const { run, turnStart } = await publishRun(child, undefined, { - onError: (error) => { errors.push(error.message) }, - }) + const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.stderr.emit('error', new Error('stderr broke')) - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) - expect(errors.at(-1)).toContain('stderr broke') + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) await run.dispose() expect(child.stderr.listenerCount('error')).toBe(0) } @@ -1264,10 +1273,23 @@ describe('run lifecycle and quiescence', () => { const stderrChild = fakeChild() const stderrStarting = startCodexRun(request(), runSpec(stderrChild)) - await stderrChild.peer.nextMethod('initialize') + const stderrInitialize = await stderrChild.peer.nextMethod('initialize') stderrChild.stderr.emit('error', new Error('startup stderr broke')) - await expect(stderrStarting).rejects.toThrow('startup stderr broke') - expect(stderrChild.terminate).toHaveBeenCalledTimes(1) + stderrChild.peer.respond(stderrInitialize, { userAgent: 'codex-cli 0.147.0' }) + await stderrChild.peer.nextMethod('initialized') + const stderrThreadStart = await stderrChild.peer.nextMethod('thread/start') + stderrChild.peer.respond(stderrThreadStart, { + thread: { id: 'thread-1', ephemeral: true }, + }) + const stderrRun = await stderrStarting + const stderrTurnStart = await stderrChild.peer.nextMethod('turn/start') + stderrChild.peer.send( + { id: stderrTurnStart.id, result: { turn: { id: 'turn-1' } } }, + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(stderrRun.result).resolves.toMatchObject({ stopReason: 'completed' }) + await stderrRun.dispose() expect(stderrChild.stderr.listenerCount('error')).toBe(0) }) @@ -1458,7 +1480,7 @@ describe('run lifecycle and quiescence', () => { describe('disposeCodexChild', () => { it('closes stdin, terminates, and waits for the managed tree', async () => { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) const end = vi.spyOn(child.toChild, 'end') await disposeCodexChild(wire, child.handle) expect(end).toHaveBeenCalled() @@ -1469,7 +1491,7 @@ describe('disposeCodexChild', () => { it('does not finish disposal before the managed tree exits', async () => { const child = fakeChild({ exitOnTerminate: false }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) let disposed = false const disposal = disposeCodexChild(wire, child.handle).then(() => { disposed = true @@ -1483,7 +1505,7 @@ describe('disposeCodexChild', () => { it('contains a concurrently closed stdin error', async () => { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) vi.spyOn(child.toChild, 'end').mockImplementation(() => { throw new Error('already closed') }) @@ -1496,7 +1518,7 @@ describe('disposeCodexChild', () => { pid: -1, doneError: new Error('spawn failed'), }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() expect(child.terminate).not.toHaveBeenCalled() @@ -1508,14 +1530,14 @@ describe('disposeCodexChild', () => { const child = fakeChild({ doneError: new Error('close observer failed'), }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) .rejects.toThrow('close observer failed') } { const child = fakeChild() const handle = { ...child.handle, stdin: undefined } - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() } }) From cfcecbf0c7fa1f8a33de1f280cced62cda43b2bb Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 19:11:24 +0800 Subject: [PATCH 29/95] fix(subagent): stabilize Codex permission diagnostics --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 2 +- ...bagent-one-shot-background-tasks.i18n.yaml | 4 +- ...duct-subagent-one-shot-background-tasks.md | 2 +- ...t-subagent-one-shot-background-tasks.zh.md | 2 +- packages/subagent/subagent-codex/src/run.ts | 23 ++++-- packages/subagent/subagent-codex/src/wire.ts | 56 +++++++++----- .../tests/subagent-codex.spec.ts | 75 ++++++++++++++++++- 9 files changed, 139 insertions(+), 31 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 597b078939..777fff4e2a 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: 49c3e3fc6a99cae23b606f5a680320307c79d08c -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dc3a0737b9cfe00a850697ca482fad2743105058 +2026-08-04-claude-code-and-codex-subagent-backends.md: f65c0626ad22db8f3e7d2a543c7aa87e58df54d4 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 97ac527b8e89cc07d65aa28102ba43d648b1b64c 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 49c3e3fc6a..f65c0626ad 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 provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile-selected mode and the shared failure diagnostic. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +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 [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. 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 dc3a0737b9..97ac527b8e 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 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责 Claude Code 的 Profile 模式选择与共享失败诊断。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index b6cad9b39f..cec2cc269a 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: 9aeccfadbad0d8f44ac2c294c4008b672f855027 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 74a0a614847543aff5f88cc5696246a76f2bb72f +2026-08-12-product-subagent-one-shot-background-tasks.md: 248bb943f8ee46a7050c373b6b7c3f7dec65d566 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d6867a97561e7efbe2b152b6c45991553393b4e7 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index 9aeccfadba..248bb943f8 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -14,7 +14,7 @@ Exposing background execution must not add a product session, product-specific j Production `dsh` does not install the optional product providers. A Profile that opts in installs and mounts `dsh-subagent-codex`, `dsh-subagent-claude-code`, or both once on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. -The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile configuration and diagnostic production. +The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index 74a0a61484..d6867a9756 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -14,7 +14,7 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装 `dsh-subagent-codex`、`dsh-subagent-claude-code` 或两者,并在 host plane(宿主平面)各挂载一次。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 -[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责 Claude Code 的 Profile 配置与诊断生产。 +[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 配置与诊断生产。 本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 086b6a588b..94183802f1 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -154,21 +154,27 @@ export async function startCodexRun( spec.permissionMode, ) const onStderr = (chunk: Buffer | string): void => { - process.stderr.write(chunk) wire.observeStderr(chunk.toString()) } const onStderrError = (): void => { // Stderr observation is auxiliary. JSON-RPC and child.done remain the // only terminal authorities if the diagnostic stream itself fails. } + const onHostStderrError = (): void => { + // Host stderr is an observation sink, not a child-run failure authority. + } child.stderr?.on('data', onStderr) child.stderr?.on('error', onStderrError) + process.stderr.on('error', onHostStderrError) + child.stderr?.pipe(process.stderr, { end: false }) const disposeProcess = async (): Promise => { try { await disposeCodexChild(wire, child) } finally { + child.stderr?.unpipe(process.stderr) child.stderr?.off('data', onStderr) child.stderr?.off('error', onStderrError) + process.stderr.off('error', onHostStderrError) } } @@ -214,10 +220,17 @@ export async function startCodexRun( const collectOutput = (): ContentBlock[] => wire.collectOutput() const result: Promise = settleRunResult({ - attempt: () => Promise.race([ - wire.runTurn(texts, runAbort.signal), - processFailure, - ]), + attempt: async () => { + try { + return await Promise.race([ + wire.runTurn(texts, runAbort.signal), + processFailure, + ]) + } catch (error: unknown) { + await new Promise((resolve) => { setImmediate(resolve) }) + throw error + } + }, collectOutput, collectDiagnostic: () => wire.collectDiagnostic(), cancelled: () => runAbort.signal.aborted, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 9fe5036d53..6e617891b1 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -47,6 +47,21 @@ const STDERR_SIGNATURE_TAIL_CHARS = Math.max( ...STDERR_PERMISSION_SIGNATURES.map(signature => signature.text.length), ) - 1 +function stderrSignatureTail(value: string): string { + for ( + let length = Math.min(STDERR_SIGNATURE_TAIL_CHARS, value.length) + ; length > 0 + ; length -= 1 + ) { + const tail = value.slice(-length) + if (STDERR_PERMISSION_SIGNATURES.some(signature => + tail.length < signature.text.length && signature.text.startsWith(tail))) { + return tail + } + } + return '' +} + function object(value: unknown, label: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) @@ -318,7 +333,7 @@ export class CodexAppServerWire { if (latest !== undefined) { this.recordDiagnostic(latest.request, latest.decision, latest.reason) } - this.stderrTail = observed.slice(-STDERR_SIGNATURE_TAIL_CHARS) + this.stderrTail = stderrSignatureTail(observed) } /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ @@ -399,6 +414,26 @@ export class CodexAppServerWire { ) } + private recordDeclinedItem(item: JsonObject): boolean { + if (item.type === 'commandExecution' && item.status === 'declined') { + this.recordDiagnostic( + 'command execution', + 'declined', + 'Codex declined the command under the selected permission mode', + ) + return true + } + if (item.type === 'fileChange' && item.status === 'declined') { + this.recordDiagnostic( + 'file change', + 'declined', + 'Codex declined the file change under the selected permission mode', + ) + return true + } + return false + } + private handleServerRequest(method: string, params: JsonObject): Promise { try { switch (method) { @@ -475,28 +510,15 @@ export class CodexAppServerWire { if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { this.observePendingTurnId(id) + const item = object(params.item, 'item/completed item') + if (this.recordDeclinedItem(item)) return this.earlyTurnNotifications.push({ method, params }) } return } if (id !== this.turnId) return const item = object(params.item, 'item/completed item') - if (item.type === 'commandExecution' && item.status === 'declined') { - this.recordDiagnostic( - 'command execution', - 'declined', - 'Codex declined the command under the selected permission mode', - ) - return - } - if (item.type === 'fileChange' && item.status === 'declined') { - this.recordDiagnostic( - 'file change', - 'declined', - 'Codex declined the file change under the selected permission mode', - ) - return - } + if (this.recordDeclinedItem(item)) return if (item.type !== 'agentMessage') return const text = typeof item.text === 'string' ? item.text diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index a50b9b488a..0fd2642d52 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -868,7 +868,7 @@ describe('CodexAppServerWire', () => { it('does not reapply an old stderr signature after a newer request diagnostic', async () => { const { child, wire } = await initializeWire() - wire.observeStderr('approval policy is Never; reject command') + wire.observeStderr('recorded sandbox violation:') const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -891,6 +891,36 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('keeps a newer request diagnostic after replaying an older early item', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { type: 'fileChange', status: 'declined' }, + }, + }) + await nextTask() + child.peer.send({ + id: 'newer-command-request', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + }, + }) + await child.peer.nextResponse('newer-command-request') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(wire.collectDiagnostic()).toContain('request: command approval') + wire.close() + }) + it('fails the run on unknown requests or wrong request association', async () => { for (const serverRequest of [ { @@ -1222,11 +1252,37 @@ describe('run lifecycle and quiescence', () => { await run.dispose() }) + it('drains queued stderr before settling a failed published run', async () => { + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + setImmediate(() => { + child.stderr.write('approval policy is Never; reject command') + }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + await run.dispose() + write.mockRestore() + }) + it('forwards stderr while extracting only a fixed safe permission signature', async () => { const child = fakeChild() const forwarded: string[] = [] + let writes = 0 const write = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { forwarded.push(String(chunk)) + writes += 1 + if (writes === 1) { + setImmediate(() => { process.stderr.emit('drain') }) + return false + } return true }) const { run, turnStart } = await publishRun(child) @@ -1243,11 +1299,28 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(forwarded.join('')).toContain('SECRET_TOKEN') + expect(writes).toBe(2) await run.dispose() expect(child.stderr.listenerCount('data')).toBe(0) write.mockRestore() }) + it('contains host stderr errors without changing run settlement', async () => { + const child = fakeChild() + const initialErrorListeners = process.stderr.listenerCount('error') + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + expect(process.stderr.listenerCount('error')).toBeGreaterThan(initialErrorListeners) + process.stderr.emit('error', new Error('host stderr broke')) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + await run.dispose() + expect(process.stderr.listenerCount('error')).toBe(initialErrorListeners) + }) + it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { const controller = new AbortController() controller.abort() From d1e9dcae7a0e86c9322638ee055661881b77e5d3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 19:33:03 +0800 Subject: [PATCH 30/95] refactor(subagent): redesign Codex stderr diagnostics --- packages/subagent/subagent-codex/src/run.ts | 16 +-- packages/subagent/subagent-codex/src/wire.ts | 43 +++++-- .../tests/subagent-codex.spec.ts | 116 +++++++++++++----- 3 files changed, 127 insertions(+), 48 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 94183802f1..c5eb7c1e61 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -8,6 +8,7 @@ */ import { randomUUID } from 'node:crypto' +import { writeSync } from 'node:fs' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -154,27 +155,26 @@ export async function startCodexRun( spec.permissionMode, ) const onStderr = (chunk: Buffer | string): void => { - wire.observeStderr(chunk.toString()) + const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk + wire.observeStderr(bytes.toString()) + try { + writeSync(process.stderr.fd, bytes) + } catch { + // Host stderr is an observation sink, not a child-run failure authority. + } } const onStderrError = (): void => { // Stderr observation is auxiliary. JSON-RPC and child.done remain the // only terminal authorities if the diagnostic stream itself fails. } - const onHostStderrError = (): void => { - // Host stderr is an observation sink, not a child-run failure authority. - } child.stderr?.on('data', onStderr) child.stderr?.on('error', onStderrError) - process.stderr.on('error', onHostStderrError) - child.stderr?.pipe(process.stderr, { end: false }) const disposeProcess = async (): Promise => { try { await disposeCodexChild(wire, child) } finally { - child.stderr?.unpipe(process.stderr) child.stderr?.off('data', onStderr) child.stderr?.off('error', onStderrError) - process.stderr.off('error', onHostStderrError) } } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 6e617891b1..28777b7d7a 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -156,10 +156,13 @@ export class CodexAppServerWire { private readonly earlyTurnNotifications: Array<{ readonly method: string readonly params: JsonObject + readonly order: number }> = [] private lastFinalAnswer: string | undefined private lastUnphasedAnswer: string | undefined private diagnostic: string | undefined + private diagnosticOrder = 0 + private observationOrder = 0 private stderrTail = '' private closed = false @@ -382,7 +385,11 @@ export class CodexAppServerWire { this.turnId = id const notifications = this.earlyTurnNotifications.splice(0) for (const notification of notifications) { - this.handleNotification(notification.method, notification.params) + this.handleNotification( + notification.method, + notification.params, + notification.order, + ) } } @@ -405,7 +412,10 @@ export class CodexAppServerWire { request: Parameters[1], decision: Parameters[2], reason: string, + order = this.nextObservationOrder(), ): void { + if (order < this.diagnosticOrder) return + this.diagnosticOrder = order this.diagnostic = unattendedDiagnostic( this.permissionMode, request, @@ -414,12 +424,18 @@ export class CodexAppServerWire { ) } - private recordDeclinedItem(item: JsonObject): boolean { + private nextObservationOrder(): number { + this.observationOrder += 1 + return this.observationOrder + } + + private recordDeclinedItem(item: JsonObject, order?: number): boolean { if (item.type === 'commandExecution' && item.status === 'declined') { this.recordDiagnostic( 'command execution', 'declined', 'Codex declined the command under the selected permission mode', + order, ) return true } @@ -428,6 +444,7 @@ export class CodexAppServerWire { 'file change', 'declined', 'Codex declined the file change under the selected permission mode', + order, ) return true } @@ -493,7 +510,11 @@ export class CodexAppServerWire { } } - private handleNotification(method: string, params: JsonObject): void { + private handleNotification( + method: string, + params: JsonObject, + order?: number, + ): void { if (method === 'turn/started') { const threadId = string(params.threadId, 'turn/started thread id') if (threadId !== this.threadId) return @@ -510,15 +531,17 @@ export class CodexAppServerWire { if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { this.observePendingTurnId(id) - const item = object(params.item, 'item/completed item') - if (this.recordDeclinedItem(item)) return - this.earlyTurnNotifications.push({ method, params }) + this.earlyTurnNotifications.push({ + method, + params, + order: this.nextObservationOrder(), + }) } return } if (id !== this.turnId) return const item = object(params.item, 'item/completed item') - if (this.recordDeclinedItem(item)) return + if (this.recordDeclinedItem(item, order)) return if (item.type !== 'agentMessage') return const text = typeof item.text === 'string' ? item.text @@ -541,7 +564,11 @@ export class CodexAppServerWire { if (turnCompleted === undefined) return if (this.turnId === undefined) { this.observePendingTurnId(id) - this.earlyTurnNotifications.push({ method, params }) + this.earlyTurnNotifications.push({ + method, + params, + order: this.nextObservationOrder(), + }) return } if (id !== this.turnId) return diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 0fd2642d52..36c7185952 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -26,6 +26,37 @@ import { } from '../src/run.ts' import { CodexAppServerWire } from '../src/wire.ts' +const { hostStderrWrite } = vi.hoisted(() => ({ + hostStderrWrite: { + capture: false, + failNext: false, + chunks: [] as Buffer[], + }, +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeSync(fd: number, value: string | Uint8Array): number { + if (fd === 2 && hostStderrWrite.capture) { + if (hostStderrWrite.failNext) { + hostStderrWrite.failNext = false + throw Object.assign(new Error('host stderr broke'), { code: 'EIO' }) + } + const bytes = typeof value === 'string' + ? Buffer.from(value) + : Buffer.from(value.buffer, value.byteOffset, value.byteLength) + hostStderrWrite.chunks.push(bytes) + return bytes.byteLength + } + return typeof value === 'string' + ? actual.writeSync(fd, value, null, 'utf8') + : actual.writeSync(fd, value, 0, value.byteLength, null) + }, + } +}) + type JsonObject = Record const fakeParent = { @@ -983,6 +1014,24 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('does not retain a diagnostic from a mismatched early item', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-early', + item: { type: 'fileChange', status: 'declined' }, + }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + expect(wire.collectDiagnostic()).toBeUndefined() + wire.close() + }) + it('rejects conflicting early notifications and requests before turn/start', async () => { { const { child, wire } = await initializeWire() @@ -1253,7 +1302,8 @@ describe('run lifecycle and quiescence', () => { }) it('drains queued stderr before settling a failed published run', async () => { - const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + hostStderrWrite.capture = true + hostStderrWrite.chunks.length = 0 const { child, run, turnStart } = await publishRun() child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { @@ -1269,26 +1319,18 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) await run.dispose() - write.mockRestore() + hostStderrWrite.capture = false }) it('forwards stderr while extracting only a fixed safe permission signature', async () => { const child = fakeChild() - const forwarded: string[] = [] - let writes = 0 - const write = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { - forwarded.push(String(chunk)) - writes += 1 - if (writes === 1) { - setImmediate(() => { process.stderr.emit('drain') }) - return false - } - return true - }) + hostStderrWrite.capture = true + hostStderrWrite.chunks.length = 0 const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.stderr.write('SECRET_TOKEN approval policy is Ne') child.stderr.write('ver; reject command — /private/secret.txt') + child.stderr.emit('data', 'string stderr suffix') child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { message: 'fixture terminal failure', codexErrorInfo: 'badRequest', @@ -1298,27 +1340,27 @@ describe('run lifecycle and quiescence', () => { diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', stopReason: 'error', }) - expect(forwarded.join('')).toContain('SECRET_TOKEN') - expect(writes).toBe(2) + expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN') + expect(hostStderrWrite.chunks).toHaveLength(3) await run.dispose() expect(child.stderr.listenerCount('data')).toBe(0) - write.mockRestore() + hostStderrWrite.capture = false }) - it('contains host stderr errors without changing run settlement', async () => { + it('contains host stderr write failures without changing run settlement', async () => { const child = fakeChild() - const initialErrorListeners = process.stderr.listenerCount('error') + hostStderrWrite.capture = true + hostStderrWrite.failNext = true const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - expect(process.stderr.listenerCount('error')).toBeGreaterThan(initialErrorListeners) - process.stderr.emit('error', new Error('host stderr broke')) + child.stderr.write('forwarding failure') child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed', }) await run.dispose() - expect(process.stderr.listenerCount('error')).toBe(initialErrorListeners) + hostStderrWrite.capture = false }) it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { @@ -1406,12 +1448,21 @@ describe('run lifecycle and quiescence', () => { }) it('keeps overlapping runs isolated', async () => { - const first = fakeChild() - const second = fakeChild() - const runs = await Promise.all([ - publishRun(first), - publishRun(second), - ]) + const initialStderrListeners = { + error: process.stderr.listenerCount('error'), + unpipe: process.stderr.listenerCount('unpipe'), + close: process.stderr.listenerCount('close'), + finish: process.stderr.listenerCount('finish'), + } + const runs = await Promise.all( + Array.from({ length: 6 }, () => publishRun(fakeChild())), + ) + expect({ + error: process.stderr.listenerCount('error'), + unpipe: process.stderr.listenerCount('unpipe'), + close: process.stderr.listenerCount('close'), + finish: process.stderr.listenerCount('finish'), + }).toEqual(initialStderrListeners) for (const [index, entry] of runs.entries()) { const id = `turn-${index + 1}` entry.child.peer.send( @@ -1421,11 +1472,12 @@ describe('run lifecycle and quiescence', () => { ) } const results = await Promise.all(runs.map(entry => entry.run.result)) - expect(results.map(result => result.output)).toEqual([ - [{ type: 'text', text: 'answer-1' }], - [{ type: 'text', text: 'answer-2' }], - ]) - expect(runs[0].run.id).not.toBe(runs[1].run.id) + expect(results.map(result => result.output)).toEqual( + Array.from({ length: 6 }, (_, index) => [ + { type: 'text', text: `answer-${index + 1}` }, + ]), + ) + expect(runs[0]!.run.id).not.toBe(runs[1]!.run.id) await Promise.all(runs.map(entry => entry.run.dispose())) }) From 6ed3cab9b58fceaf0d15608c0f8a5ddd0e7b3419 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 19:58:08 +0800 Subject: [PATCH 31/95] fix(subagent): preserve Codex diagnostic ordering --- packages/subagent/subagent-codex/src/run.ts | 12 +- packages/subagent/subagent-codex/src/wire.ts | 119 +++++++++++++----- .../tests/subagent-codex.spec.ts | 115 +++++++++++++++-- 3 files changed, 200 insertions(+), 46 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index c5eb7c1e61..78f9a6fd9c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -158,7 +158,17 @@ export async function startCodexRun( const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk wire.observeStderr(bytes.toString()) try { - writeSync(process.stderr.fd, bytes) + let offset = 0 + while (offset < bytes.byteLength) { + const written = writeSync( + process.stderr.fd, + bytes, + offset, + bytes.byteLength - offset, + ) + if (written <= 0) throw new Error('subagent-codex: host stderr made no write progress') + offset += written + } } catch { // Host stderr is an observation sink, not a child-run failure authority. } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 28777b7d7a..a777b05331 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -152,7 +152,10 @@ export class CodexAppServerWire { private threadId: string | undefined private turnId: string | undefined private pendingTurnId: string | undefined - private turnCompleted: PromiseWithResolvers | undefined + private turnCompleted: PromiseWithResolvers<{ + readonly params: JsonObject + readonly order: number + }> | undefined private readonly earlyTurnNotifications: Array<{ readonly method: string readonly params: JsonObject @@ -163,6 +166,13 @@ export class CodexAppServerWire { private diagnostic: string | undefined private diagnosticOrder = 0 private observationOrder = 0 + private pendingDiagnostic: { + readonly turnId: string + readonly order: number + readonly request: Parameters[1] + readonly decision: Parameters[2] + readonly reason: string + } | undefined private stderrTail = '' private closed = false @@ -247,7 +257,10 @@ export class CodexAppServerWire { texts: readonly string[], signal: AbortSignal, ): Promise { - const completion = Promise.withResolvers() + const completion = Promise.withResolvers<{ + readonly params: JsonObject + readonly order: number + }>() this.turnCompleted = completion const threadId = this.threadId as string const response = object(await this.guarded(this.transport.request('turn/start', { @@ -258,7 +271,7 @@ export class CodexAppServerWire { this.commitTurnId(string(turn.id, 'turn/start turn id')) const completed = await this.guarded(completion.promise, signal) - const terminal = object(completed.turn, 'turn/completed turn') + const terminal = object(completed.params.turn, 'turn/completed turn') const status = terminal.status if (isContextWindowExceeded(terminal)) { return { output: this.collectOutput(), stopReason: 'max-tokens' } @@ -270,6 +283,7 @@ export class CodexAppServerWire { 'sandbox execution', 'failed', 'Codex reported a sandbox failure', + completed.order, ) } const detail = status === 'failed' @@ -383,6 +397,16 @@ export class CodexAppServerWire { throw new Error('subagent-codex: turn/start response did not match the active turn') } this.turnId = id + const pendingDiagnostic = this.pendingDiagnostic + this.pendingDiagnostic = undefined + if (pendingDiagnostic?.turnId === id) { + this.recordDiagnostic( + pendingDiagnostic.request, + pendingDiagnostic.decision, + pendingDiagnostic.reason, + pendingDiagnostic.order, + ) + } const notifications = this.earlyTurnNotifications.splice(0) for (const notification of notifications) { this.handleNotification( @@ -393,19 +417,43 @@ export class CodexAppServerWire { } } - private validateRunIds(params: JsonObject, nullableTurn = false): void { + private validateRunIds( + params: JsonObject, + nullableTurn = false, + ): string | undefined { if (params.threadId !== this.threadId) { throw new Error('subagent-codex: app-server request referenced another thread') } - if (nullableTurn && params.turnId === null) return + if (nullableTurn && params.turnId === null) return undefined const id = string(params.turnId, 'server request turn id') if (this.turnId === undefined) { this.observePendingTurnId(id) - return + return id } if (id !== this.turnId) { throw new Error('subagent-codex: app-server request referenced another turn') } + return undefined + } + + private recordRequestDiagnostic( + provisionalTurnId: string | undefined, + request: Parameters[1], + decision: Parameters[2], + reason: string, + ): void { + const order = this.nextObservationOrder() + if (provisionalTurnId !== undefined) { + this.pendingDiagnostic = { + turnId: provisionalTurnId, + order, + request, + decision, + reason, + } + return + } + this.recordDiagnostic(request, decision, reason, order) } private recordDiagnostic( @@ -455,46 +503,48 @@ export class CodexAppServerWire { try { switch (method) { case 'item/commandExecution/requestApproval': - this.validateRunIds(params) - { - const decision = unattendedDecision(params) - this.recordDiagnostic( - 'command approval', - decision === 'cancel' ? 'cancelled' : 'declined', - 'the provider does not grant interactive approval', - ) - return Promise.resolve({ decision }) - } + { + const provisionalTurnId = this.validateRunIds(params) + const decision = unattendedDecision(params) + this.recordRequestDiagnostic( + provisionalTurnId, + 'command approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/fileChange/requestApproval': - this.validateRunIds(params) - { - const decision = unattendedDecision(params) - this.recordDiagnostic( - 'file approval', - decision === 'cancel' ? 'cancelled' : 'declined', - 'the provider does not grant interactive approval', - ) - return Promise.resolve({ decision }) - } + { + const provisionalTurnId = this.validateRunIds(params) + const decision = unattendedDecision(params) + this.recordRequestDiagnostic( + provisionalTurnId, + 'file approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/permissions/requestApproval': - this.validateRunIds(params) - this.recordDiagnostic( + this.recordRequestDiagnostic( + this.validateRunIds(params), 'permission grant', 'denied', 'the provider grants no additional turn permissions', ) return Promise.resolve({ permissions: {}, scope: 'turn' }) case 'item/tool/requestUserInput': - this.validateRunIds(params) - this.recordDiagnostic( + this.recordRequestDiagnostic( + this.validateRunIds(params), 'user input', 'empty response', 'the provider does not collect interactive answers', ) return Promise.resolve({ answers: {} }) case 'mcpServer/elicitation/request': - this.validateRunIds(params, true) - this.recordDiagnostic( + this.recordRequestDiagnostic( + this.validateRunIds(params, true), 'MCP elicitation', 'declined', 'the provider does not collect interactive MCP input', @@ -575,6 +625,9 @@ export class CodexAppServerWire { if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) } - turnCompleted.resolve(params) + turnCompleted.resolve({ + params, + order: order ?? this.nextObservationOrder(), + }) } } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 36c7185952..1c63e11592 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -30,6 +30,8 @@ const { hostStderrWrite } = vi.hoisted(() => ({ hostStderrWrite: { capture: false, failNext: false, + zeroNext: false, + maxBytesPerWrite: undefined as number | undefined, chunks: [] as Buffer[], }, })) @@ -38,8 +40,17 @@ vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - writeSync(fd: number, value: string | Uint8Array): number { + writeSync( + fd: number, + value: string | Uint8Array, + offset?: number | null, + length?: number | null, + ): number { if (fd === 2 && hostStderrWrite.capture) { + if (hostStderrWrite.zeroNext) { + hostStderrWrite.zeroNext = false + return 0 + } if (hostStderrWrite.failNext) { hostStderrWrite.failNext = false throw Object.assign(new Error('host stderr broke'), { code: 'EIO' }) @@ -47,12 +58,26 @@ vi.mock('node:fs', async (importOriginal) => { const bytes = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value.buffer, value.byteOffset, value.byteLength) - hostStderrWrite.chunks.push(bytes) - return bytes.byteLength + const start = typeof value === 'string' ? 0 : offset ?? 0 + const requested = typeof value === 'string' + ? bytes.byteLength + : length ?? bytes.byteLength - start + const written = Math.min( + requested, + hostStderrWrite.maxBytesPerWrite ?? requested, + ) + hostStderrWrite.chunks.push(Buffer.from(bytes.subarray(start, start + written))) + return written } return typeof value === 'string' ? actual.writeSync(fd, value, null, 'utf8') - : actual.writeSync(fd, value, 0, value.byteLength, null) + : actual.writeSync( + fd, + value, + offset ?? 0, + length ?? value.byteLength - (offset ?? 0), + null, + ) }, } }) @@ -698,12 +723,13 @@ describe('CodexAppServerWire', () => { expect(await child.peer.nextResponse('command')).toMatchObject({ result: { decision: 'cancel' }, }) - expect(wire.collectDiagnostic()).toBe( - 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', - ) + expect(wire.collectDiagnostic()).toBeUndefined() child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + ) const requests = [ { id: 'command-decline', @@ -952,6 +978,25 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('keeps a newer stderr fact after replaying an older early terminal', async () => { + hostStderrWrite.capture = true + hostStderrWrite.chunks.length = 0 + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'sandbox failure', + codexErrorInfo: 'sandboxError', + })) + await nextTask() + wire.observeStderr('approval policy is Never; reject command') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await expect(result).rejects.toThrow('sandboxError') + expect(wire.collectDiagnostic()).toContain('request: command execution') + wire.close() + hostStderrWrite.capture = false + }) + it('fails the run on unknown requests or wrong request association', async () => { for (const serverRequest of [ { @@ -1032,6 +1077,26 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('does not retain a diagnostic from a mismatched provisional request', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + id: 'provisional-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-early', + availableDecisions: ['cancel'], + }, + }) + await child.peer.nextResponse('provisional-approval') + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + expect(wire.collectDiagnostic()).toBeUndefined() + wire.close() + }) + it('rejects conflicting early notifications and requests before turn/start', async () => { { const { child, wire } = await initializeWire() @@ -1325,6 +1390,7 @@ describe('run lifecycle and quiescence', () => { it('forwards stderr while extracting only a fixed safe permission signature', async () => { const child = fakeChild() hostStderrWrite.capture = true + hostStderrWrite.maxBytesPerWrite = 3 hostStderrWrite.chunks.length = 0 const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -1341,9 +1407,10 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN') - expect(hostStderrWrite.chunks).toHaveLength(3) + expect(hostStderrWrite.chunks.length).toBeGreaterThan(3) await run.dispose() expect(child.stderr.listenerCount('data')).toBe(0) + hostStderrWrite.maxBytesPerWrite = undefined hostStderrWrite.capture = false }) @@ -1353,11 +1420,35 @@ describe('run lifecycle and quiescence', () => { hostStderrWrite.failNext = true const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.stderr.write('forwarding failure') - child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + child.stderr.write('approval policy is Never; reject command') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) await expect(run.result).resolves.toEqual({ - output: [{ type: 'text', text: 'answer' }], - stopReason: 'completed', + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + await run.dispose() + hostStderrWrite.capture = false + }) + + it('contains a zero-progress host stderr write without losing the diagnostic', async () => { + const child = fakeChild() + hostStderrWrite.capture = true + hostStderrWrite.zeroNext = true + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.stderr.write('approval policy is Never; reject command') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', }) await run.dispose() hostStderrWrite.capture = false From 0ff3c236ecb6384302c20f729452af4d0cc9da5d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 20:09:04 +0800 Subject: [PATCH 32/95] refactor(subagent): simplify Codex diagnostic handoff --- packages/subagent/subagent-codex/src/run.ts | 14 +---- packages/subagent/subagent-codex/src/wire.ts | 24 ++++---- .../tests/subagent-codex.spec.ts | 58 ++----------------- 3 files changed, 19 insertions(+), 77 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 78f9a6fd9c..fdf467c876 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -8,7 +8,7 @@ */ import { randomUUID } from 'node:crypto' -import { writeSync } from 'node:fs' +import { writeFileSync } from 'node:fs' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -158,17 +158,7 @@ export async function startCodexRun( const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk wire.observeStderr(bytes.toString()) try { - let offset = 0 - while (offset < bytes.byteLength) { - const written = writeSync( - process.stderr.fd, - bytes, - offset, - bytes.byteLength - offset, - ) - if (written <= 0) throw new Error('subagent-codex: host stderr made no write progress') - offset += written - } + writeFileSync(process.stderr.fd, bytes) } catch { // Host stderr is an observation sink, not a child-run failure authority. } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index a777b05331..cfb24a7481 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -167,7 +167,6 @@ export class CodexAppServerWire { private diagnosticOrder = 0 private observationOrder = 0 private pendingDiagnostic: { - readonly turnId: string readonly order: number readonly request: Parameters[1] readonly decision: Parameters[2] @@ -399,7 +398,7 @@ export class CodexAppServerWire { this.turnId = id const pendingDiagnostic = this.pendingDiagnostic this.pendingDiagnostic = undefined - if (pendingDiagnostic?.turnId === id) { + if (pendingDiagnostic !== undefined) { this.recordDiagnostic( pendingDiagnostic.request, pendingDiagnostic.decision, @@ -420,32 +419,31 @@ export class CodexAppServerWire { private validateRunIds( params: JsonObject, nullableTurn = false, - ): string | undefined { + ): boolean { if (params.threadId !== this.threadId) { throw new Error('subagent-codex: app-server request referenced another thread') } - if (nullableTurn && params.turnId === null) return undefined + if (nullableTurn && params.turnId === null) return false const id = string(params.turnId, 'server request turn id') if (this.turnId === undefined) { this.observePendingTurnId(id) - return id + return true } if (id !== this.turnId) { throw new Error('subagent-codex: app-server request referenced another turn') } - return undefined + return false } private recordRequestDiagnostic( - provisionalTurnId: string | undefined, + provisional: boolean, request: Parameters[1], decision: Parameters[2], reason: string, ): void { const order = this.nextObservationOrder() - if (provisionalTurnId !== undefined) { + if (provisional) { this.pendingDiagnostic = { - turnId: provisionalTurnId, order, request, decision, @@ -504,10 +502,10 @@ export class CodexAppServerWire { switch (method) { case 'item/commandExecution/requestApproval': { - const provisionalTurnId = this.validateRunIds(params) + const provisional = this.validateRunIds(params) const decision = unattendedDecision(params) this.recordRequestDiagnostic( - provisionalTurnId, + provisional, 'command approval', decision === 'cancel' ? 'cancelled' : 'declined', 'the provider does not grant interactive approval', @@ -516,10 +514,10 @@ export class CodexAppServerWire { } case 'item/fileChange/requestApproval': { - const provisionalTurnId = this.validateRunIds(params) + const provisional = this.validateRunIds(params) const decision = unattendedDecision(params) this.recordRequestDiagnostic( - provisionalTurnId, + provisional, 'file approval', decision === 'cancel' ? 'cancelled' : 'declined', 'the provider does not grant interactive approval', diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 1c63e11592..497303237a 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -30,8 +30,6 @@ const { hostStderrWrite } = vi.hoisted(() => ({ hostStderrWrite: { capture: false, failNext: false, - zeroNext: false, - maxBytesPerWrite: undefined as number | undefined, chunks: [] as Buffer[], }, })) @@ -40,17 +38,11 @@ vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - writeSync( + writeFileSync( fd: number, value: string | Uint8Array, - offset?: number | null, - length?: number | null, - ): number { + ): void { if (fd === 2 && hostStderrWrite.capture) { - if (hostStderrWrite.zeroNext) { - hostStderrWrite.zeroNext = false - return 0 - } if (hostStderrWrite.failNext) { hostStderrWrite.failNext = false throw Object.assign(new Error('host stderr broke'), { code: 'EIO' }) @@ -58,26 +50,10 @@ vi.mock('node:fs', async (importOriginal) => { const bytes = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value.buffer, value.byteOffset, value.byteLength) - const start = typeof value === 'string' ? 0 : offset ?? 0 - const requested = typeof value === 'string' - ? bytes.byteLength - : length ?? bytes.byteLength - start - const written = Math.min( - requested, - hostStderrWrite.maxBytesPerWrite ?? requested, - ) - hostStderrWrite.chunks.push(Buffer.from(bytes.subarray(start, start + written))) - return written + hostStderrWrite.chunks.push(bytes) + return } - return typeof value === 'string' - ? actual.writeSync(fd, value, null, 'utf8') - : actual.writeSync( - fd, - value, - offset ?? 0, - length ?? value.byteLength - (offset ?? 0), - null, - ) + actual.writeFileSync(fd, value) }, } }) @@ -1390,7 +1366,6 @@ describe('run lifecycle and quiescence', () => { it('forwards stderr while extracting only a fixed safe permission signature', async () => { const child = fakeChild() hostStderrWrite.capture = true - hostStderrWrite.maxBytesPerWrite = 3 hostStderrWrite.chunks.length = 0 const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -1407,10 +1382,9 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN') - expect(hostStderrWrite.chunks.length).toBeGreaterThan(3) + expect(hostStderrWrite.chunks).toHaveLength(3) await run.dispose() expect(child.stderr.listenerCount('data')).toBe(0) - hostStderrWrite.maxBytesPerWrite = undefined hostStderrWrite.capture = false }) @@ -1434,26 +1408,6 @@ describe('run lifecycle and quiescence', () => { hostStderrWrite.capture = false }) - it('contains a zero-progress host stderr write without losing the diagnostic', async () => { - const child = fakeChild() - hostStderrWrite.capture = true - hostStderrWrite.zeroNext = true - const { run, turnStart } = await publishRun(child) - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.stderr.write('approval policy is Never; reject command') - child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { - message: 'fixture terminal failure', - codexErrorInfo: 'badRequest', - })) - await expect(run.result).resolves.toEqual({ - output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', - stopReason: 'error', - }) - await run.dispose() - hostStderrWrite.capture = false - }) - it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { const controller = new AbortController() controller.abort() From 29d6066870fd35488b4bf60f9237939dd8b0def1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 16:46:14 +0800 Subject: [PATCH 33/95] feat(ui-settings): add SettingsDescribeMirror single describe source --- .../ui-settings/src/client/settings-mirror.ts | 164 ++++++++++++++++++ .../tests/settings-mirror.client.spec.ts | 140 +++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 packages/client/ui-settings/src/client/settings-mirror.ts create mode 100644 packages/client/ui-settings/tests/settings-mirror.client.spec.ts diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts new file mode 100644 index 0000000000..a1dfe8c5a8 --- /dev/null +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -0,0 +1,164 @@ +/** + * Client mirror of the Host settings document: the one `settings.describe` + * reader in the browser. Every settings consumer derives from this store — + * per-namespace scopes through `SettingsScopeBinder.bind`, cross-namespace + * surfaces through the binder's read-only describe face — so startup cost and + * freshness are properties of this class, not of how many features own a + * preference. The Host stays the fact source: the mirror re-reads on the + * invalidations its owning plugin subscribes to and folds write answers in + * through {@link SettingsDescribeMirror.acceptView}. + */ + +import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +type SettingsFace = Pick + +/** The full `settings.describe` answer the mirror serves. */ +export interface SettingsDescribeView { + /** Every namespace a live Host plugin registered, as the Host reported it. */ + namespaces: readonly SettingsNamespaceView[] + /** Whether the settings provider accepts writes. */ + writable: boolean + /** Whether a native settings document exists for the Host to open. */ + hasDocument: boolean +} + +/** Mirror state every derived settings surface renders from. */ +export interface SettingsMirrorSnapshot { + /** + * `unavailable` is the terminal non-loopback state; `ready` persists across + * later failed refreshes (the held view keeps serving); `idle` means no + * answer is held and no read is running, so `ensure` will start one. + */ + status: 'idle' | 'loading' | 'ready' | 'unavailable' + /** The last good answer; undefined until the first success. */ + view: SettingsDescribeView | undefined + /** The latest refresh failure message, cleared by the next success. */ + error: string | null +} + +/** + * Serializes every Host `settings.describe` read behind one snapshot store. + * Concurrent {@link load} calls fold into the in-flight read plus one rerun, + * so an invalidation arriving mid-read is never lost and never duplicated. + */ +export class SettingsDescribeMirror { + private readonly store: SnapshotStore + private inFlight: Promise | undefined + private rerun = false + private generation = 0 + + /** + * @param api - settings wire face. + * @param persistence - remote browsers stay process-local because settings RPCs are loopback-only. + */ + constructor( + private readonly api: SettingsFace, + private readonly persistence: 'host' | 'memory' = 'host', + ) { + this.store = createSnapshotStore({ + status: persistence === 'host' ? 'idle' : 'unavailable', + view: undefined, + error: null, + }) + } + + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsMirrorSnapshot { + return this.store.getSnapshot() + } + + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void { + return this.store.subscribe(listener) + } + + /** + * Refresh from the Host. A call during an in-flight read marks one rerun + * after it settles instead of racing a second wire read. + * @returns settlement after this call's freshness is reflected. + */ + load(): Promise { + if (this.persistence === 'memory') return Promise.resolve() + if (this.inFlight !== undefined) { + this.rerun = true + return this.inFlight + } + const run = this.run().finally(() => { this.inFlight = undefined }) + this.inFlight = run + return run + } + + /** + * Resolve once an answer is held (or the mirror is terminally unavailable), + * reading only from `idle`. The cheap idempotent entry for surfaces that + * render on first use. + * @returns settlement of the current or newly started read, if any. + */ + ensure(): Promise { + if (this.persistence === 'memory') return Promise.resolve() + if (this.inFlight !== undefined) return this.inFlight + if (this.getSnapshot().status === 'idle') return this.load() + return Promise.resolve() + } + + /** + * Fold one write answer's namespace view into the held view without a wire + * read. A no-op until a first answer exists — a write cannot precede the + * read that supplied its `expectedRevision`. + * @param view - the namespace view a settings write answered with. + */ + acceptView(view: SettingsNamespaceView): void { + const before = this.store.getSnapshot() + if (before.view === undefined) return + const namespaces = before.view.namespaces.some(row => row.ns === view.ns) + ? before.view.namespaces.map(row => row.ns === view.ns ? view : row) + : [...before.view.namespaces, view] + this.store.set({ ...before, view: { ...before.view, namespaces } }) + } + + /** + * Convenience row lookup on the held view. + * @param ns - namespace identity. + * @returns the namespace view, or undefined while unanswered or unregistered. + */ + namespace(ns: string): SettingsNamespaceView | undefined { + return this.store.getSnapshot().view?.namespaces.find(row => row.ns === ns) + } + + private async run(): Promise { + do { + this.rerun = false + const generation = ++this.generation + const before = this.store.getSnapshot() + if (before.status === 'idle') this.store.set({ ...before, status: 'loading' }) + let outcome: { view: SettingsDescribeView } | { failure: string } + try { + const response = await this.api.settings.describe({}) + outcome = response.result.ok + ? { view: response.result.value } + : { failure: response.result.error.message } + } catch (error) { + outcome = { failure: error instanceof Error ? error.message : String(error) } + } + if (generation !== this.generation) continue + if ('view' in outcome) { + this.store.set({ status: 'ready', view: outcome.view, error: null }) + } else { + const held = this.store.getSnapshot() + // No answer yet: fall back to idle so `ensure` retries; with one, the + // held view keeps serving and only the error field reports the miss. + this.store.set({ + status: held.view === undefined ? 'idle' : 'ready', + view: held.view, + error: outcome.failure, + }) + } + } while (this.rerun) + } +} diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts new file mode 100644 index 0000000000..20c0cc55ee --- /dev/null +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts' + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `mirror-${rpc++}` as never, result: { ok: true, value } } +} + +function rejected(message: string): RpcResponse { + return { + rpcId: `mirror-${rpc++}` as never, + result: { + ok: false, + error: { code: 'settings-rejected', message, details: {} }, + }, + } +} + +function view(ns: string, revision = 0): SettingsNamespaceView { + return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision } +} + +function described(namespaces: SettingsNamespaceView[]): RpcResponse { + return ok({ writable: true, hasDocument: true, namespaces }) +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { resolve = res }) + return { promise, resolve } +} + +describe('SettingsDescribeMirror', () => { + it('folds concurrent load calls into the in-flight read plus one rerun', async () => { + const gate = deferred>() + const describeCall = vi.fn() + .mockReturnValueOnce(gate.promise) + .mockResolvedValue(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const first = mirror.load() + const second = mirror.load() + const third = mirror.load() + gate.resolve(described([view('theme', 0)])) + await Promise.all([first, second, third]) + expect(describeCall).toHaveBeenCalledTimes(2) + expect(mirror.getSnapshot().status).toBe('ready') + expect(mirror.namespace('theme')?.revision).toBe(1) + }) + + it('keeps the last good view when a later refresh fails, recording the failure', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described([view('theme', 2)])) + .mockRejectedValueOnce(new Error('host gone')) + .mockResolvedValueOnce(rejected('busy')) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.load() + expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + await mirror.load() + expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'host gone' }) + expect(mirror.namespace('theme')?.revision).toBe(2) + await mirror.load() + expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'busy' }) + expect(mirror.getSnapshot().view?.namespaces).toHaveLength(1) + }) + + it('returns to idle after a first read that never succeeded, so ensure retries', async () => { + const describeCall = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.ensure() + expect(mirror.getSnapshot()).toMatchObject({ status: 'idle', view: undefined, error: 'offline' }) + await mirror.ensure() + expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + expect(describeCall).toHaveBeenCalledTimes(2) + }) + + it('treats ensure as a no-op once ready', async () => { + const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.ensure() + await mirror.ensure() + await mirror.ensure() + expect(describeCall).toHaveBeenCalledTimes(1) + }) + + it('memory persistence is terminally unavailable and never touches the wire', async () => { + const describeCall = vi.fn() + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never, 'memory') + await mirror.ensure() + await mirror.load() + expect(mirror.getSnapshot()).toEqual({ status: 'unavailable', view: undefined, error: null }) + expect(describeCall).not.toHaveBeenCalled() + }) + + it('acceptView folds one write answer into the held view without a wire read', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described([view('theme', 1), view('locale', 4)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.load() + const seen: number[] = [] + mirror.subscribe(() => { seen.push(mirror.namespace('theme')?.revision ?? -1) }) + mirror.acceptView(view('theme', 9)) + expect(mirror.namespace('theme')?.revision).toBe(9) + expect(mirror.namespace('locale')?.revision).toBe(4) + expect(seen).toEqual([9]) + expect(describeCall).toHaveBeenCalledTimes(1) + }) + + it('acceptView before any answer is a no-op instead of inventing a document', () => { + const describeCall = vi.fn() + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + mirror.acceptView(view('theme', 1)) + expect(mirror.getSnapshot()).toEqual({ status: 'idle', view: undefined, error: null }) + }) + + it('acceptView appends a namespace the held view has not seen yet', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.load() + mirror.acceptView(view('fresh-ns', 0)) + expect(mirror.namespace('fresh-ns')).toBeDefined() + expect(mirror.getSnapshot().view?.namespaces).toHaveLength(2) + }) + + it('suppresses a stale answer that lost to a newer generation', async () => { + const slow = deferred>() + const describeCall = vi.fn() + .mockReturnValueOnce(slow.promise) + .mockResolvedValue(described([view('theme', 8)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const first = mirror.load() + const second = mirror.load() + slow.resolve(described([view('theme', 1)])) + await Promise.all([first, second]) + expect(mirror.namespace('theme')?.revision).toBe(8) + }) +}) From 4db77d398808005f3f6567e76aeb6d446ae16629 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 16:57:47 +0800 Subject: [PATCH 34/95] fix(ui-settings): clear the mirror in-flight slot in the rerun check's segment --- .../ui-settings/src/client/settings-mirror.ts | 66 +++++++++++-------- .../tests/settings-mirror.client.spec.ts | 14 ++++ 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index a1dfe8c5a8..398895c9cb 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -89,7 +89,7 @@ export class SettingsDescribeMirror { this.rerun = true return this.inFlight } - const run = this.run().finally(() => { this.inFlight = undefined }) + const run = this.run() this.inFlight = run return run } @@ -132,33 +132,41 @@ export class SettingsDescribeMirror { } private async run(): Promise { - do { - this.rerun = false - const generation = ++this.generation - const before = this.store.getSnapshot() - if (before.status === 'idle') this.store.set({ ...before, status: 'loading' }) - let outcome: { view: SettingsDescribeView } | { failure: string } - try { - const response = await this.api.settings.describe({}) - outcome = response.result.ok - ? { view: response.result.value } - : { failure: response.result.error.message } - } catch (error) { - outcome = { failure: error instanceof Error ? error.message : String(error) } - } - if (generation !== this.generation) continue - if ('view' in outcome) { - this.store.set({ status: 'ready', view: outcome.view, error: null }) - } else { - const held = this.store.getSnapshot() - // No answer yet: fall back to idle so `ensure` retries; with one, the - // held view keeps serving and only the error field reports the miss. - this.store.set({ - status: held.view === undefined ? 'idle' : 'ready', - view: held.view, - error: outcome.failure, - }) - } - } while (this.rerun) + // The in-flight slot must clear in the same synchronous segment that + // observes `rerun` false (and on abrupt exit): a `.finally()` on the + // returned promise runs one microtask later, and a `load()` landing in + // that gap would mark a rerun nobody reads, losing the read. + try { + do { + this.rerun = false + const generation = ++this.generation + const before = this.store.getSnapshot() + if (before.status === 'idle') this.store.set({ ...before, status: 'loading' }) + let outcome: { view: SettingsDescribeView } | { failure: string } + try { + const response = await this.api.settings.describe({}) + outcome = response.result.ok + ? { view: response.result.value } + : { failure: response.result.error.message } + } catch (error) { + outcome = { failure: error instanceof Error ? error.message : String(error) } + } + if (generation !== this.generation) continue + if ('view' in outcome) { + this.store.set({ status: 'ready', view: outcome.view, error: null }) + } else { + const held = this.store.getSnapshot() + // No answer yet: fall back to idle so `ensure` retries; with one, the + // held view keeps serving and only the error field reports the miss. + this.store.set({ + status: held.view === undefined ? 'idle' : 'ready', + view: held.view, + error: outcome.failure, + }) + } + } while (this.rerun) + } finally { + this.inFlight = undefined + } } } diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts index 20c0cc55ee..058c401d5e 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -125,6 +125,20 @@ describe('SettingsDescribeMirror', () => { expect(mirror.getSnapshot().view?.namespaces).toHaveLength(2) }) + it('never loses a load landing between a run settling and its slot clearing', async () => { + // Regression: with the in-flight slot cleared by a promise .finally(), + // a load() in the one-microtask gap after the rerun check marked a rerun + // nobody read, and that refresh never reached the wire. + const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + void mirror.load() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) + void mirror.load() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) }) + void mirror.load() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) + }) + it('suppresses a stale answer that lost to a newer generation', async () => { const slow = deferred>() const describeCall = vi.fn() From fd61fa889b697fae3e519863553985bfd79cc8d2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 16:57:48 +0800 Subject: [PATCH 35/95] refactor(ui-settings): derive settings scopes from the describe mirror --- .../client/ui-settings/src/client/index.ts | 53 +++- .../ui-settings/src/client/settings-scope.ts | 138 ++++++----- .../ui-settings/tests/plugin.client.spec.ts | 45 +++- .../tests/settings-scope.client.spec.ts | 232 ++++++++---------- 4 files changed, 250 insertions(+), 218 deletions(-) diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 2ace9e56b1..b3c149c938 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -1,35 +1,64 @@ /** * Settings domain base plugin, browser half. Provides `ctx.settingsScope`, the - * settings-namespace Host transport every preference row binds its durable - * section through, and owns the canonical slot-type contract for the settings - * surface. It depends on no `ui-*` presentation package, so any feature that - * owns a preference can reach it: the settings SHELL — the `sidebar.settings` - * occupant, its navigation, and the chrome — lives in ui-settings-general, - * because a shell dependency on ui-sidebar would close a reference cycle - * through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md. + * settings-namespace scope service every preference row binds its durable + * section through, and owns the one `settings.describe` reader in the browser: + * the describe mirror, whose invalidation subscriptions + * (`settings/document-updated`, `connection/reset`) live here so every derived + * surface refreshes from a single wire read. It depends on no `ui-*` + * presentation package, so any feature that owns a preference can reach it: + * the settings SHELL — the `sidebar.settings` occupant, its navigation, and + * the chrome — lives in ui-settings-general, because a shell dependency on + * ui-sidebar would close a reference cycle through ui-layout and ui-theme. + * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +// Type-only pair supplying `$on` and its key face without dragging a build +// artifact into the Host graph (rationale beside the same pair in +// settings-scope.ts). +import type {} from '@deepseek-ai/dsh-api-remotes/types' +import type {} from '@deepseek-ai/dsh-settings/types' import { SettingsScopeBinder } from './settings-scope.ts' +import { SettingsDescribeMirror } from './settings-mirror.ts' export type { SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps, SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts' +export { SettingsDescribeMirror } from './settings-mirror.ts' +export type { SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' /** - * Required services: none. The transport is resolved per caller through - * `this.ctx` at `bind` time, so this plugin waits for nothing. + * Required services: the wire handle for the mirror's reads and the forwarded + * settings invalidation the mirror refreshes on. */ -export const inject = [] +export const inject = ['connection', 'remote'] /** - * Provide the settings-namespace scope service. + * Provide the settings-namespace scope service over one shared describe + * mirror, and keep that mirror fresh on the two signals that can move the + * settings document: a document commit and a (re)connect. * * Constructing the service in this plugin's fiber keeps its traced methods * bound to each consuming plugin's context. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - new SettingsScopeBinder(ctx) + const connection = ctx.get('connection') as ConnectionHandle + const mirror = new SettingsDescribeMirror( + connection.api, + connection.isLoopback ? 'host' : 'memory', + ) + ctx.effect(() => { + const disposers = [ + (ctx.get('remote') as ClientContext['remote']).$on('settings/document-updated', () => { void mirror.load() }), + ctx.on('connection/reset', () => { void mirror.load() }), + ] + // The first connection also emits connection/reset; the in-flight fold + // makes this eager read and that reset converge to one wire call. + void mirror.ensure() + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings: describe mirror invalidations') + new SettingsScopeBinder(ctx, { mirror }) } diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 4668c4924b..47ebb3daa3 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -1,8 +1,11 @@ /** * Host transport for the settings-namespace scope contract. The contract types * live in `dsh-client-runtime` (the common dependency of every feature that - * owns a preference); this file owns the wire behavior and the invalidation - * subscription, both of which are Settings-surface concerns. + * owns a preference); this file owns the per-namespace derivation over the + * shared {@link SettingsDescribeMirror} and the serialized write path, both of + * which are Settings-surface concerns. Reads never touch the wire here: the + * mirror is the one `settings.describe` reader, and every scope is a selector + * over its snapshot. */ import { Service } from '@deepseek-ai/cordis' @@ -22,8 +25,8 @@ import { // Client half declares `ctx.remote` with no generated import, and the // allowlist's `types` subpath is a pure-type source file, so the pair supplies // `$on` and its key face without dragging a build artifact in. The runtime -// `remote` injection belongs to whoever calls bindSettingsScope: the -// subscription is registered on the caller's own context. +// `remote` injection belongs to the providing plugin's apply, which registers +// the mirror's invalidation subscriptions. import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-remotes/types' // The forwarded event's own declaration: `$on`'s key face is @@ -31,29 +34,39 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' +import { SettingsDescribeMirror } from './settings-mirror.ts' + type SettingsFace = Pick /** - * Serializes one namespace's Host reads and writes behind a snapshot store. - * Reads never block plugin activation; writes carry the latest known - * namespace revision and teardown waits for the operation already crossing - * the wire. + * One namespace's derived view over the shared describe mirror, plus that + * namespace's serialized Host writes. Writes carry the latest known namespace + * revision, fold their answers back into the mirror, and teardown waits for + * the operation already crossing the wire. */ export class SettingsScopeController implements SettingsScope { private readonly store: SnapshotStore> private tail: Promise = Promise.resolve() - private readGeneration = 0 private writeGeneration = 0 private disposed = false + private readonly unsubscribe: (() => void) | undefined + /** + * Revision answered by a superseded write still ahead of the mirror: the + * mirror only folds the LATEST settlement in, so a queued successor takes + * its fence from here first. + */ + private pendingRevision: number | undefined /** - * @param api - settings wire face. + * @param api - settings wire face (writes only; reads ride the mirror). * @param spec - namespace identity and optional narrowing decoder. + * @param mirror - the shared describe mirror this scope derives from. * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. */ constructor( private readonly api: SettingsFace, private readonly spec: SettingsScopeSpec, + private readonly mirror: SettingsDescribeMirror, private readonly persistence: 'host' | 'memory' = 'host', ) { this.store = createSnapshotStore>({ @@ -65,6 +78,10 @@ export class SettingsScopeController implements SettingsScope { writable: false, mode: persistence, }) + if (persistence === 'host') { + this.unsubscribe = mirror.subscribe(() => { this.derive() }) + this.derive() + } } /** @returns the current sync snapshot (stable reference until the next change). */ @@ -81,15 +98,6 @@ export class SettingsScopeController implements SettingsScope { return this.store.subscribe(listener) } - /** - * Queue a Host refresh; a newer read or user write suppresses stale publication. - * @returns settlement after the queued read completes or is skipped. - */ - load(): Promise { - const generation = ++this.readGeneration - return this.enqueue(() => this.read(generation)) - } - /** * Queue one field write; see {@link SettingsScope.set} for the ordering, * revision, and recovery contract. @@ -112,10 +120,9 @@ export class SettingsScopeController implements SettingsScope { } private write(op: SettingsPathOpView): Promise { - this.readGeneration += 1 const generation = ++this.writeGeneration return this.enqueue(async () => { - const revision = this.getSnapshot().revision + const revision = this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { response = await this.api.settings.mutate({ @@ -124,25 +131,39 @@ export class SettingsScopeController implements SettingsScope { ...(revision === undefined ? {} : { expectedRevision: revision }), }) } catch (_settingsWriteFailure) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + await this.recover(generation) return } if (!response.result.ok) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + await this.recover(generation) return } - this.accept(response.result.value, generation === this.writeGeneration) + if (this.disposed) return + if (generation === this.writeGeneration) { + this.pendingRevision = undefined + this.mirror.acceptView(response.result.value) + } else { + this.pendingRevision = response.result.value.revision + } }) } + /** Reload Host state for the latest failed write; superseded failures leave recovery to it. */ + private async recover(generation: number): Promise { + if (this.disposed || generation !== this.writeGeneration) return + this.pendingRevision = undefined + await this.mirror.load() + } + /** - * Stop queued operations and wait for the current wire call to settle. + * Stop queued operations, stop deriving, and wait for the current wire call + * to settle. * @returns settlement after the controller reaches quiescence. */ async dispose(): Promise { this.disposed = true - this.readGeneration += 1 this.writeGeneration += 1 + this.unsubscribe?.() await this.tail } @@ -158,36 +179,25 @@ export class SettingsScopeController implements SettingsScope { return task } - private async read(generation: number): Promise { - let response: Awaited> - try { - response = await this.api.settings.describe({}) - } catch (_settingsReadFailure) { - return - } - if (!response.result.ok || this.disposed) return - const { namespaces, writable } = response.result.value - const view = namespaces.find(candidate => candidate.ns === this.spec.namespace) - const publish = generation === this.readGeneration + private derive(): void { + if (this.disposed) return + const mirrored = this.mirror.getSnapshot() + if (mirrored.view === undefined) return + const { writable } = mirrored.view + const view = mirrored.view.namespaces.find(candidate => candidate.ns === this.spec.namespace) if (view === undefined) { - if (publish) { - this.store.update((draft) => { - draft.status = 'unavailable' - draft.writable = writable - }) - } + this.store.update((draft) => { + draft.status = 'unavailable' + draft.writable = writable + }) return } - this.accept(view, publish, writable) - } - - private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void { - const decoded = publish ? this.decode(view) : undefined + const decoded = this.decode(view) this.store.update((draft) => { draft.revision = view.revision draft.base = view.base draft.user = view.user - if (writable !== undefined) draft.writable = writable + draft.writable = writable if (decoded === undefined) return draft.status = 'ready' draft.value = decoded @@ -225,20 +235,24 @@ declare module '@deepseek-ai/cordis' { * (`packages/client/tsdown.client.ts`). */ export class SettingsScopeBinder extends Service { + private readonly mirror: SettingsDescribeMirror + /** * @param ctx - the providing plugin's context. + * @param config - the shared describe mirror every bound scope derives from. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: { mirror: SettingsDescribeMirror }) { super(ctx, 'settingsScope') + this.mirror = config.mirror } /** - * Bind one namespace scope to settings and connection invalidations on the - * CALLER's plugin lifecycle — the service proxy binds `this.ctx` to the - * caller at call time, so the scope's disposer belongs to the calling fiber. - * Listeners exist before the initial background read starts, so activation - * never blocks on the settings transport. The caller injects `connection` - * for the transport and `remote` for the forwarded settings invalidation. + * Bind one namespace scope on the CALLER's plugin lifecycle — the service + * proxy binds `this.ctx` to the caller at call time, so the scope's disposer + * belongs to the calling fiber. The scope derives from the shared mirror + * (whose invalidation subscriptions live with the providing plugin), so + * binding adds no wire read of its own and activation never blocks on the + * settings transport. * @param spec - domain-owned namespace contract. * @returns the bound scope consumed by the domain's services and rows. */ @@ -248,20 +262,12 @@ export class SettingsScopeBinder extends Service { const controller = new SettingsScopeController( connection.api, spec, + this.mirror, connection.isLoopback ? 'host' : 'memory', ) ctx.effect(() => { - const refresh = (namespace?: string): void => { - if (namespace !== undefined && namespace !== spec.namespace) return - void controller.load() - } - const disposers = [ - (ctx.get('remote') as Context['remote']).$on('settings/document-updated', refresh), - ctx.on('connection/reset', () => { refresh() }), - ] - void controller.load() + void this.mirror.ensure() return async () => { - for (const dispose of disposers) dispose() await controller.dispose() } }, `ui-settings: ${spec.namespace} settings scope`) diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index 1643e9580f..c63bf1fe00 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -1,29 +1,56 @@ /** * The settings domain base plugin's own mounting behavior: it stands up - * `ctx.settingsScope` for every feature that owns a preference row, and the - * service retires with its fiber. + * `ctx.settingsScope` over one shared describe mirror, keeps that mirror + * fresh on settings-document and connection-reset invalidations, and retires + * both the service and the subscriptions with its fiber. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SettingsScopeBinder } from '../src/client/index.ts' -/** Boot the browser half over a bare root context; it injects nothing. */ +/** Boot the browser half over a fake loopback connection and test remote. */ function bench() { + const describeCall = vi.fn().mockResolvedValue({ + rpcId: 'plugin-bench' as never, + result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [] } }, + }) const ctx = new Context() - return { ctx, fiber: ctx.plugin({ inject: [...inject], apply }) } + ctx.provide('connection', { + api: { settings: { describe: describeCall } }, + isLoopback: true, + } as never) + new TestRemote(ctx) + return { ctx, describeCall, fiber: ctx.plugin({ inject: [...inject], apply }) } } describe('settings domain base plugin', () => { - it('mounts the scope service under settingsScope', async () => { - const { ctx, fiber } = bench() + it('mounts the scope service under settingsScope and reads once eagerly', async () => { + const { ctx, describeCall, fiber } = bench() await fiber.await() expect(ctx.get('settingsScope')).toBeInstanceOf(SettingsScopeBinder) + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) }) - it('fiber disposal retires the service', async () => { - const { ctx, fiber } = bench() + it('refreshes the mirror on document commits and connection resets, once each', async () => { + const { ctx, describeCall, fiber } = bench() await fiber.await() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) + ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) }) + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) + }) + + it('fiber disposal retires the service and its invalidation subscriptions', async () => { + const { ctx, describeCall, fiber } = bench() + await fiber.await() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) await fiber.dispose() expect(ctx.get('settingsScope')).toBeUndefined() + ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + ctx.emit('connection/reset') + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 429002028a..1e4c473ef1 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -5,6 +5,7 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-re import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts' +import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts' interface UiTestSettings { preference: 'light' | 'dark' | 'system' @@ -52,6 +53,17 @@ function deferred() { return { promise, resolve, reject } } +/** A host-mode mirror plus a controller derived from it, over one fake wire. */ +function derivedScope( + api: { describe?: ReturnType; mutate?: ReturnType }, + spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' }, +) { + const wire = { settings: api } as never + const mirror = new SettingsDescribeMirror(wire) + const scope = new SettingsScopeController(wire, spec, mirror) + return { mirror, scope } +} + /** Record each distinct published section, starting from the current one. */ function trackValues(scope: SettingsScope): Array { const seen: Array = [scope.getSnapshot().value] @@ -63,16 +75,13 @@ function trackValues(scope: SettingsScope): Array { - it('starts loading and publishes a schema-valid section with revision and writability', async () => { + it('starts loading and derives a schema-valid section with revision and writability', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) expect(scope.getSnapshot()).toEqual({ status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toEqual({ status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host', }) @@ -87,12 +96,9 @@ describe('SettingsScopeController', () => { .mockResolvedValueOnce(described(['queue'], 7)) .mockResolvedValueOnce(rejected()) .mockRejectedValueOnce(new Error('offline')) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) const good = trackValues(scope) - for (let i = 0; i < 7; i++) await scope.load() + for (let i = 0; i < 7; i++) await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 7, }) @@ -103,45 +109,22 @@ describe('SettingsScopeController', () => { const broken = { ...view({ preference: 'dark' }, 2), schema: null } const describeCall = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] })) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall }) + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 }) }) - it('suppresses a superseded read of an unexposed namespace', async () => { - const describeCall = vi.fn() - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) - .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) - const statuses: string[] = [] - scope.subscribe(() => { statuses.push(scope.getSnapshot().status) }) - const stale = scope.load() - const fresh = scope.load() - await Promise.all([stale, fresh]) - expect(statuses).not.toContain('unavailable') - expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) - }) - it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'light' }, 1)) .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) .mockResolvedValueOnce(described({ preference: 'system' }, 2)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall }) + await mirror.load() expect(scope.getSnapshot().status).toBe('ready') - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 }) }) @@ -149,18 +132,15 @@ describe('SettingsScopeController', () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'light' }, 1)) .mockResolvedValueOnce(described({ preference: 'dark' }, 2)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { - namespace: 'ui-test', - decode: section => (section as UiTestSettings).preference === 'dark' - ? section as UiTestSettings - : undefined, - }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall }, { + namespace: 'ui-test', + decode: section => (section as UiTestSettings).preference === 'dark' + ? section as UiTestSettings + : undefined, + }) + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 }) }) @@ -170,12 +150,9 @@ describe('SettingsScopeController', () => { const mutate = vi.fn() .mockReturnValueOnce(first.promise) .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) - await scope.load() + await mirror.load() const dark = scope.set('preference', 'dark') const light = scope.set('preference', 'light') await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) @@ -195,6 +172,19 @@ describe('SettingsScopeController', () => { }) }) + it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4)) + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) + const wire = { settings: { describe: describeCall, mutate } } as never + const mirror = new SettingsDescribeMirror(wire) + const writer = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror) + const sibling = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror) + await mirror.load() + await writer.set('preference', 'dark') + expect(describeCall).toHaveBeenCalledTimes(1) + expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 }) + }) + it('recovers the latest rejected or thrown write from Host state', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'system' }, 2)) @@ -202,52 +192,45 @@ describe('SettingsScopeController', () => { const mutate = vi.fn() .mockResolvedValueOnce(rejected()) .mockRejectedValueOnce(new Error('offline')) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) + await mirror.load() await scope.set('preference', 'dark') await scope.set('preference', 'system') expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) }) it('does not recover superseded rejected or thrown writes', async () => { - const describeCall = vi.fn() + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 2)) const mutate = vi.fn() .mockResolvedValueOnce(rejected()) .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) + await mirror.load() await Promise.all([ scope.set('preference', 'dark'), scope.set('preference', 'system'), scope.set('preference', 'light'), ]) - expect(describeCall).not.toHaveBeenCalled() - expect(published.map(section => section?.preference)).toEqual([undefined, 'light']) + expect(describeCall).toHaveBeenCalledTimes(1) + expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) }) it('keeps the write queue usable when a subscriber throws', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) .mockResolvedValueOnce(described({ preference: 'light' }, 2)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) let thrown = false scope.subscribe(() => { if (thrown) return thrown = true throw new Error('subscriber failed') }) - await expect(scope.load()).rejects.toThrow('subscriber failed') - await expect(scope.load()).resolves.toBeUndefined() + await expect(mirror.load()).rejects.toThrow('subscriber failed') + await expect(mirror.load()).resolves.toBeUndefined() expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) }) @@ -255,10 +238,7 @@ describe('SettingsScopeController', () => { const first = deferred>() const mutate = vi.fn().mockReturnValue(first.promise) const describeCall = vi.fn() - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) const dark = scope.set('preference', 'dark') await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) @@ -270,24 +250,34 @@ describe('SettingsScopeController', () => { first.resolve(ok(view({ preference: 'dark' }, 1))) await Promise.all([dark, light, stop]) await scope.set('preference', 'system') - await scope.load() expect(mutate).toHaveBeenCalledOnce() expect(describeCall).not.toHaveBeenCalled() expect(published).toEqual([undefined]) }) + it('stops deriving from the mirror after dispose', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + const { mirror, scope } = derivedScope({ describe: describeCall }) + await mirror.load() + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' } }) + await scope.dispose() + await mirror.load() + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) + }) + it('keeps a remote browser in memory mode without Host calls', async () => { const describeCall = vi.fn() const mutate = vi.fn() + const wire = { settings: { describe: describeCall, mutate } } as never + const mirror = new SettingsDescribeMirror(wire, 'memory') const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - 'memory', - ) + wire, { namespace: 'ui-test' }, mirror, 'memory') expect(scope.getSnapshot()).toEqual({ status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory', }) - await scope.load() + await mirror.load() await scope.set('preference', 'dark') await scope.dispose() expect(describeCall).not.toHaveBeenCalled() @@ -302,12 +292,9 @@ describe('SettingsScopeController', () => { } const describeCall = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] })) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, @@ -320,12 +307,9 @@ describe('SettingsScopeController', () => { const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } } const describeCall = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] })) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) - await scope.load() + await mirror.load() expect(scope.getSnapshot().user).toBeUndefined() }) @@ -333,11 +317,8 @@ describe('SettingsScopeController', () => { it('clears one field through an unset op fenced by the held revision', async () => { const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4))) const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() await scope.unset('preference') @@ -354,64 +335,53 @@ describe('SettingsScopeController', () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) .mockResolvedValueOnce(described({ preference: 'light' }, 5)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() await scope.unset('preference') expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 }) }) }) + describe('SettingsScopeBinder.bind', () => { - it('subscribes before the initial read and converges to the latest queued invalidation', async () => { - const initial = deferred>() - const describeCall = vi.fn() - .mockReturnValueOnce(initial.promise) - .mockResolvedValueOnce(described({ preference: 'light' }, 2)) - .mockResolvedValueOnce(described({ preference: 'system' }, 3)) + it('shares one mirror read across bound scopes and disposes each with its fiber', async () => { + const describeCall = vi.fn().mockResolvedValue(described({ preference: 'dark' }, 1)) + const wire = { settings: { describe: describeCall } } + const mirror = new SettingsDescribeMirror(wire as never) const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe: describeCall } }, - isLoopback: true, - } as never) - let scope!: SettingsScope + ctx.provide('connection', { api: wire, isLoopback: true } as never) + let theme!: SettingsScope + let locale!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, { mirror }).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { - scope = plugin.settingsScope.bind({ namespace: 'ui-test' }) + theme = plugin.settingsScope.bind({ namespace: 'ui-test' }) + locale = plugin.settingsScope.bind({ namespace: 'ui-test' }) }, }) await fiber.await() - await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() }) - ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0]) - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) - ctx.emit('connection/reset') - initial.resolve(described({ preference: 'dark' }, 1)) - await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) await vi.waitFor(() => { - expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 }) + expect(theme.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) + expect(locale.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) }) + expect(describeCall).toHaveBeenCalledTimes(1) await fiber.dispose() - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) - await Promise.resolve() - expect(describeCall).toHaveBeenCalledTimes(3) + await mirror.load() + expect(theme.getSnapshot()).toMatchObject({ revision: 1 }) }) it('binds a remote browser in memory mode without starting a settings read', async () => { const describeCall = vi.fn() + const wire = { settings: { describe: describeCall } } + const mirror = new SettingsDescribeMirror(wire as never, 'memory') const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe: describeCall } }, - isLoopback: false, - } as never) + ctx.provide('connection', { api: wire, isLoopback: false } as never) let scope!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, { mirror }).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { From a2c001eb3e35e2e819bd263cf2476734f9d1a349 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:02:32 +0800 Subject: [PATCH 36/95] test(client): bench downstream settings consumers on the real ui-settings apply --- .../client/locale/tests/apply.client.spec.ts | 9 ++++++--- .../tests/apply.client.spec.ts | 4 ++-- .../tests/settings-mirror.client.spec.ts | 2 +- .../ui-theme/tests/apply.client.spec.ts | 20 ++++++++++++++----- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index dd38786073..2378ae796a 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SETTINGS_NS, @@ -47,7 +47,7 @@ async function bench() { ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, @@ -133,7 +133,10 @@ describe('locale apply', () => { it('loads and refreshes the explicit Host preference after nonblocking activation', async () => { const b = await bench() + // The shared mirror read once at bench time; a Host-side change reaches it + // through the document invalidation, exactly as production announces one. b.setHostPreference('en') + b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime @@ -144,7 +147,7 @@ describe('locale apply', () => { b.setHostPreference('en') b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) - expect(b.describe).toHaveBeenCalledTimes(3) + expect(b.describe).toHaveBeenCalledTimes(4) }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 2934097b94..c5516ff4bc 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, @@ -52,7 +52,7 @@ async function bench(served?: string[]) { credentials: { describe: describeCredentials }, }, } as never) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings } } diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts index 058c401d5e..972b3cea73 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -13,7 +13,7 @@ function rejected(message: string): RpcResponse { rpcId: `mirror-${rpc++}` as never, result: { ok: false, - error: { code: 'settings-rejected', message, details: {} }, + error: { code: 'settings-rejected', message, details: { ns: 'theme' } }, }, } } diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index fb84c9860d..1629ebe342 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' @@ -56,7 +56,7 @@ async function bench(isLoopback = true) { ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, setHostPreference: (next: string) => { preference = next }, @@ -127,13 +127,19 @@ describe('ui-theme apply', () => { it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => { const b = await bench() + // The shared mirror read once at bench time; a Host-side change reaches it + // through the document invalidation, exactly as production announces one. b.setHostPreference('dark') + b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + // The mirror refreshes on every document commit (ns-agnostic); the scope's + // derived value only moves when its own namespace changed. b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0]) - expect(b.describe).toHaveBeenCalledOnce() + await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(3) }) + expect(theme.getTheme().preference).toBe('dark') b.setHostPreference('light') b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') }) @@ -151,12 +157,15 @@ describe('ui-theme apply', () => { expect(remote.mutate).not.toHaveBeenCalled() }) - it('activates before a slow initial settings read and converges when it settles', async () => { + it('activates before a slow settings refresh and converges when it settles', async () => { const b = await bench() b.setHostPreference('dark') const describe = b.describe.getMockImplementation()! const pending = deferred>>() b.describe.mockImplementationOnce(() => pending.promise) + // The refresh hangs on the wire; the mirror keeps serving the last good + // answer, so activation never blocks on the settings transport. + b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() const theme = b.ctx.get('theme') as ThemeRuntime @@ -169,9 +178,10 @@ describe('ui-theme apply', () => { it('ignores an invalid preference crossing the settings wire', async () => { const b = await bench() b.setHostPreference('sepia') + b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime - await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(2) }) expect(theme.getTheme().preference).toBe('system') }) From 941e5c506178681f33b4192dadfc0d622a101201 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 17 Aug 2026 17:03:48 +0800 Subject: [PATCH 37/95] feat(workflow): let users control run and phase disclosures --- ...low-run-status-driven-disclosure.i18n.yaml | 4 +- ...1-workflow-run-status-driven-disclosure.md | 30 +- ...orkflow-run-status-driven-disclosure.zh.md | 30 +- .../workflow-run/ui-live.expected.md | 23 ++ .../snapshots/workflow-run/ui.expected.md | 6 +- apps/web/tests/workflow-run.e2e.ts | 37 +- .../client/ui-workflow-run/README.i18n.yaml | 4 +- packages/client/ui-workflow-run/README.md | 2 +- packages/client/ui-workflow-run/README.zh.md | 2 +- .../src/client/WorkflowRunPanel.tsx | 275 +++++++++++--- .../tests/workflow-run.client.spec.tsx | 339 ++++++++++++++---- 11 files changed, 592 insertions(+), 160 deletions(-) create mode 100644 apps/web/tests/snapshots/workflow-run/ui-live.expected.md diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml index 1f7ecb98d3..a9e09de830 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.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-11-workflow-run-status-driven-disclosure.md -2026-08-11-workflow-run-status-driven-disclosure.md: 2f452d25a8922bb6c275419af55e8af155dd2781 -2026-08-11-workflow-run-status-driven-disclosure.zh.md: 12cc106fea274a1681ee5615906ae6df266d567b +2026-08-11-workflow-run-status-driven-disclosure.md: a783ede82442cfb28ed4c0a2fd394677728949d8 +2026-08-11-workflow-run-status-driven-disclosure.zh.md: 0d047bf7383f68ff7ec23d3602789e776c85d2cc diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md index 2f452d25a8..a783ede824 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md @@ -6,38 +6,40 @@ English | [中文](2026-08-11-workflow-run-status-driven-disclosure.zh.md) ## Problem -A durable workflow Chat node updates in place from its running prefix to a terminal record. A disclosure choice initialized only at mount can hide a newly running phase, leave completed work occupying the conversation, or bury a failed, cancelled, or interrupted member behind two collapsed levels. Making openness a pure function of completion avoids those failures but also prevents users from reopening clean history for review. +A durable workflow Chat node updates in place from its running prefix to a terminal record. The renderer must draw attention to new work, abnormal outcomes, and normal completion without repeatedly overriding a user's decision to reclaim conversation space. -The renderer already receives every required lifecycle fact from the workflow Conversation Node. Visibility therefore needs a component-local lifecycle that gives current execution and attention states priority without adding another durable fact or taking ownership of workflow outcomes. +The renderer already receives every durable lifecycle fact from the workflow Conversation Node. Disclosure choice therefore belongs to the mounted presentation, but its lifecycle must also preserve nested phase choices when the outer run is hidden and avoid removing content that still contains keyboard focus. ## Decision -Each phase derives one visibility requirement from its current members. A running, failed, cancelled, or interrupted member forces that phase open; a phase whose members are all completed is clean. The workflow forces itself open when its own status requires attention or any phase is forced open, so an abnormal member remains visible even when the workflow outcome is recorded as completed. A completed sibling phase remains independently collapsible. +`WorkflowRunPanel` owns one local disclosure state for the run and a map keyed by the existing phase key. A phase is clean when every member completed, abnormal when any member failed, was cancelled, or was interrupted, and running otherwise. The run is abnormal when its own status or any phase is abnormal, running when its own status or any phase is running, and clean only when the run and every phase completed normally. A mount opens running and abnormal levels and closes clean levels. -A forced-open level renders as an expanded static row. It exposes no button role, focus target, keyboard toggle, or `aria-expanded` value because collapsing cannot change the result. This keeps the visual hierarchy and status summaries while making the interaction promise match the available action. +Each level records its current mode, append-only member count, open choice, and any pending clean close. Ordinary updates within a running or abnormal interval preserve the user's choice. A phase transition from clean to activity opens that phase and the outer run once, the first transition into abnormal opens once, and a transition into clean closes once. A member-count change while a phase remains clean represents a complete activity cycle delivered in one render and closes an open review without adding an activity epoch or durable field. After an automatic action, mouse, Enter, and Space control the level until another defined edge occurs. -A clean level mounts an ordinary controlled disclosure in the closed state. Its local choice survives rerenders for the same continuous clean interval. New running or abnormal data replaces that manual interval with forced expansion; the next transition back to clean mounts a fresh closed disclosure, which produces one automatic fold per activity cycle. Closing the workflow naturally unmounts its phase controls, and a Session remount reconstructs every level from the current durable status rather than restoring an earlier choice. +Phase state remains in `WorkflowRunPanel` while the outer disclosure hides its children, so closing and reopening the run restores each phase choice. Removing a phase deletes its entry; a renderer remount reconstructs every level from current durable facts rather than restoring an earlier choice. -For example, a running workflow exposes its active phase and member without clicks. When that phase completes, only the phase folds while the workflow remains open; when the workflow and every phase complete, the workflow also folds. The user can then reopen both levels for review. If another member starts under the same phase key, both affected levels immediately return to forced expansion and fold again only after the new activity completes. +Normal completion checks whether focus is inside the content before closing. Focused content remains mounted with current completed status and closes after focus leaves. When a navigable member becomes terminal while its button holds focus, `MemberRow` keeps the same button mounted as `aria-disabled` until blur; later terminal review renders the ordinary non-interactive row. This preserves the active DOM target without allowing terminal navigation or adding a focus manager. -The renderer owns only this visibility lifecycle. It does not add Session events, stores, settings, acknowledgement state, timers, focus movement, automatic scrolling, or cross-remount persistence. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, or the shared `DisclosureRow` API. Shared `data-expandable` styling owns pointer cursors, so forced-open static rows do not advertise an unavailable action. An interrupted durable prefix remains an attention state and therefore stays visible until the underlying facts change. +The renderer adds no Session events, store, setting, acknowledgement, timer, automatic scrolling, persistent activity identity, or `DisclosureRow` API. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, or visual tokens. ## Verification -Component tests drive the same keyed workflow and phase through running, clean completion, manual review, renewed activity, repeated clean completion, zero-member completion, and each abnormal status. They also verify abnormal-member propagation, clean-sibling independence, mouse and keyboard review, continuous-clean choice retention, and the absence of false button and ARIA semantics while expansion is mandatory. +Component tests drive one keyed run and its phases through initial running controls, mouse and keyboard choices, ordinary running updates, outer hide and restore, phase completion, run completion, clean review, same-key renewed activity, a fully batched clean cycle, every abnormal status, first-abnormal escalation, later abnormal updates, zero-member completion, focused-member completion, sibling independence, and renderer remount. They also verify terminal navigation remains absent after the deferred focus path settles. -The shipped Web replay observes the real workflow, worker, Session log, browser plugin graph, and child navigation. It requires the live workflow and active phase to be visible without disclosure controls, the normally settled workflow and phase to fold, manual review to retain the terminal member without navigation, and a reload to reconstruct the folded history from durable facts. +The shipped Web replay exercises the real workflow, worker, Session log, browser plugin graph, and child navigation. It collapses and reopens live run and phase controls, records the live collapsed status summary and ARIA state, verifies normal settlement folds both levels, confirms terminal review cannot navigate the member, and records the folded history reconstructed after reload. ## Alternatives considered -**Keep one manual state initialized from the first render.** Rejected because later lifecycle updates cannot reopen newly active or abnormal content and cannot fold normally settled work. +**Force every running or abnormal level open as a static row.** Rejected because it makes the attention state impossible to dismiss and removes truthful mouse, keyboard, and ARIA disclosure semantics. -**Derive `open` directly from whether a level is clean.** Rejected because completed history would remain permanently closed and could not be reopened for review. +**Keep one manual state initialized from the first render.** Rejected because later activity, abnormal escalation, and normal completion cannot perform their one-time automatic actions. -**Persist expansion, acknowledgement, or read state.** Rejected because current lifecycle facts already determine mandatory visibility, while review choice belongs only to the mounted presentation. Persistence would add a second state owner and require semantics for stale choices, abnormal acknowledgement, replay, and synchronization that the user result does not need. +**Let each phase own state inside its disclosure content.** Rejected because hiding the outer run unmounts that content and discards independent phase choices during the same mounted workflow record. + +**Persist expansion, acknowledgement, or an activity epoch.** Rejected because current workflow facts and the append-only member count provide every required edge. Persistence adds a second durable owner and synchronization semantics that this presentation choice does not need. ## Consequences -Workflow records expose current work and abnormal outcomes without preparatory clicks, then reclaim conversation space after normal completion without sacrificing review. Interaction semantics remain truthful during automatic control, and the same durable record produces the same initial state during live rendering, refresh, and history reconstruction. +Workflow records call attention to lifecycle changes while remaining dismissible in every status. Normal completion reclaims space, current focus remains safe, nested phase choices survive outer hiding, and the same durable record reconstructs a deterministic initial state on refresh or history replay. -The trade-off is deliberate local reset behavior. A phase choice disappears when its parent workflow closes or the component unmounts, and abnormal records cannot be manually hidden because the product has no acknowledgement state. Supporting either behavior later requires a separate ownership and persistence decision rather than extending this local lifecycle implicitly. +The local lifecycle deliberately resets on renderer remount and cannot remember a choice across refresh, devices, or users. Adding that behavior requires a separate persistence and stale-choice decision rather than extending this presentation state implicitly. diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md index 12cc106fea..0d047bf738 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md @@ -6,38 +6,40 @@ Status: implemented ## 问题 -持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。只在挂载时初始化的 disclosure 选择可能隐藏新开始运行的阶段,让已完成工作继续占据对话空间,或者把失败、已取消或已中断成员埋在两层折叠内容之后。若只把开合状态作为完成状态的纯派生结果,虽然能避免这些问题,却也会阻止用户重新打开干净历史进行复盘。 +持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。renderer 必须提示新工作、异常结果和正常完成,同时不能在普通更新中反复覆盖用户回收对话空间的选择。 -renderer 已经从工作流 Conversation Node 收到全部所需生命周期事实。因此,可见性需要一个组件本地生命周期:让当前执行与需注意状态优先,同时不增加另一项持久事实,也不取得工作流结果的所有权。 +renderer 已经从工作流 Conversation Node 收到全部持久生命周期事实。因此,disclosure 选择属于已挂载的展示层,但它的生命周期还必须在外层运行隐藏时保留嵌套 Phase 选择,并避免移除仍含键盘焦点的内容。 ## 决策 -每个阶段从当前成员派生一项可见性要求。存在运行中、失败、已取消或已中断成员时,该阶段强制展开;全部成员均已完成时,该阶段处于干净状态。工作流自身状态需要注意或任一阶段强制展开时,工作流也强制展开,因此即使工作流结果记录为已完成,异常成员仍保持可见。已完成的兄弟阶段继续可以独立折叠。 +`WorkflowRunPanel` 持有一项运行 disclosure 本地状态,以及一张按现有 phase key 索引的 Phase 状态表。全部成员都已完成时,Phase 为干净状态;任一成员失败、已取消或已中断时为异常状态;其余情况为运行状态。运行自身或任一 Phase 异常时,运行处于异常状态;运行自身或任一 Phase 正在运行时,运行处于运行状态;只有运行与全部 Phase 都正常完成时才处于干净状态。挂载时,运行和异常层级默认展开,干净层级默认折叠。 -强制展开层级渲染为静态展开行。它不提供按钮 role、焦点目标、键盘切换或 `aria-expanded` 值,因为折叠操作无法改变结果。这样既保留视觉层级与状态摘要,也让交互承诺与实际可执行动作一致。 +每个层级记录当前模式、仅追加成员数、开合选择和待执行的干净折叠。Phase 从干净状态进入新活动时,该 Phase 与外层运行自动展开一次;连续运行或异常区间内的普通更新保留用户选择,首次进入异常状态时自动展开一次,进入干净状态时自动折叠一次。若新增成员及其正常完成在同一次渲染中送达,Phase 会保持干净但成员数改变;该变化会折叠已打开的复盘,而无需增加 activity epoch 或持久字段。自动动作完成后,鼠标、Enter 和 Space 控制该层级,直到出现下一项约定边沿。 -干净层级会以关闭状态挂载普通受控 disclosure。它的本地选择在同一段连续干净状态的 rerender 中保持。新的运行中或异常数据会用强制展开替代该手动区间;下一次回到干净状态时会挂载新的关闭 disclosure,从而让每个活动周期只自动折叠一次。关闭工作流会自然卸载其阶段控件;Session remount 会从当前持久状态重建每个层级,而不恢复更早的选择。 +外层 disclosure 隐藏子内容时,Phase 状态仍留在 `WorkflowRunPanel`,因此关闭并重新打开运行会恢复各 Phase 选择。Phase 被移除时,其表项同时清理;renderer remount 会从当前持久事实重建每个层级,而不恢复更早选择。 -例如,运行中的工作流无需点击即可展示活跃阶段与成员。该阶段完成时,只有阶段折叠,工作流继续展开;工作流自身和全部阶段均完成时,工作流也会折叠。用户随后可以重新打开两个层级复盘。若同一阶段 key 下又开始新成员,受影响的两个层级会立即恢复强制展开,并且只在新活动完成后再次折叠。 +正常完成会在折叠前检查焦点是否位于内容内。仍含焦点的内容保持挂载并立即显示完成状态,焦点离开后再折叠。可导航成员的按钮持有焦点并变为终态时,`MemberRow` 会把同一个按钮以 `aria-disabled` 形式保留到 blur;之后的终态复盘渲染普通不可交互行。这样既保留当前 DOM 目标,也不允许终态导航,并且无需增加焦点管理器。 -renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案或共享 `DisclosureRow` API。pointer 光标由共享的 `data-expandable` 样式拥有,因此强制展开的静态行不会提示无法执行的操作。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。 +renderer 不增加 Session 事件、store、设置、确认状态、计时器、自动滚动、持久活动身份或 `DisclosureRow` API。它不改变工作流状态派生、Phase 分组、成员顺序、导航准入、文案或视觉 token。 ## 验证 -组件测试驱动同一个 keyed 工作流与阶段依次经过运行、干净完成、手动复盘、新活动、再次干净完成、零成员完成以及每种异常状态。测试还验证异常成员向上展开、干净兄弟阶段独立、鼠标和键盘复盘、连续干净状态中的选择保持,以及强制展开时不存在虚假按钮和 ARIA 语义。 +组件测试驱动同一个 keyed 运行及其 Phase,覆盖初始运行控件、鼠标和键盘选择、普通运行更新、外层隐藏与恢复、Phase 完成、运行完成、干净复盘、同 key 新活动、同次渲染送达的完整干净周期、每种异常状态、首次异常升级、后续异常更新、零成员完成、成员持焦点时完成、兄弟 Phase 独立以及 renderer remount。测试还确认延后焦点路径结算后,终态导航仍不存在。 -shipped Web 回放观察真实工作流、worker、Session 日志、浏览器插件图和子级导航。它要求实时工作流与活跃阶段无需 disclosure 控件即可见,正常结算的工作流与阶段会折叠,手动复盘仍能看到不再可导航的终态成员,并且刷新会从持久事实重建折叠历史。 +shipped Web 回放经过真实工作流、worker、Session 日志、浏览器插件图和子级导航。它折叠并重新打开实时运行与 Phase 控件,记录实时折叠标题的状态摘要和 ARIA 状态,验证正常结算会折叠两个层级,确认终态复盘不能导航成员,并记录刷新后从历史重建的折叠记录。 ## 曾考虑的替代方案 -**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续生命周期更新无法重新打开新活动或异常内容,也无法折叠正常结算的工作。 +**把每个运行中或异常层级强制展开为静态行。** 拒绝,因为需注意状态将无法收起,也不会提供真实的鼠标、键盘和 ARIA disclosure 语义。 -**只根据层级是否干净来派生 `open`。** 拒绝,因为已完成历史会永久保持关闭,无法重新打开复盘。 +**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续活动、异常升级和正常完成无法执行各自的一次性自动动作。 -**持久化展开、确认或已读状态。** 拒绝,因为当前生命周期事实已经决定强制可见性,而复盘选择只属于已挂载的展示层。持久化会增加第二个状态归属方,并要求定义陈旧选择、异常确认、回放和同步语义,而用户结果不需要这些机制。 +**让每个 Phase 在自身 disclosure 内容中持有状态。** 拒绝,因为隐藏外层运行会卸载这些内容,并在同一条已挂载工作流记录中丢失独立的 Phase 选择。 + +**持久化展开、确认或 activity epoch。** 拒绝,因为当前工作流事实与仅追加成员数已经提供全部所需边沿。持久化会增加第二个持久归属方以及本展示选择不需要的同步语义。 ## 后果 -工作流记录无需预备点击即可展示当前工作与异常结果,并在正常完成后回收对话空间,同时不牺牲复盘能力。自动控制期间的交互语义保持真实,同一份持久记录在实时渲染、刷新和历史重建时得到相同初始状态。 +工作流记录会提示生命周期变化,同时在所有状态下都允许用户收起。正常完成会回收空间,当前焦点保持安全,嵌套 Phase 选择在外层隐藏期间保留;同一份持久记录在刷新或历史回放时会重建确定性的初始状态。 -代价是有意保留的本地重置行为。父工作流关闭或组件卸载时,阶段选择会消失;由于产品没有确认状态,异常记录不能手动隐藏。以后若要支持任一行为,需要单独决定所有权与持久化,而不能隐式扩展这项本地生命周期。 +这项本地生命周期会在 renderer remount 时重置,无法跨刷新、设备或用户记住选择。若要增加该行为,需要单独决定持久化与陈旧选择语义,而不能隐式扩展这项展示状态。 diff --git a/apps/web/tests/snapshots/workflow-run/ui-live.expected.md b/apps/web/tests/snapshots/workflow-run/ui-live.expected.md new file mode 100644 index 0000000000..9e7f7b0ddc --- /dev/null +++ b/apps/web/tests/snapshots/workflow-run/ui-live.expected.md @@ -0,0 +1,23 @@ +- 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): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:": + - img + - img + - text: "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:" +- text: Running +- button "Tool call workflow ·": + - img + - img + - text: Tool call workflow · +- button "snapshot-flow 1 member Running" [expanded]: + - img + - text: snapshot-flow 1 member Running +- button "Run 1 member Running 1": + - img + - text: Run 1 member Running 1 +- status: Deep diving... diff --git a/apps/web/tests/snapshots/workflow-run/ui.expected.md b/apps/web/tests/snapshots/workflow-run/ui.expected.md index d9fe664863..5ad87e217b 100644 --- a/apps/web/tests/snapshots/workflow-run/ui.expected.md +++ b/apps/web/tests/snapshots/workflow-run/ui.expected.md @@ -13,13 +13,9 @@ - img - img - text: Tool call workflow · -- button "snapshot-flow 1 member Completed" [expanded]: +- button "snapshot-flow 1 member Completed": - img - text: snapshot-flow 1 member Completed -- button "Run 1 member Completed 1" [expanded]: - - img - - text: Run 1 member Completed 1 -- text: Reply with exactly the word WF_CHILD_OK and not… Completed - button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.": - img - img diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index 4cbae8e6e2..04f79cccd4 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -20,6 +20,7 @@ import { const MODE = webSnapshotMode() const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workflow-run', import.meta.url)) +const UI_LIVE_EXPECTED = join(SNAPSHOT_DIR, 'ui-live.expected.md') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const PARENT_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.jsonl') const CHILD_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl') @@ -52,7 +53,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = scaffold = await launchWebScaffold({ replayFixture: PARENT_FIXTURE, replayChildFixtures: [CHILD_FIXTURE], - paceMs: 25, + paceMs: 50, }) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -78,14 +79,30 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await workflow.waitFor({ timeout: 30_000 }) const disclosures = workflow.locator('[data-disclosure-row]') await disclosures.nth(1).waitFor({ timeout: 15_000 }) - expect(await disclosures.nth(0).getAttribute('role')).toBeNull() - expect(await disclosures.nth(0).getAttribute('aria-expanded')).toBeNull() - expect(await disclosures.nth(1).getAttribute('role')).toBeNull() - expect(await disclosures.nth(1).getAttribute('aria-expanded')).toBeNull() - expect(await disclosures.nth(0).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer') - expect(await disclosures.nth(1).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer') + const runDisclosure = disclosures.nth(0) + const phaseDisclosure = disclosures.nth(1) + expect(await runDisclosure.getAttribute('role')).toBe('button') + expect(await runDisclosure.getAttribute('aria-expanded')).toBe('true') + expect(await phaseDisclosure.getAttribute('role')).toBe('button') + expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('true') + expect(await runDisclosure.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer') + expect(await phaseDisclosure.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer') const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ }) await member.waitFor({ timeout: 15_000 }) + + await phaseDisclosure.click() + expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('false') + expect(await member.count()).toBe(0) + const liveSnapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_LIVE_EXPECTED, liveSnapshot, MODE) + await phaseDisclosure.press('Enter') + await member.waitFor() + await runDisclosure.click() + expect(await runDisclosure.getAttribute('aria-expanded')).toBe('false') + expect(await disclosures.count()).toBe(1) + await runDisclosure.press('Space') + expect(await disclosures.count()).toBe(2) + expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('true') await member.focus() const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color) @@ -171,6 +188,8 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const workflow = page.getByRole('button', { name: /^snapshot-flow/ }) await workflow.waitFor({ timeout: 15_000 }) expect(await workflow.getAttribute('aria-expanded')).toBe('false') + const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) await workflow.click() const phase = page.getByRole('button', { name: /^Run/ }) await phase.waitFor() @@ -179,13 +198,11 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0) - const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) - await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }, 60_000) it('stays clean and owns only its one golden', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui-live.expected.md', 'ui.expected.md']) }) }) diff --git a/packages/client/ui-workflow-run/README.i18n.yaml b/packages/client/ui-workflow-run/README.i18n.yaml index ed480dc9c8..7f4dd23638 100644 --- a/packages/client/ui-workflow-run/README.i18n.yaml +++ b/packages/client/ui-workflow-run/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/client/ui-workflow-run/README.md -README.md: 3fcdc632b54be7d1f8f452c6f8b3b2e1525953aa -README.zh.md: 5f0ba0c8f8a251d0ce5977d469ee60637f0454db +README.md: 837ae237258430d942ebdd28a0f4d6efd2876de8 +README.zh.md: dfec61005f8f06637602722041f3233219b7216b diff --git a/packages/client/ui-workflow-run/README.md b/packages/client/ui-workflow-run/README.md index 3fcdc632b5..837ae23725 100644 --- a/packages/client/ui-workflow-run/README.md +++ b/packages/client/ui-workflow-run/README.md @@ -12,7 +12,7 @@ Phase groups come only from members that actually started. Exact phase strings s ## Presentation and navigation -The run and each phase derive disclosure control from their current lifecycle facts. The run stays expanded while its own status is running, failed, cancelled, or interrupted, or while any phase contains such a member; each affected phase also stays expanded. Forced-open headers are static expanded rows without button, keyboard, or `aria-expanded` promises. A phase folds once when every member completes, and the run folds once when it and every phase complete. Each clean layer then exposes an ordinary disclosure control whose local choice survives clean rerenders; new activity takes control again, and a remount derives the initial state from current data. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. +The run and each phase are controlled disclosures in every status. A mount opens running, failed, cancelled, and interrupted levels and closes fully completed levels; users can then toggle either level with the full row, Enter, or Space. Ordinary running updates preserve the current choice, the first abnormal edge opens once, normal completion closes once, and a completed phase plus the outer run open again when a new running member starts under the same phase key. Completion updates the visible status immediately but delays its automatic close while focus remains inside the content. `WorkflowRunPanel` owns the phase choices, so closing and reopening the outer run does not reset them; a renderer remount reconstructs every initial choice from durable facts. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive. diff --git a/packages/client/ui-workflow-run/README.zh.md b/packages/client/ui-workflow-run/README.zh.md index 5f0ba0c8f8..dfec61005f 100644 --- a/packages/client/ui-workflow-run/README.zh.md +++ b/packages/client/ui-workflow-run/README.zh.md @@ -12,7 +12,7 @@ ## 展示与导航 -运行和每个阶段都从当前生命周期事实派生 disclosure 控制。运行自身处于运行中、失败、已取消或已中断,或者任一阶段包含这些状态的成员时,运行保持展开;受影响的阶段也保持展开。强制展开的标题行只是静态展开行,不承诺按钮、键盘操作或 `aria-expanded`。阶段在全部成员完成时折叠一次;运行在自身和全部阶段都完成时折叠一次。每个干净层级随后恢复普通 disclosure 控件,其本地选择在干净状态的 rerender 中保持;新活动会重新取得控制,remount 则从当前数据派生初始状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。 +运行和每个阶段在所有状态下都是受控 disclosure。挂载时,运行中、失败、已取消和已中断层级默认展开,全部完成的层级默认折叠;此后用户可以点击整行,或按 Enter、Space 切换任一层级。普通运行更新保留当前选择,首次异常边沿只自动展开一次,正常完成只自动折叠一次;已完成阶段在同一 phase key 下开始新的运行成员时,该 Phase 与外层运行会再次自动展开。完成状态会立即更新,但只要焦点仍位于展开内容内,自动折叠就会等待焦点离开。Phase 选择由 `WorkflowRunPanel` 持有,因此关闭并重新打开外层运行不会重置它们;renderer remount 会从持久事实重建每层的初始选择。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。 只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。 diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index 5e24a16717..a839403266 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -1,4 +1,7 @@ -import { useState, type ReactNode } from 'react' +import { + useLayoutEffect, useMemo, useRef, useState, + type FocusEvent, type ReactNode, +} from 'react' import { DisclosureRow, IconChevronRightOutline14, StateDot, type DisclosureRowProps, type StateDotState, @@ -63,34 +66,85 @@ function memberCount(count: number, t: WorkflowRunPanelProps['t']): string { return t(count === 1 ? 'run.members.one' : 'run.members.other', { count }) } -function phaseRequiresExpansion(phase: WorkflowRunPhaseData): boolean { - return phase.members.some(member => member.status !== 'completed') +type DisclosureMode = 'clean' | 'running' | 'abnormal' + +interface DisclosureFacts { + readonly mode: DisclosureMode + readonly activityCount: number } -type StatusDisclosureProps = Omit - -/* v8 ignore next -- DisclosureRow requires the callback but cannot invoke it when expandable is false. */ -const forcedOpenToggle = (): void => {} - -function ManualDisclosure(props: StatusDisclosureProps) { - const [open, setOpen] = useState(false) - return ( - { setOpen(value => !value) }} - /> - ) +interface DisclosureState extends DisclosureFacts { + readonly open: boolean + readonly pendingCleanCollapse: boolean } -function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & { - /** Remount a clean Phase when its append-only member count changes between batched renders. */ - readonly cleanCycleKey?: number | undefined - readonly requiresExpansion: boolean -}) { - if (!requiresExpansion) return - return +interface WorkflowDisclosureState { + readonly run: DisclosureState + readonly phases: ReadonlyMap +} + +type StatusDisclosureProps = Omit + +function StatusDisclosure(props: StatusDisclosureProps) { + return +} + +function abnormal(status: WorkflowRunStatus): boolean { + return status === 'failed' || status === 'cancelled' || status === 'interrupted' +} + +function phaseDisclosureFacts(phase: WorkflowRunPhaseData): DisclosureFacts { + const mode = phase.members.some(member => abnormal(member.status)) + ? 'abnormal' + : phase.members.some(member => member.status === 'running') ? 'running' : 'clean' + return { mode, activityCount: phase.members.length } +} + +function runDisclosureFacts( + status: WorkflowRunStatus, + phases: readonly (readonly [string, DisclosureFacts])[], +): DisclosureFacts { + const mode = abnormal(status) || phases.some(([, facts]) => facts.mode === 'abnormal') + ? 'abnormal' + : status === 'running' || phases.some(([, facts]) => facts.mode === 'running') + ? 'running' + : 'clean' + const activityCount = phases.reduce((count, [, facts]) => count + facts.activityCount, 0) + return { mode, activityCount } +} + +function initialDisclosureState(facts: DisclosureFacts): DisclosureState { + return { ...facts, open: facts.mode !== 'clean', pendingCleanCollapse: false } +} + +function advanceDisclosureState( + current: DisclosureState, + facts: DisclosureFacts, + focusWithin: boolean, +): DisclosureState { + const sameFacts = current.mode === facts.mode && current.activityCount === facts.activityCount + if (sameFacts) { + if (!current.pendingCleanCollapse || focusWithin) return current + return { ...current, open: false, pendingCleanCollapse: false } + } + if (facts.mode === 'clean') { + const deferCollapse = current.open && focusWithin + return { ...facts, open: deferCollapse, pendingCleanCollapse: deferCollapse } + } + if (current.mode === 'clean' || (facts.mode === 'abnormal' && current.mode !== 'abnormal')) { + return { ...facts, open: true, pendingCleanCollapse: false } + } + return { ...facts, open: current.open, pendingCleanCollapse: false } +} + +function focusIsWithin(element: HTMLElement | null | undefined): boolean { + if (element === null || element === undefined) return false + return element.contains(element.ownerDocument.activeElement) +} + +function collapsePending(state: DisclosureState): DisclosureState { + if (!state.pendingCleanCollapse) return state + return { ...state, open: false, pendingCleanCollapse: false } } function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { @@ -128,11 +182,12 @@ function navigableMembers( return result } -function RunHeader({ children, count, name, requiresExpansion, status, t }: { +function RunHeader({ children, count, name, onToggle, open, status, t }: { readonly children: ReactNode readonly count: number readonly name: string - readonly requiresExpansion: boolean + readonly onToggle: () => void + readonly open: boolean readonly status: WorkflowRunStatus readonly t: WorkflowRunPanelProps['t'] }) { @@ -140,7 +195,8 @@ function RunHeader({ children, count, name, requiresExpansion, status, t }: { } title={t('run.title', { name })} - requiresExpansion={requiresExpansion} + open={open} + onToggle={onToggle} expandOnRowClick previewChevron={false} keepContentWhenOpen @@ -170,6 +226,21 @@ function MemberRow({ member, navigable, openSession, t }: { readonly t: WorkflowRunPanelProps['t'] }) { const name = readableMember(member.label, t) + const buttonRef = useRef(null) + const [keepFocusedButton, setKeepFocusedButton] = useState(navigable) + const renderButton = navigable || keepFocusedButton + + useLayoutEffect(() => { + if (navigable) { + if (!keepFocusedButton) setKeepFocusedButton(true) + return + } + const button = buttonRef.current + if (button === null || button.ownerDocument.activeElement !== button) { + if (keepFocusedButton) setKeepFocusedButton(false) + } + }, [keepFocusedButton, navigable]) + const content = ( <> @@ -177,23 +248,33 @@ function MemberRow({ member, navigable, openSession, t }: { {t(STATUS_KEYS[member.status])} ) - if (!navigable) { + if (!renderButton) { return
{content}
} return ( ) } -function PhaseSection({ phase, navigable, openSession, t }: { +function PhaseSection({ + contentRef, onContentBlur, onToggle, open, phase, navigable, openSession, t, +}: { + readonly contentRef: (element: HTMLDivElement | null) => void + readonly onContentBlur: (event: FocusEvent) => void + readonly onToggle: () => void + readonly open: boolean readonly phase: WorkflowRunPhaseData readonly navigable: readonly SessionId[] readonly openSession: WorkflowRunInjected['openSession'] @@ -203,8 +284,8 @@ function PhaseSection({ phase, navigable, openSession, t }: { } title={readablePhase(phase.phase, t)} - cleanCycleKey={phase.members.length} - requiresExpansion={phaseRequiresExpansion(phase)} + open={open} + onToggle={onToggle} expandOnRowClick previewChevron={false} keepContentWhenOpen @@ -220,7 +301,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { )} > -
+
{phase.members.map(member => ( count + phase.members.length, 0) - const requiresExpansion = node.data.status !== 'completed' - || node.data.phases.some(phaseRequiresExpansion) + const phaseFacts = useMemo(() => node.data.phases.map(phase => ( + [phase.key, phaseDisclosureFacts(phase)] as const + )), [node.data.phases]) + const runFacts = useMemo( + () => runDisclosureFacts(node.data.status, phaseFacts), + [node.data.status, phaseFacts], + ) + const totalMembers = runFacts.activityCount + const [disclosures, setDisclosures] = useState(() => ({ + run: initialDisclosureState(runFacts), + phases: new Map(phaseFacts.map(([key, facts]) => [key, initialDisclosureState(facts)])), + })) + const runContentRef = useRef(null) + const phaseContentRefs = useRef(new Map()) const navigable = useSessions( sessions => navigableMembers(sessions, node.data.phases, sessionId), shallowEqual, ) + + useLayoutEffect(() => { + setDisclosures((current) => { + const phases = new Map() + let phasesChanged = current.phases.size !== phaseFacts.length + let phaseBecameActive = false + for (const [key, facts] of phaseFacts) { + const previous = current.phases.get(key) + const next = previous === undefined + ? initialDisclosureState(facts) + : advanceDisclosureState(previous, facts, focusIsWithin(phaseContentRefs.current.get(key))) + phases.set(key, next) + if (next !== previous) phasesChanged = true + if (previous?.mode === 'clean' && facts.mode !== 'clean') phaseBecameActive = true + } + const advancedRun = advanceDisclosureState( + current.run, + runFacts, + focusIsWithin(runContentRef.current), + ) + const run = phaseBecameActive && !advancedRun.open + ? { ...advancedRun, open: true, pendingCleanCollapse: false } + : advancedRun + return run !== current.run || phasesChanged ? { run, phases } : current + }) + }, [disclosures.run.open, phaseFacts, runFacts]) + + const toggleRun = (): void => { + setDisclosures(current => ({ + ...current, + run: { + ...current.run, + open: !current.run.open, + pendingCleanCollapse: false, + }, + })) + } + const togglePhase = (key: string, facts: DisclosureFacts): void => { + setDisclosures((current) => { + const phases = new Map(current.phases) + /* v8 ignore next -- layout effects insert every rendered phase before user input can toggle it. */ + const phase = phases.get(key) ?? initialDisclosureState(facts) + phases.set(key, { + ...phase, + open: !phase.open, + pendingCleanCollapse: false, + }) + return { ...current, phases } + }) + } + const settleRunBlur = (event: FocusEvent): void => { + if (event.currentTarget.contains(event.relatedTarget)) return + setDisclosures((current) => { + const run = collapsePending(current.run) + return run === current.run ? current : { ...current, run } + }) + } + const settlePhaseBlur = (key: string, event: FocusEvent): void => { + if (event.currentTarget.contains(event.relatedTarget)) return + setDisclosures((current) => { + const phase = current.phases.get(key) + /* v8 ignore next -- the blur handler unmounts with the phase whose state it addresses. */ + if (phase === undefined) return current + const next = collapsePending(phase) + if (next === phase) return current + const phases = new Map(current.phases) + phases.set(key, next) + return { ...current, phases } + }) + } + return (
-
+
{node.data.phases.length === 0 ? {t('run.empty')} - : node.data.phases.map(phase => ( - - ))} + : node.data.phases.map((phase) => { + const facts = phaseDisclosureFacts(phase) + const disclosure = disclosures.phases.get(phase.key) ?? initialDisclosureState(facts) + return ( + { + if (element === null) phaseContentRefs.current.delete(phase.key) + else phaseContentRefs.current.set(phase.key, element) + }} + onContentBlur={(event) => { settlePhaseBlur(phase.key, event) }} + onToggle={() => { togglePhase(phase.key, facts) }} + open={disclosure.open} + phase={phase} + navigable={navigable} + openSession={openSession} + t={t} + /> + ) + })}
diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 8be62e8209..e14d203421 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -27,6 +27,7 @@ afterEach(cleanup) const PARENT_ID = 'parent' as SessionId const CHILD_ID = 'child-1' as SessionId +const SECOND_ID = 'child-2' as SessionId interface ChatSnapshot { readonly nodes: ReadonlyMap @@ -301,28 +302,62 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi } describe('WorkflowRunPanel', () => { - it('forces running run and phase content open without false disclosure controls', () => { - const view = render( { + const running: WorkflowRunChatData = { name: 'audit', status: 'running', phases: [phase({ key: 'research', phase: 'Research' })], - })} />) - expect(screen.getByText('worker')).toBeTruthy() - expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() - expect(screen.queryByRole('button', { name: /Research/ })).toBeNull() - const rows = [...view.container.querySelectorAll('[data-disclosure-row]')] - expect(rows).toHaveLength(2) - for (const row of rows) { - expect(row.getAttribute('role')).toBeNull() - expect(row.getAttribute('tabindex')).toBeNull() - expect(row.getAttribute('aria-expanded')).toBeNull() - expect(row.getAttribute('data-expandable')).toBeNull() } + const view = render() + const runHeader = screen.getByRole('button', { name: /^audit/ }) + const phaseHeader = screen.getByRole('button', { name: /Research/ }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('worker')).toBeTruthy() + + fireEvent.click(phaseHeader) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('worker')).toBeNull() + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + + view.rerender() + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('button', { name: /Research/ })).toBeNull() + fireEvent.keyDown(runHeader, { key: 'ArrowDown' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(runHeader, { key: ' ' }) + const updatedPhase = screen.getByRole('button', { name: /Research/ }) + expect(updatedPhase.getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('运行中 2')).toBeTruthy() + fireEvent.keyDown(updatedPhase, { key: 'Enter' }) + expect(screen.getByText('worker')).toBeTruthy() + expect(screen.getByText('second')).toBeTruthy() + + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('button', { name: /Research/ })).toBeNull() + fireEvent.keyDown(runHeader, { key: ' ' }) + expect(screen.getByRole('button', { name: /Research/ }).getAttribute('aria-expanded')).toBe('true') }) - it('folds each clean transition once and preserves review choices until activity returns', () => { + it('folds each normal completion once and opens a new same-key activity cycle', () => { const running: WorkflowRunChatData = { name: 'audit', status: 'running', phases: [phase()], } const view = render() + const runningPhase = screen.getByRole('button', { name: /未分阶段/ }) + fireEvent.click(runningPhase) + fireEvent.keyDown(runningPhase, { key: 'Enter' }) + expect(screen.getByText('worker')).toBeTruthy() + const phaseCompleted: WorkflowRunChatData = { ...running, phases: [phase({ @@ -338,27 +373,8 @@ describe('WorkflowRunPanel', () => { fireEvent.click(phaseHeader) expect(screen.getByText('done')).toBeTruthy() - const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' } - view.rerender() - const runHeader = screen.getByRole('button', { name: /^audit/ }) - expect(runHeader.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText('未分阶段')).toBeNull() - fireEvent.keyDown(runHeader, { key: 'ArrowDown' }) - expect(runHeader.getAttribute('aria-expanded')).toBe('false') - fireEvent.keyDown(runHeader, { key: 'Enter' }) - expect(runHeader.getAttribute('aria-expanded')).toBe('true') - const completedPhase = screen.getByRole('button', { name: /未分阶段/ }) - fireEvent.keyDown(completedPhase, { key: 'Enter' }) - expect(screen.getByText('done')).toBeTruthy() - fireEvent.keyDown(runHeader, { key: ' ' }) - expect(runHeader.getAttribute('aria-expanded')).toBe('false') - fireEvent.keyDown(runHeader, { key: ' ' }) - expect(runHeader.getAttribute('aria-expanded')).toBe('true') - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) - expect(screen.getByText('done')).toBeTruthy() - const cleanUpdate: WorkflowRunChatData = { - ...completed, + ...phaseCompleted, phases: [phase({ members: [{ seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed', @@ -368,13 +384,44 @@ describe('WorkflowRunPanel', () => { view.rerender() expect(screen.getByText('reviewed')).toBeTruthy() - view.rerender() - expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() - expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() - expect(screen.getByText('worker')).toBeTruthy() - view.rerender() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + const runHeader = screen.getByRole('button', { name: /^audit/ }) + fireEvent.click(runHeader) + const renewed: WorkflowRunChatData = { + name: 'audit', status: 'running', + phases: [phase({ + members: [ + { seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' }, + { seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'running' }, + ], + })], + } + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('new')).toBeTruthy() + + const renewedPhaseCompleted: WorkflowRunChatData = { + ...renewed, + phases: [phase({ + members: [ + { seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' }, + { seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'completed' }, + ], + })], + } + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + + view.rerender() expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText('未分阶段')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') }) it('refolds a phase when a complete activity cycle arrives as one clean update', () => { @@ -399,10 +446,32 @@ describe('WorkflowRunPanel', () => { expect(screen.queryByText('second')).toBeNull() }) + it('initializes a newly observed phase before it becomes interactive', () => { + const running: WorkflowRunChatData = { + name: 'dynamic-phase', status: 'running', + phases: [phase({ key: 'research', phase: 'Research' })], + } + const view = render() + view.rerender() + const build = screen.getByRole('button', { name: /Build/ }) + expect(build.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(build) + expect(build.getAttribute('aria-expanded')).toBe('false') + }) + it('derives the zero-member running and completed states from the current run status', () => { const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] } const view = render() - expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull() + expect(screen.getByRole('button', { name: /^empty/ }).getAttribute('aria-expanded')).toBe('true') expect(screen.getByText('没有启动成员')).toBeTruthy() view.rerender() const header = screen.getByRole('button', { name: /^empty/ }) @@ -413,31 +482,60 @@ describe('WorkflowRunPanel', () => { }) it.each(['failed', 'cancelled', 'interrupted'] as const)( - 'bubbles a %s member to the run and keeps a matching run outcome open', + 'initializes %s attention as an expanded disclosure that remains manually collapsible', (status) => { - const memberView = render() - expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull() - expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + const runHeader = screen.getByRole('button', { name: /^member-outcome/ }) + const phaseHeader = screen.getByRole('button', { name: /未分阶段/ }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') expect(screen.getByText(status)).toBeTruthy() - memberView.unmount() - - render() - expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull() - expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText('done')).toBeNull() + fireEvent.click(phaseHeader) + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') }, ) + it('opens the first abnormal edge once and preserves later abnormal choices', () => { + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + + const failed: WorkflowRunChatData = { + name: 'audit', status: 'failed', + phases: [phase({ + members: [{ seq: 1, label: 'failed', childId: CHILD_ID, status: 'failed' }], + })], + } + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy() + }) + it('keeps clean sibling phases independent and preserves empty versus absent names', () => { render( { }] }), ], })} />) - expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() + const runHeader = screen.getByRole('button', { name: /^audit/ }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') const cleanPhase = screen.getByRole('button', { name: /空阶段名/ }) expect(cleanPhase.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + const activePhase = screen.getByRole('button', { name: /未分阶段/ }) + expect(activePhase.getAttribute('aria-expanded')).toBe('true') expect(screen.queryByText('空成员名')).toBeNull() expect(screen.getByText('second')).toBeTruthy() fireEvent.click(cleanPhase) expect(screen.getByText('空成员名')).toBeTruthy() expect(screen.getByText('second')).toBeTruthy() - fireEvent.click(cleanPhase) + fireEvent.click(activePhase) + expect(screen.queryByText('second')).toBeNull() + expect(screen.getByText('空成员名')).toBeTruthy() + fireEvent.click(runHeader) + fireEvent.click(runHeader) + expect(screen.getByRole('button', { name: /空阶段名/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) expect(screen.queryByText('空成员名')).toBeNull() - expect(screen.getByText('second')).toBeTruthy() }) it('renders mixed and interrupted aggregate status while attention stays visible', () => { @@ -496,6 +602,107 @@ describe('WorkflowRunPanel', () => { expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2) }) + it('defers normal completion collapse until focused member content loses focus', () => { + const sessions = listState({ + ids: [PARENT_ID, CHILD_ID, SECOND_ID], + byId: { + ...listState().byId, + [SECOND_ID]: { + id: SECOND_ID, displayTitle: 'second', parentId: PARENT_ID, origin: 'subagent', + running: true, blank: false, updatedAt: 0, + }, + }, + }) + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase({ + members: [ + { seq: 1, label: 'worker', childId: CHILD_ID, status: 'running' }, + { seq: 2, label: 'second', childId: SECOND_ID, status: 'running' }, + ], + })], + } + const view = render() + const member = screen.getByRole('button', { name: '打开 worker' }) + const second = screen.getByRole('button', { name: '打开 second' }) + const runHeader = screen.getByRole('button', { name: /^audit/ }) + const phaseHeader = screen.getByRole('button', { name: /未分阶段/ }) + member.focus() + expect(document.activeElement).toBe(member) + fireEvent.blur(member, { relatedTarget: second }) + second.focus() + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + + const outside = document.createElement('button') + document.body.append(outside) + fireEvent.blur(second, { relatedTarget: outside }) + outside.focus() + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + member.focus() + + view.rerender() + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + const retained = screen.getByRole('button', { name: 'worker' }) + expect(retained.getAttribute('aria-disabled')).toBe('true') + expect(document.activeElement).toBe(retained) + + fireEvent.blur(retained, { relatedTarget: outside }) + outside.focus() + expect(document.activeElement).toBe(outside) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(runHeader) + const completedPhase = screen.getByRole('button', { name: /未分阶段/ }) + expect(completedPhase.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(completedPhase) + expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull() + expect(screen.getByText('worker')).toBeTruthy() + outside.remove() + }) + + it('settles a deferred phase close when the user hides the outer run', () => { + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + const member = screen.getByRole('button', { name: '打开 worker' }) + member.focus() + view.rerender() + const runHeader = screen.getByRole('button', { name: /^audit/ }) + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') + fireEvent.click(runHeader) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(runHeader) + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + }) + + it('reinitializes manual choices from durable facts after a renderer remount', () => { + const data: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + view.unmount() + render() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true') + }) + it('opens only a running ordinary-list subagent proven to have this parent', () => { const data: WorkflowRunChatData = { name: 'audit', status: 'running', phases: [phase()], @@ -506,6 +713,16 @@ describe('WorkflowRunPanel', () => { expect(openSession).toHaveBeenCalledWith('child-1') }) + it('promotes a running member when its ordinary Session row arrives', () => { + const data: WorkflowRunChatData = { + name: 'audit', status: 'running', phases: [phase()], + } + const view = render() + expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull() + view.rerender() + expect(screen.getByRole('button', { name: '打开 worker' })).toBeTruthy() + }) + it.each([ ['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'], ['remote row', listState({ byId: { From 85616ec627a37334607b8499e3329b0d785328d7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:04:16 +0800 Subject: [PATCH 38/95] test(web): pin the cold-boot settings.describe budget --- apps/web/tests/startup-rpc-budget.e2e.ts | 58 ++++++++++++++++++++++++ apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 3 files changed, 60 insertions(+) create mode 100644 apps/web/tests/startup-rpc-budget.e2e.ts diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts new file mode 100644 index 0000000000..5f8d7dd0e1 --- /dev/null +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -0,0 +1,58 @@ +// Cold-boot RPC budget. The describe mirror (packages/client/ui-settings) is +// the one `settings.describe` reader in the browser, so startup describe +// traffic stays bounded no matter how many client plugins own a preference. +// A regression here means a consumer bypassed the mirror — grep for +// `settings.describe(` outside ui-settings' client sources. +// +// Zero model calls: the lane only boots chrome, so no replay fixture mounts. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +/** + * Itemized so the budget stays explainable. The mirror reads twice: once + * eagerly at bind time over HTTP, and once on the first-connection reset — + * that second read closes the window where a document commit lands between + * the eager read and the SSE subscription and its invalidation is lost. + * Beside it, the direct callers not yet migrated: welcome notice (1) + models + * onboarding (1) + plugin-directory tab at bind and at reset (2) + + * agent-preset settings row on reset (1). Batch 2 migrates those onto the + * mirror and tightens this to 2. + */ +const DESCRIBE_BUDGET = 7 + +let scaffold: WebScaffold +let browser: Browser +let page: Page + +beforeAll(async () => { + scaffold = await launchWebScaffold() + browser = await chromium.launch() +}) + +afterAll(async () => { + await page?.close() + await browser?.close() + await scaffold?.close() +}) + +describe('startup RPC budget', () => { + it('keeps cold-boot settings.describe within the mirror budget', async () => { + page = await newEnglishPage(browser) + watchConsole(page) + const calls: string[] = [] + page.on('request', (request) => { + const url = new URL(request.url()) + if (url.pathname.startsWith('/api/')) calls.push(url.pathname.slice('/api/'.length)) + }) + await page.goto(scaffold.baseUrl) + // Boot settles when the workspace picker is interactive; the trailing wait + // absorbs the first-connection reset wave the budget must include. + await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) + await page.waitForTimeout(3000) + const describeCount = calls.filter(method => method === 'settings.describe').length + expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBeLessThanOrEqual(DESCRIBE_BUDGET) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 6e706c7123..11cd1ec807 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,6 +24,7 @@ "exclude": [ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", + "tests/startup-rpc-budget.e2e.ts", "tests/minimal-preset.snapshot.ts", "tests/message-feedback-protocol.snapshot.ts", "tests/live-interactions.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index 459036247a..80c91c2a14 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -13,6 +13,7 @@ "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", + "apps/web/tests/startup-rpc-budget.e2e.ts", "apps/web/tests/minimal-preset.snapshot.ts", "apps/web/tests/message-feedback-protocol.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", From 000cb1a0dbe46b8e3ee679ce8d23c8b9ebe22076 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 17 Aug 2026 17:05:18 +0800 Subject: [PATCH 39/95] docs(subagent): clarify unattended review contracts --- .../acp-agent/tests/fixtures/subagent-result-diagnostic.ts | 2 +- packages/subagent/subagent-claude-code/src/index.ts | 4 ++-- .../subagent/subagent-claude-code/tests/messages-fixture.ts | 2 +- packages/subagent/subagent/src/run-settlement.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts index f381bf3a81..3f9f9ebe55 100644 --- a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts +++ b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts @@ -44,7 +44,7 @@ class DiagnosticProvider implements SubagentProvider { } } -/** Register the fixed snapshot provider under the public product provider name. */ +/** Register the fixed provider behind the public Codex-shaped snapshot tool. */ export function apply(ctx: Context): void { ctx.subagents.registerProvider(new DiagnosticProvider()) } diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 4960e54def..4095ca8f8e 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -29,8 +29,8 @@ import { export const name = 'subagent-claude-code' export const inject = ['subagents', 'subprocess'] -/* jscpd:ignore-start -- sibling product providers intentionally expose the - * same two deployment-owned fields without adding a shared config owner. */ +/* jscpd:ignore-start -- sibling product providers intentionally expose + * overlapping deployment-owned fields without adding a shared config owner. */ /** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts index accab2951b..78d2f84176 100644 --- a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -193,7 +193,7 @@ export async function startMessagesFixture( ) { complete(response, body, behavior.finalText) } - // A hold deliberately leaves the response pending until client abort. + // A hold, or a tool-use without final text, waits for client abort. }) }) await new Promise((resolve, reject) => { diff --git a/packages/subagent/subagent/src/run-settlement.ts b/packages/subagent/subagent/src/run-settlement.ts index d23812ca6a..d17fdaad35 100644 --- a/packages/subagent/subagent/src/run-settlement.ts +++ b/packages/subagent/subagent/src/run-settlement.ts @@ -42,7 +42,7 @@ function runOutcome(result: SubagentResult): JobOutcome { case 'max-tokens': case 'refusal': return { status: 'failed', detail: failureDetail(result) } - // Merge-extensible reasons remain failures with their raw detail. + // Merge-extensible reasons remain failures with provider-authored detail. default: return { status: 'failed', detail: failureDetail(result) } } From bb3128f266268c9918a35b75cca2f7b9203841d7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:11:03 +0800 Subject: [PATCH 40/95] refactor(ui-settings-models): welcome notice reads through the settings scope --- .../ui-settings-models/src/client/index.ts | 34 +-- .../src/client/welcome-store.ts | 166 +++++++------ .../tests/apply.client.spec.ts | 60 +++-- .../tests/welcome-notice.client.spec.tsx | 52 ++-- .../tests/welcome-store.client.spec.ts | 235 ++++++++---------- 5 files changed, 283 insertions(+), 264 deletions(-) diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index dc7f32e370..e45e52a4e8 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -22,7 +22,7 @@ import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from './WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' -import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts' +import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts' import { ModelsSettingsStore } from './store.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' @@ -56,7 +56,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registration depends on each slot through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] /** * Register the Models section once the `settings.section` declaration is on @@ -85,10 +85,12 @@ export function apply(ctx: ClientContext): void { api: connection.api, t, }) - const welcomeController = new WelcomeNoticeStore( - connection.api, - connection.isLoopback ? 'host' : 'memory', - ) + // The scope's own memory mode is what keeps a remote browser process-local, + // so the store needs no isLoopback branch of its own. + const welcomeController = new WelcomeNoticeStore(ctx.settingsScope.bind({ + namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, + decode: decodeWelcomeSection, + })) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, hooks: { welcome: welcomeController.store }, @@ -96,23 +98,21 @@ export function apply(ctx: ClientContext): void { }) // Pushed invalidations converge every open surface without polling: any - // settings/credentials/topology change refetches once the page loaded. + // settings/credentials/topology change refetches once the page loaded. The + // welcome notice follows its settings scope, so the shared mirror already + // keeps it fresh without a subscription here. ctx.effect(() => { const refreshModels = (): void => { refreshIfLoaded(controller) } - const refreshAll = (): void => { - refreshModels() - refreshWelcomeIfLoaded(welcomeController) - } const disposers = [ - ctx.remote.$on('settings/document-updated', (ns) => { - refreshModels() - if (ns === WELCOME_NOTICE_SETTINGS_NAMESPACE) refreshWelcomeIfLoaded(welcomeController) - }), + ctx.remote.$on('settings/document-updated', () => { refreshModels() }), ctx.remote.$on('credentials/updated', refreshModels), ctx.remote.$on('llm/adapters-updated', refreshModels), - ctx.on('connection/reset', refreshAll), + ctx.on('connection/reset', refreshModels), ] - return () => { for (const dispose of disposers) dispose() } + return () => { + welcomeController.dispose() + for (const dispose of disposers) dispose() + } }, 'ui-settings-models: pushed invalidations') ctx.slots.inject('settings.section', () => ctx.slots.register({ diff --git a/packages/client/ui-settings-models/src/client/welcome-store.ts b/packages/client/ui-settings-models/src/client/welcome-store.ts index 6e139f1a43..9edd54a9cb 100644 --- a/packages/client/ui-settings-models/src/client/welcome-store.ts +++ b/packages/client/ui-settings-models/src/client/welcome-store.ts @@ -1,10 +1,14 @@ -/** Welcome-notice state, durable when the browser may use Host settings. */ +/** + * Welcome-notice state derived from the welcome settings scope. The scope is + * the transport: a loopback browser follows the durable Host section, while a + * remote browser's memory-mode scope never answers and the acknowledgement + * stays process-local here. + */ -import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { - WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION, } from '../onboarding-copy.ts' /** State rendered by the welcome step. */ @@ -14,113 +18,111 @@ export interface WelcomeNoticeState { error: string | null } -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} +/** The welcome section as the notice reads it. */ +export type WelcomeSection = Record -function acknowledgementOf(view: SettingsNamespaceView): string | undefined { - if (typeof view.value !== 'object' || view.value === null) return undefined - const value = (view.value as Record)[WELCOME_NOTICE_ACK_FIELD] - return typeof value === 'string' ? value : undefined +/** + * Accept any object section verbatim; a malformed durable value reads as an + * empty section, so the notice treats it as unacknowledged instead of leaving + * the scope stuck on its previous value. + * @param section - the wire section value. + * @returns the section object, or an empty one for non-object values. + */ +export function decodeWelcomeSection(section: unknown): WelcomeSection { + return typeof section === 'object' && section !== null && !Array.isArray(section) + ? section as WelcomeSection + : {} } /** Coordinates durable Host acknowledgement or a process-local remote fallback. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ readonly store: SnapshotStore = createSnapshotStore({ - status: 'idle', acknowledged: false, error: null, + status: 'idle' as const, acknowledged: false, error: null, }) - private generation = 0 + private localAcknowledged = false + private saving = false + private following: (() => void) | undefined /** - * @param api - settings wire face used for durable reads and writes. - * @param persistence - remote browsers use memory because settings is loopback-only. + * @param scope - the welcome settings namespace scope; its memory mode is + * what keeps a remote browser process-local. */ - constructor( - private readonly api: Pick, - private readonly persistence: 'host' | 'memory' = 'host', - ) {} + constructor(private readonly scope: SettingsScope) {} - /** Load the acknowledgement from Host settings or initialize process-local state. */ + /** Begin following the bound scope (idempotent) and publish its current answer. */ async load(): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') { - this.store.update((state) => { state.status = 'ready'; state.error = null }) - return - } - this.store.update((state) => { state.status = 'loading'; state.error = null }) - try { - const response = await this.api.settings.describe({}) - if (!response.result.ok) throw new Error(response.result.error.message) - const view = response.result.value.namespaces.find( - candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE, - ) - if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable') - if (generation !== this.generation) return - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION - state.error = null - }) - } catch (error) { - if (generation !== this.generation) return - this.store.update((state) => { - state.status = 'error' - state.acknowledged = false - state.error = messageOf(error) - }) - } + this.following ??= this.scope.subscribe(() => { this.derive() }) + this.derive() } /** - * Persist this copy version, or advance only this process for a remote browser. - * @returns true when the selected persistence mode accepted the acknowledgement. + * Persist this copy version, or advance only this process for a remote + * browser. Success is judged against the state the write left behind, so a + * refused or failed write reports false after its recovery read settles. + * @returns true when the selected persistence mode holds the acknowledgement. */ async acknowledge(): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') { - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = true - state.error = null - }) + if (this.scope.getSnapshot().mode === 'memory') { + this.localAcknowledged = true + this.derive() return true } + this.saving = true this.store.update((state) => { state.status = 'saving'; state.error = null }) try { - const response = await this.api.settings.mutate({ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }], + await this.scope.set(WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION) + } finally { + this.saving = false + } + this.derive() + const { acknowledged } = this.store.getSnapshot() + if (!acknowledged) { + this.store.update((state) => { + state.status = 'error' + state.error = 'the acknowledgement did not persist' }) - if (!response.result.ok) throw new Error(response.result.error.message) - if (generation === this.generation) { - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = true - state.error = null - }) - } - return true - } catch (error) { - if (generation === this.generation) { + } + return acknowledged + } + + /** Stop following the scope. */ + dispose(): void { + this.following?.() + this.following = undefined + } + + private derive(): void { + if (this.saving) return + const scope = this.scope.getSnapshot() + if (scope.mode === 'memory') { + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = this.localAcknowledged + state.error = null + }) + return + } + switch (scope.status) { + case 'loading': + this.store.update((state) => { state.status = 'loading'; state.error = null }) + return + case 'unavailable': this.store.update((state) => { state.status = 'error' state.acknowledged = false - state.error = messageOf(error) + state.error = 'welcome acknowledgement settings are unavailable' + }) + return + case 'ready': { + const acknowledged = scope.value?.[WELCOME_NOTICE_ACK_FIELD] === WELCOME_NOTICE_VERSION + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = acknowledged + state.error = null }) } - return false } } } - -/** - * Refresh only after welcome state has left idle. A memory-mode load retains - * acknowledgement so reconnect does not reopen a process-local notice. - * @param controller - welcome state owner whose current status decides whether to load. - */ -export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void { - if (controller.store.getSnapshot().status === 'idle') return - void controller.load() -} diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 39ba3e4b65..5267f72974 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -5,7 +5,11 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client' +import { + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, +} from '../src/onboarding-copy.ts' import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' @@ -14,7 +18,7 @@ import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -async function bench(isLoopback = true) { +async function bench(isLoopback = true, settings?: object) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) @@ -22,9 +26,10 @@ async function bench(isLoopback = true) { // The plugins inject `remote`; forwarded events reach them through the // same `$dispatch` handoff the connection sink makes. new TestRemote(ctx) - // The apply path only captures the wire face; no call leaves this fake - // until a section actually loads. - ctx.provide('connection', { api: {}, isLoopback } as never) + // Without a settings face the mirror's reads fail and stay contained; the + // Models join itself never fetches until a section actually loads. + ctx.provide('connection', { api: settings === undefined ? {} : { settings }, isLoopback } as never) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale } } @@ -43,7 +48,7 @@ function declare(slots: SlotRegistry): () => void { describe('ui-settings-models apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) }) it('registers the models nav entry for declarations before or after apply', async () => { @@ -204,8 +209,31 @@ describe('pushed invalidations', () => { expect(load).toHaveBeenCalledTimes(1) }) - it('routes only the onboarding namespace invalidation into welcome state', async () => { - const b = await bench() + it('welcome state follows the shared mirror across document commits', async () => { + // The welcome notice derives from its settings scope: a document commit + // reaches it through the mirror's one refresh, with no routing here. + const acknowledgement = { current: undefined as string | undefined } + const settings = { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'apply-welcome' as never, + result: { + ok: true as const, + value: { + writable: true, + hasDocument: false, + namespaces: [{ + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current }, + applies: 'live' as const, + secrets: [], + revision: 0, + }], + }, + }, + })), + } + const b = await bench(true, settings) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding') @@ -214,14 +242,14 @@ describe('pushed invalidations', () => { entry.inject as unknown as () => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected )() - injected.hooks.welcome.update((state) => { state.status = 'ready' }) - const load = vi.spyOn(injected.controller, 'load').mockResolvedValue() - - b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) - expect(load).not.toHaveBeenCalled() - b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 2]) - expect(load).toHaveBeenCalledOnce() - b.ctx.emit('connection/reset') - expect(load).toHaveBeenCalledTimes(2) + await injected.controller.load() + await vi.waitFor(() => { + expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) + }) + acknowledgement.current = WELCOME_NOTICE_VERSION + b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 1]) + await vi.waitFor(() => { + expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + }) }) }) diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 8b8858c64a..7554e1b77f 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -2,9 +2,13 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { + SettingsDescribeMirror, SettingsScopeController, +} from '@deepseek-ai/dsh-client-ui-settings/client' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx' -import { WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import type { WelcomeSection } from '../src/client/welcome-store.ts' import { en, zh } from '../src/client/locales.ts' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, @@ -20,7 +24,24 @@ function response(value: T) { return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } } } -function mount(version?: string, mutateImpl: () => Promise = () => Promise.resolve(response({}))) { +function welcomeView(value: unknown, revision = 0) { + return { + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value, + base: {}, + user: {}, + applies: 'live' as const, + secrets: [], + revision, + } +} + +function mount( + version?: string, + mutateImpl: () => Promise = () => + Promise.resolve(response(welcomeView({ [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, 1))), +) { const appRoot = document.createElement('div') appRoot.id = 'root' document.body.append(appRoot) @@ -30,21 +51,19 @@ function mount(version?: string, mutateImpl: () => Promise = () => Prom describe: () => Promise.resolve(response({ writable: true, hasDocument: false, - namespaces: [{ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - schema: {}, - value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version }, - base: {}, - user: {}, - applies: 'live' as const, - secrets: [], - revision: 0, - }], + namespaces: [welcomeView(version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version })], })), mutate, }, } - const controller = new WelcomeNoticeStore(api as never) + const mirror = new SettingsDescribeMirror(api as never) + const scope = new SettingsScopeController( + api as never, + { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, + mirror, + ) + const controller = new WelcomeNoticeStore(scope) + void mirror.load() const complete = vi.fn() const unusedHook = (() => { throw new Error('unused standard hook') }) as never const props: WelcomeNoticeProps = { @@ -57,7 +76,7 @@ function mount(version?: string, mutateImpl: () => Promise = () => Prom useWelcome: bindSnapshotSelector(controller.store), t: key => zh[key], } - return { ...render(), complete, controller, mutate, appRoot } + return { ...render(), complete, controller, mirror, mutate, appRoot } } describe('WelcomeNotice', () => { @@ -100,7 +119,10 @@ describe('WelcomeNotice', () => { it('skips itself when this exact version was already acknowledged', async () => { const h = mount(WELCOME_NOTICE_VERSION) - await act(async () => { await h.controller.load() }) + await act(async () => { + await h.mirror.load() + await h.controller.load() + }) expect(screen.queryByRole('dialog')).toBeNull() expect(h.complete).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts index e1fa7572c3..7eee8ae300 100644 --- a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' -import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import { + SettingsDescribeMirror, SettingsScopeController, +} from '@deepseek-ai/dsh-client-ui-settings/client' +import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, } from '../src/onboarding-copy.ts' @@ -10,31 +13,42 @@ function ok(value: T): RpcResponse { return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } } } -function namespace(version?: string) { +function namespace(value: unknown = {}, revision = 0) { return { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, schema: {}, - value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version }, - base: {}, - user: {}, + value, applies: 'live' as const, secrets: [], - revision: 0, + revision, } } -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - return { promise, resolve, reject } +function acknowledgedNamespace(version: string, revision = 1) { + return namespace({ [WELCOME_NOTICE_ACK_FIELD]: version }, revision) +} + +/** The welcome store over a real mirror-derived scope and a fake wire. */ +function buildWelcome( + api: { describe?: ReturnType; mutate?: ReturnType }, + persistence: 'host' | 'memory' = 'host', +) { + const wire = { settings: api } as never + const mirror = new SettingsDescribeMirror(wire, persistence) + const scope = new SettingsScopeController( + wire, + { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, + mirror, + persistence, + ) + return { mirror, controller: new WelcomeNoticeStore(scope) } } describe('WelcomeNoticeStore', () => { it('acknowledges in memory without calling loopback-only settings APIs', async () => { - const describe = vi.fn() + const describeCall = vi.fn() const mutate = vi.fn() - const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory') + const { controller } = buildWelcome({ describe: describeCall, mutate }, 'memory') await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null }) @@ -42,7 +56,7 @@ describe('WelcomeNoticeStore', () => { expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) - expect(describe).not.toHaveBeenCalled() + expect(describeCall).not.toHaveBeenCalled() expect(mutate).not.toHaveBeenCalled() }) @@ -52,148 +66,101 @@ describe('WelcomeNoticeStore', () => { ['older-copy', false], [WELCOME_NOTICE_VERSION, true], ] as const) { - const api = { - settings: { - describe: vi.fn(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace(version)], - }))), - }, - } - const controller = new WelcomeNoticeStore(api as never) + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, + hasDocument: false, + namespaces: [version === undefined ? namespace() : acknowledgedNamespace(version)], + }))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() await controller.load() expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged }) } }) - it('persists the owner version through one idempotent path mutation', async () => { - const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))) - const controller = new WelcomeNoticeStore({ settings: { mutate } } as never) + it('persists the owner version through one revision-fenced mutation', async () => { + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace({}, 3)], + }))) + const mutate = vi.fn(() => Promise.resolve(ok(acknowledgedNamespace(WELCOME_NOTICE_VERSION, 4)))) + const { mirror, controller } = buildWelcome({ describe: describeCall, mutate }) + await mirror.load() + await controller.load() await expect(controller.acknowledge()).resolves.toBe(true) expect(mutate).toHaveBeenCalledWith({ ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }], + expectedRevision: 3, }) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + // The write answer folded into the mirror; no re-read followed. + expect(describeCall).toHaveBeenCalledTimes(1) }) - it('keeps the notice pending when loading or persistence fails', async () => { - const load = new WelcomeNoticeStore({ - settings: { describe: () => Promise.reject(new Error('offline')) }, - } as never) - await load.load() - expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' }) - - const save = new WelcomeNoticeStore({ - settings: { mutate: () => Promise.reject(new Error('disk full')) }, - } as never) - await expect(save.acknowledge()).resolves.toBe(false) - expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' }) - - const nonError = new WelcomeNoticeStore({ - // Durable/wire failures are unknown; exercise containment of a non-Error rejection. - settings: { describe: () => Promise.reject(new Error('offline string')) }, - } as never) - await nonError.load() - expect(nonError.store.getSnapshot().error).toBe('offline string') + it('keeps the notice pending while the settings read has not answered', async () => { + const describeCall = vi.fn(() => Promise.reject(new Error('offline'))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() + await controller.load() + // No answer stands, so the step renders nothing and never acknowledges. + expect(controller.store.getSnapshot()).toEqual({ status: 'loading', acknowledged: false, error: null }) }) - it('reports business failures, missing namespaces, and malformed durable values', async () => { - for (const describe of [ - () => Promise.resolve({ - rpcId: 'failed' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } }, - }), - () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), - ]) { - const controller = new WelcomeNoticeStore({ settings: { describe } } as never) - await controller.load() - expect(controller.store.getSnapshot().status).toBe('error') - } + it('reports a failed or refused persistence attempt after its recovery read', async () => { + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace()], + }))) + const mutate = vi.fn(() => Promise.reject(new Error('disk full'))) + const { mirror, controller } = buildWelcome({ describe: describeCall, mutate }) + await mirror.load() + await controller.load() + await expect(controller.acknowledge()).resolves.toBe(false) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'error', + acknowledged: false, + error: 'the acknowledgement did not persist', + }) + // The failed latest write triggered one mirror recovery read. + expect(describeCall).toHaveBeenCalledTimes(2) + }) + it('reports a missing namespace as an error instead of a silent skip', async () => { + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [], + }))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'error', + error: 'welcome acknowledgement settings are unavailable', + }) + }) + + it('reads malformed durable values as unacknowledged', async () => { for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) { - const controller = new WelcomeNoticeStore({ - settings: { describe: () => Promise.resolve(ok({ - writable: true, - hasDocument: false, - namespaces: [{ ...namespace(), value }], - })) }, - } as never) + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace(value)], + }))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() await controller.load() expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) } - - const save = new WelcomeNoticeStore({ - settings: { mutate: () => Promise.resolve({ - rpcId: 'failed-save' as never, - result: { - ok: false, - error: { - code: 'settings-rejected', - message: 'denied', - details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE }, - }, - }, - }) }, - } as never) - await expect(save.acknowledge()).resolves.toBe(false) - expect(save.store.getSnapshot().error).toBe('denied') }) - it('lets the latest load win over stale success and failure', async () => { - const first = deferred>() - const describe = vi.fn() - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace()], - }))) - const controller = new WelcomeNoticeStore({ settings: { describe } } as never) - const stale = controller.load() + it('follows a later document change without an own read', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })) + .mockResolvedValueOnce(ok({ + writable: true, hasDocument: false, + namespaces: [acknowledgedNamespace(WELCOME_NOTICE_VERSION)], + })) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() await controller.load() - first.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)], - })) - await stale - expect(controller.store.getSnapshot().acknowledged).toBe(false) - - const failed = deferred>() - describe - .mockImplementationOnce(() => failed.promise) - .mockImplementationOnce(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)], - }))) - const staleFailure = controller.load() - await controller.load() - failed.reject('stale failure') - await staleFailure - expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null }) - }) - - it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => { - const write = deferred>() - const describe = vi.fn(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace()], - }))) - const controller = new WelcomeNoticeStore({ - settings: { mutate: () => write.promise, describe }, - } as never) - refreshWelcomeIfLoaded(controller) - expect(describe).not.toHaveBeenCalled() - const staleWrite = controller.acknowledge() - await controller.load() - write.resolve(ok(namespace(WELCOME_NOTICE_VERSION))) - await expect(staleWrite).resolves.toBe(true) - expect(controller.store.getSnapshot().acknowledged).toBe(false) - refreshWelcomeIfLoaded(controller) - await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) }) - - const failedWrite = deferred>() - const staleFailure = new WelcomeNoticeStore({ - settings: { mutate: () => failedWrite.promise, describe }, - } as never) - const pending = staleFailure.acknowledge() - await staleFailure.load() - failedWrite.reject('late failure') - await expect(pending).resolves.toBe(false) - expect(staleFailure.store.getSnapshot().status).toBe('ready') + expect(controller.store.getSnapshot()).toMatchObject({ acknowledged: false }) + await mirror.load() + expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) }) }) From 232e4beeaeef9ae4d4514f596e721ba358a8052e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:15:10 +0800 Subject: [PATCH 41/95] refactor(ui-settings-plugins): plugin tab derives served namespaces from the mirror --- apps/web/tests/startup-rpc-budget.e2e.ts | 8 +-- .../ui-settings-plugins/src/client/index.ts | 17 ++--- .../src/client/tab-store.ts | 63 +++++++------------ .../tests/stores.client.spec.ts | 60 ++++++------------ .../client/ui-settings/src/client/index.ts | 2 +- .../ui-settings/src/client/settings-mirror.ts | 30 ++++++++- .../ui-settings/src/client/settings-scope.ts | 13 +++- 7 files changed, 89 insertions(+), 104 deletions(-) diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index 5f8d7dd0e1..33938d6088 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -16,12 +16,10 @@ import { newEnglishPage } from './support.ts' * eagerly at bind time over HTTP, and once on the first-connection reset — * that second read closes the window where a document commit lands between * the eager read and the SSE subscription and its invalidation is lost. - * Beside it, the direct callers not yet migrated: welcome notice (1) + models - * onboarding (1) + plugin-directory tab at bind and at reset (2) + - * agent-preset settings row on reset (1). Batch 2 migrates those onto the - * mirror and tightens this to 2. + * Beside it, the direct callers not yet migrated: models onboarding (1) + + * agent-preset settings row on reset (1). Their migration tightens this to 2. */ -const DESCRIBE_BUDGET = 7 +const DESCRIBE_BUDGET = 4 let scaffold: WebScaffold let browser: Browser diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 82dea6d796..184511ead1 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -72,26 +72,17 @@ export function apply(ctx: ClientContext): void { 'ui-settings-plugins: credential invalidations', ) - // Which namespaces the Host serves is a registration fact the wire does not - // announce, so the directory re-reads on the two signals that can carry a - // changed composition: a settings document commit and a reconnect. + // Which namespaces the Host serves comes from the shared describe mirror, + // whose owning plugin already refreshes it on document commits and + // reconnects — the tab only derives. const configurable = new ConfigurablePluginsTabController( - api, () => ctx.slots.entries('settings.plugin.item')) + ctx.settingsScope.describe(), () => ctx.slots.entries('settings.plugin.item')) ctx.effect(() => () => { configurable.dispose() }, 'ui-settings-plugins: tab directory') - ctx.effect( - () => ctx.remote.$on('settings/document-updated', () => { void configurable.load() }), - 'ui-settings-plugins: served-namespace invalidations', - ) - ctx.effect( - () => ctx.on('connection/reset', () => { void configurable.load() }), - 'ui-settings-plugins: served-namespace reconnect', - ) // A card registered after the first read joins the list without a wire call. ctx.effect( () => ctx.slots.subscribe('settings.plugin.item', () => { configurable.refresh() }), 'ui-settings-plugins: card ledger', ) - void configurable.load() let tabsVersion = -1 let tabsRevision = -1 diff --git a/packages/client/ui-settings-plugins/src/client/tab-store.ts b/packages/client/ui-settings-plugins/src/client/tab-store.ts index a4ed4439f2..ff9d7b4b74 100644 --- a/packages/client/ui-settings-plugins/src/client/tab-store.ts +++ b/packages/client/ui-settings-plugins/src/client/tab-store.ts @@ -10,7 +10,7 @@ * trace and does not count toward the empty line. */ -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -42,47 +42,23 @@ export interface ConfigurablePluginsTabFace { } } -/** Reads the served namespaces and pairs them with the cards that claim them. */ +/** Derives the served namespaces from the shared describe mirror and pairs them with the cards that claim them. */ export class ConfigurablePluginsTabController { private readonly store = createSnapshotStore({ loaded: false, namespaces: [] }) - /** Last Host answer; kept so a slot mutation republishes without a wire read. */ - private served: readonly string[] = [] - private loaded = false - private generation = 0 private disposed = false + private readonly unsubscribe: () => void /** - * @param api - settings wire face. + * @param describeFace - the shared mirror's read-only face; its refreshes + * (document commits, reconnects) are what keep the served set current. * @param entries - reads the cards currently registered into the section's slot. */ constructor( - private readonly api: Pick, + private readonly describeFace: SettingsDescribeFace, private readonly entries: () => readonly StoredEntry[], - ) {} - - /** Opaque read of {@link disposed}: control flow cannot narrow it across awaits. */ - private isDisposed(): boolean { - return this.disposed - } - - /** - * Re-read the served namespaces from the Host and republish. - * @returns settlement after the read, or immediately once disposed. - */ - async load(): Promise { - if (this.isDisposed()) return - const generation = ++this.generation - let response: Awaited> - try { - response = await this.api.settings.describe({}) - } catch (_settingsReadFailure) { - // The tab keeps the namespaces it last knew; the next invalidation - // or reconnect reads again. - return - } - if (this.isDisposed() || generation !== this.generation || !response.result.ok) return - this.served = response.result.value.namespaces.map(view => view.ns) - this.loaded = true + ) { + this.unsubscribe = describeFace.subscribe(() => { this.publish() }) + void describeFace.ensure() this.publish() } @@ -92,10 +68,10 @@ export class ConfigurablePluginsTabController { this.publish() } - /** Stop publishing; an in-flight read settles without touching the store. */ + /** Stop publishing and stop following the mirror. */ dispose(): void { this.disposed = true - this.generation += 1 + this.unsubscribe() } /** @@ -107,17 +83,20 @@ export class ConfigurablePluginsTabController { } private publish(): void { - const served = new Set(this.served) + if (this.disposed) return + const mirrored = this.describeFace.getSnapshot() + const loaded = mirrored.view !== undefined + const served = new Set(mirrored.view?.namespaces.map(view => view.ns) ?? []) const namespaces = this.entries().flatMap(entry => entry.options.key !== undefined && served.has(entry.options.key) ? [entry.options.key] : []) const previous = this.store.getSnapshot() - // Every settings-document commit re-reads, and most of them change nothing - // this section shows. An observable source must keep its snapshot - // reference until the fact moves, or each unrelated save re-renders the - // whole card list (packages/client/AGENTS.md reactive rule 5). - if (previous.loaded === this.loaded + // Every settings-document commit refreshes the mirror, and most commits + // change nothing this section shows. An observable source must keep its + // snapshot reference until the fact moves, or each unrelated save + // re-renders the whole card list (packages/client/AGENTS.md reactive rule 5). + if (previous.loaded === loaded && previous.namespaces.length === namespaces.length && previous.namespaces.every((ns, index) => ns === namespaces[index])) return - this.store.set({ loaded: this.loaded, namespaces }) + this.store.set({ loaded, namespaces }) } } diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 9901bc5eb1..481c6e3679 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -8,6 +8,7 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' @@ -555,7 +556,7 @@ describe('ConfigurablePluginsTabController', () => { }, }, })) - return { api: { settings: { describe } } as never, describe } + return { mirror: new SettingsDescribeMirror({ settings: { describe } } as never), describe } } /** Slot ledger stand-in: one stored entry per registered card key. */ @@ -565,9 +566,9 @@ describe('ConfigurablePluginsTabController', () => { it('dispatches the served namespaces a card claims, in card registration order', async () => { const settings = settingsApi(['bash', 'ui-theme', 'agent-loop']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('agent-loop', 'bash')) + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('agent-loop', 'bash')) - await controller.load() + await settings.mirror.ensure() // ui-theme is served but claimed by no card here — another surface owns // it. The order is the cards', not the Host's: plugin activation can @@ -578,9 +579,9 @@ describe('ConfigurablePluginsTabController', () => { it('never dispatches a card whose namespace this deployment does not serve', async () => { const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'web-search-deepseek')) + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash', 'web-search-deepseek')) - await controller.load() + await settings.mirror.ensure() expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) }) @@ -588,8 +589,8 @@ describe('ConfigurablePluginsTabController', () => { it('takes a card registered after the read without asking the Host again', async () => { const settings = settingsApi(['bash']) let entries = ledger() - const controller = new ConfigurablePluginsTabController(settings.api, () => entries) - await controller.load() + const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries) + await settings.mirror.ensure() expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) entries = ledger('bash') @@ -599,34 +600,33 @@ describe('ConfigurablePluginsTabController', () => { expect(settings.describe).toHaveBeenCalledOnce() }) - it('keeps the namespaces it knew when a read fails', async () => { + it('keeps the namespaces it knew when a refresh fails', async () => { const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash')) - await controller.load() + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) + await settings.mirror.ensure() settings.describe.mockRejectedValueOnce(new Error('offline')) - await controller.load() + await settings.mirror.load() expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) }) - it('publishes nothing once disposed, and never claims it was answered', async () => { + it('stops following the mirror once disposed, and never claims it was answered', async () => { const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash')) + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) controller.dispose() - await controller.load() + await settings.mirror.load() expect(controller.inject().hooks.configurablePlugins.getSnapshot()) .toEqual({ loaded: false, namespaces: [] }) - expect(settings.describe).not.toHaveBeenCalled() }) it('ignores a slot-ledger change that arrives after disposal', async () => { const settings = settingsApi(['bash']) let entries = ledger() - const controller = new ConfigurablePluginsTabController(settings.api, () => entries) - await controller.load() + const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries) + await settings.mirror.ensure() controller.dispose() entries = ledger('bash') @@ -635,33 +635,11 @@ describe('ConfigurablePluginsTabController', () => { expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) }) - it('drops a read a newer one superseded', async () => { - // The section re-reads on every settings-document invalidation, so a slow - // first answer must not overwrite the newer one that already landed. - const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'agent-loop')) - const slow = Promise.withResolvers() - settings.describe.mockReturnValueOnce(slow.promise as never) - const stale = controller.load() - - await controller.load() - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) - slow.resolve({ - rpcId: 's-0', - result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [ - { ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 0 }, - ] } }, - }) - await stale - - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) - }) - it('reports the Host answered even when it serves nothing this tab shows', async () => { const settings = settingsApi(['ui-theme']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash')) + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) - await controller.load() + await settings.mirror.ensure() expect(controller.inject().hooks.configurablePlugins.getSnapshot()) .toEqual({ loaded: true, namespaces: [] }) diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index b3c149c938..f1e968174f 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -27,7 +27,7 @@ export type { } from './contract/slots.ts' export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts' export { SettingsDescribeMirror } from './settings-mirror.ts' -export type { SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' +export type { SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' /** * Required services: the wire handle for the mirror's reads and the forwarded diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index 398895c9cb..61dc21e287 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -38,12 +38,40 @@ export interface SettingsMirrorSnapshot { error: string | null } +/** + * The mirror as cross-namespace surfaces consume it: current answer, + * subscription, first-use read, and the write-answer fold. `load` stays off + * this face — invalidation refreshes belong to the mirror's owning plugin. + */ +export interface SettingsDescribeFace { + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsMirrorSnapshot + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void + /** + * Resolve once an answer is held (or the mirror is terminally unavailable), + * reading only from `idle`. + * @returns settlement of the current or newly started read, if any. + */ + ensure(): Promise + /** + * Fold one write answer's namespace view into the held view without a wire + * read. + * @param view - the namespace view a settings write answered with. + */ + acceptView(view: SettingsNamespaceView): void +} + /** * Serializes every Host `settings.describe` read behind one snapshot store. * Concurrent {@link load} calls fold into the in-flight read plus one rerun, * so an invalidation arriving mid-read is never lost and never duplicated. */ -export class SettingsDescribeMirror { +export class SettingsDescribeMirror implements SettingsDescribeFace { private readonly store: SnapshotStore private inFlight: Promise | undefined private rerun = false diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 47ebb3daa3..72a472a162 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -34,7 +34,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' -import { SettingsDescribeMirror } from './settings-mirror.ts' +import { SettingsDescribeMirror, type SettingsDescribeFace } from './settings-mirror.ts' type SettingsFace = Pick @@ -256,6 +256,17 @@ export class SettingsScopeBinder extends Service { * @param spec - domain-owned namespace contract. * @returns the bound scope consumed by the domain's services and rows. */ + /** + * The shared mirror's read-only face for cross-namespace surfaces (schema + * introspection, the served-namespace directory). Per-namespace consumers + * use {@link bind}; both derive from the same snapshot, so they can never + * disagree about the document. + * @returns the describe face over the shared mirror. + */ + describe(): SettingsDescribeFace { + return this.mirror + } + bind(spec: SettingsScopeSpec): SettingsScope { const ctx = this.ctx const connection = ctx.get('connection') as ConnectionHandle From e3f484f62ffa71259d7b4b368ee901b5cdb8ac9c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:19:10 +0800 Subject: [PATCH 42/95] refactor(ui-permission-presets): permission row derives from the describe mirror --- .../ui-permission-presets/src/client/index.ts | 26 +-- .../src/client/settings-store.ts | 134 +++++++------- .../tests/browser-plugin.client.spec.ts | 2 + .../permission-presets-row.client.spec.tsx | 17 +- .../tests/settings-store.client.spec.ts | 163 ++++++++---------- 5 files changed, 167 insertions(+), 175 deletions(-) diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index aec82bf9d9..ce6ecfc32a 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -33,9 +33,7 @@ import { import { displayPermissionPreset, FULL_ACCESS_PRESET, } from './presentation.ts' -import { - PERMISSION_SETTINGS_NS, PermissionPresetSettingsController, refreshPermissionIfLoaded, -} from './settings-store.ts' +import { PermissionPresetSettingsController } from './settings-store.ts' export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx' export type { @@ -43,7 +41,7 @@ export type { } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote'] +export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsScope'] const ACCESS_NS = 'permission.access' @@ -113,7 +111,10 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') const connection = ctx.get('connection') as ConnectionHandle - const controller = new PermissionPresetSettingsController(connection.api) + // The row follows the shared describe mirror, whose owning plugin already + // refreshes it on document commits and reconnects. + const controller = new PermissionPresetSettingsController( + ctx.settingsScope.describe(), connection.api) const load = (): Promise => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ @@ -122,20 +123,7 @@ export function apply(ctx: ClientContext): void { select, }) - ctx.effect(() => { - const refresh = (): void => { refreshPermissionIfLoaded(controller) } - const disposers = [ - ctx.remote.$on('settings/document-updated', (ns) => { - if (ns !== PERMISSION_SETTINGS_NS) return - refresh() - }), - ctx.on('connection/reset', () => { refresh() }), - ] - return () => { - controller.dispose() - for (const dispose of disposers) dispose() - } - }, 'ui-permission: settings invalidations') + ctx.effect(() => () => { controller.dispose() }, 'ui-permission: settings row directory') ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 6e7199f1be..5f61ed595a 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -1,7 +1,9 @@ /** - * Permission default-settings controller. The host descriptor supplies the - * current value and the dynamic preset enum; writes target only - * `defaultPreset` and carry the descriptor revision. + * Permission default-settings controller. The permission descriptor comes + * from the shared describe mirror (the dynamic preset enum lives in the + * namespace schema, which per-namespace scopes do not carry); writes target + * only `defaultPreset`, carry the descriptor revision, and fold their answer + * back into the mirror. */ import type { @@ -10,6 +12,7 @@ import type { import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import { nodeAtPath, rehydrateSchema, type SchemaNode, } from '@deepseek-ai/dsh-client-schema-form' @@ -75,7 +78,7 @@ export function permissionDefaultOf(view: SettingsNamespaceView): { return { currentValue: value, options } } -/** Controller joining Settings reads, writes, and pushed invalidations. */ +/** Controller deriving the row from the shared mirror and writing the default through it. */ export class PermissionPresetSettingsController { /** Row snapshot consumed through a bound selector hook. */ readonly store: SnapshotStore = createSnapshotStore({ @@ -87,42 +90,32 @@ export class PermissionPresetSettingsController { revision: 0, }) - private generation = 0 - private view: SettingsNamespaceView | undefined - - /** @param api - Settings wire face. */ - constructor(private readonly api: Pick) {} + private following: (() => void) | undefined + private saving = false + private disposed = false /** - * Refresh the permission descriptor. Latest request wins. - * @returns nothing; {@link store} carries success or failure. + * @param describeFace - the shared mirror's read-only face (descriptor and schema source). + * @param api - settings wire face for the `defaultPreset` write. + */ + constructor( + private readonly describeFace: SettingsDescribeFace, + private readonly api: Pick, + ) {} + + /** + * Begin following the mirror (idempotent) and reflect its current answer. + * @returns settlement once the snapshot reflects the mirror. */ async load(): Promise { - const generation = ++this.generation + if (this.disposed) return + this.following ??= this.describeFace.subscribe(() => { this.derive() }) this.store.update((state) => { state.status = 'loading' state.error = null }) - try { - const response = await this.api.settings.describe({}) - if (!response.result.ok) throw new Error(response.result.error.message) - if (generation !== this.generation) return - const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) - if (view === undefined) { - this.view = undefined - this.store.update((state) => { - state.status = 'unavailable' - state.writable = false - state.currentValue = '' - state.options = [] - }) - return - } - this.accept(view, response.result.value.writable) - } catch (error) { - if (generation !== this.generation) return - this.fail(error) - } + await this.describeFace.ensure() + this.derive() } /** @@ -131,10 +124,11 @@ export class PermissionPresetSettingsController { * @returns nothing; {@link store} carries success or failure. */ async select(preset: string): Promise { - const view = this.view const state = this.store.getSnapshot() - if (view === undefined || !state.writable) return - const generation = ++this.generation + const view = this.describeFace.getSnapshot().view?.namespaces + .find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined || !state.writable || this.saving) return + this.saving = true this.store.update((draft) => { draft.status = 'saving' draft.error = null @@ -145,32 +139,59 @@ export class PermissionPresetSettingsController { ops: [{ op: 'set', path: ['defaultPreset'], value: preset }], expectedRevision: view.revision, }) - if (generation !== this.generation) return if (!response.result.ok) throw new Error(response.result.error.message) - this.accept(response.result.value, true) + this.saving = false + if (this.disposed) return + // The mirror publish reaches this row's own subscription, so the fold + // is also what republishes the accepted value here. + this.describeFace.acceptView(response.result.value) } catch (error) { - if (generation !== this.generation) return + this.saving = false + if (this.disposed) return this.fail(error) } } - /** Stop in-flight responses from publishing after plugin disposal. */ + /** Stop following the mirror; later publishes leave the snapshot alone. */ dispose(): void { - this.generation += 1 - this.view = undefined + this.disposed = true + this.following?.() + this.following = undefined } - private accept(view: SettingsNamespaceView, writable: boolean): void { - const resolved = permissionDefaultOf(view) - this.view = view - this.store.update((state) => { - state.status = 'ready' - state.error = null - state.writable = writable - state.currentValue = resolved.currentValue - state.options = resolved.options - state.revision = view.revision - }) + private derive(): void { + if (this.disposed || this.saving) return + const mirrored = this.describeFace.getSnapshot() + if (mirrored.view === undefined) { + // A held failure with no answer is a failed row; without one the read + // is still in flight and the row keeps its loading state. + if (mirrored.error !== null) this.fail(new Error(mirrored.error)) + return + } + const view = mirrored.view.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined) { + this.store.update((state) => { + state.status = 'unavailable' + state.writable = false + state.currentValue = '' + state.options = [] + }) + return + } + try { + const resolved = permissionDefaultOf(view) + const { writable } = mirrored.view + this.store.update((state) => { + state.status = 'ready' + state.error = null + state.writable = writable + state.currentValue = resolved.currentValue + state.options = resolved.options + state.revision = view.revision + }) + } catch (error) { + this.fail(error) + } } private fail(error: unknown): void { @@ -180,12 +201,3 @@ export class PermissionPresetSettingsController { }) } } - -/** - * Refetch only after the row has opened once. - * @param controller - permission settings controller. - */ -export function refreshPermissionIfLoaded(controller: PermissionPresetSettingsController): void { - if (controller.store.getSnapshot().status === 'idle') return - void controller.load() -} diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index e3968cf002..19ae476a37 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest' import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client' import { @@ -58,6 +59,7 @@ async function bench() { }, }, } as never) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() let decoration: CommandDecoration | undefined ctx.provide('commandUi', { decorate(c: CommandDecoration) { diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 9df3920bd5..e4cbedbf6b 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -5,8 +5,15 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' import { en } from '../src/client/locales.ts' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { PermissionPresetSettingsController } from '../src/client/settings-store.ts' +/** Controller over a real mirror derived from the same fake wire. */ +function derivedController(api: { settings: object }) { + const wire = api as never + return new PermissionPresetSettingsController(new SettingsDescribeMirror(wire), wire) +} + afterEach(cleanup) const SCHEMA = { @@ -58,7 +65,7 @@ function mount(controller: PermissionPresetSettingsController) { describe('PermissionRow', () => { it('loads the descriptor, opens the menu, and selects a new default', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -85,7 +92,7 @@ describe('PermissionRow', () => { it('requires explicit acknowledgement before saving Full access', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -109,7 +116,7 @@ describe('PermissionRow', () => { }) it('hides an unavailable namespace and disables a read-only provider', async () => { - const absent = new PermissionPresetSettingsController({ + const absent = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), mutate: vi.fn(), @@ -119,7 +126,7 @@ describe('PermissionRow', () => { await waitFor(() => { expect(rendered.container.textContent).toBe('') }) rendered.unmount() - const readonly = new PermissionPresetSettingsController({ + const readonly = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })), mutate: vi.fn(), @@ -134,7 +141,7 @@ describe('PermissionRow', () => { writable: boolean namespaces: SettingsNamespaceView[] }>>>() - const controller = new PermissionPresetSettingsController({ + const controller = derivedController({ settings: { describe: () => describe.promise, mutate: () => Promise.resolve({ diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index e4e218fe86..b8a4725a5c 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { - PermissionPresetSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, + PermissionPresetSettingsController, permissionDefaultOf, } from '../src/client/settings-store.ts' const SCHEMA = { @@ -30,6 +31,13 @@ function ok(value: T) { return { rpcId: 'test', result: { ok: true as const, value } } } +/** The permission controller over a real mirror and one fake wire. */ +function permissionController(api: object) { + const wire = { settings: api } as never + const mirror = new SettingsDescribeMirror(wire) + return { mirror, controller: new PermissionPresetSettingsController(mirror, wire) } +} + describe('permission settings store', () => { it('derives dynamic options and host labels from the descriptor schema', () => { expect(permissionDefaultOf(view('read-only'))).toEqual({ @@ -92,9 +100,7 @@ describe('permission settings store', () => { namespaces: [view('read-only', 4)], }))) const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) - const controller = new PermissionPresetSettingsController({ - settings: { describe, mutate } as never, - }) + const { controller } = permissionController({ describe, mutate }) await controller.load() expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', @@ -113,126 +119,105 @@ describe('permission settings store', () => { currentValue: 'workspace-write', revision: 5, }) + // The write answer folded into the mirror; no re-read followed. + expect(describe).toHaveBeenCalledTimes(1) }) it('hides the row when the namespace is absent and contains write failures', async () => { const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))) - const controller = new PermissionPresetSettingsController({ - settings: { describe, mutate: vi.fn() } as never, - }) + const { controller } = permissionController({ describe, mutate: vi.fn() }) await controller.load() expect(controller.store.getSnapshot().status).toBe('unavailable') - const failing = new PermissionPresetSettingsController({ - settings: { - describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), - mutate: () => Promise.resolve({ - rpcId: 'test', - result: { - ok: false as const, - error: { code: 'settings-conflict', message: 'stale', details: {} }, - }, - }), - } as never, - }) + const failing = permissionController({ + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'stale', details: {} }, + }, + }), + }).controller await failing.load() await failing.select('workspace-write') expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' }) }) - it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => { - const first = Promise.withResolvers>>() - const describe = vi.fn() - .mockImplementationOnce(() => first.promise) - .mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] })) + it('contains read failures and no-ops without a writable view', async () => { const mutate = vi.fn() - const controller = new PermissionPresetSettingsController({ - settings: { describe, mutate } as never, - }) - const stale = controller.load() - await controller.load() - first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] })) - await stale - expect(controller.store.getSnapshot()).toMatchObject({ + const readOnly = permissionController({ + describe: () => Promise.resolve(ok({ + writable: false, hasDocument: false, namespaces: [view('read-only', 2)], + })), + mutate, + }).controller + await readOnly.load() + expect(readOnly.store.getSnapshot()).toMatchObject({ currentValue: 'read-only', writable: false, revision: 2, }) - await controller.select('workspace-write') + await readOnly.select('workspace-write') expect(mutate).not.toHaveBeenCalled() - const rejected = new PermissionPresetSettingsController({ - settings: { - describe: () => Promise.resolve({ - rpcId: 'test', - result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, - }), - mutate, - } as never, - }) + const rejected = permissionController({ + describe: () => Promise.resolve({ + rpcId: 'test', + result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, + }), + mutate, + }).controller await rejected.select('workspace-write') await rejected.load() expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' }) + expect(mutate).not.toHaveBeenCalled() - const thrown = new PermissionPresetSettingsController({ - settings: { - // Promise consumers must contain unknown rejection values from a - // transport implementation, including non-Error legacy clients. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors - describe: () => Promise.reject('disconnected'), - mutate, - } as never, - }) + const thrown = permissionController({ + // Promise consumers must contain unknown rejection values from a + // transport implementation, including non-Error legacy clients. + describe: () => Promise.reject('disconnected' as never), + mutate, + }).controller await thrown.load() expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) }) - it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => { + it('follows a mirror refresh without an own read once loaded', async () => { + const describe = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('read-only', 1)] })) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 2)] })) + const { mirror, controller } = permissionController({ describe, mutate: vi.fn() }) + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'read-only' }) + + await mirror.load() + + expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'workspace-write', revision: 2 }) + }) + + it('disposal stops deriving and suppresses in-flight writes', async () => { const read = Promise.withResolvers>>() - const describe = vi.fn(() => read.promise) - const idle = new PermissionPresetSettingsController({ settings: { describe, mutate: vi.fn() } as never }) - refreshPermissionIfLoaded(idle) - expect(describe).not.toHaveBeenCalled() + const { mirror, controller: idle } = permissionController({ describe: () => read.promise, mutate: vi.fn() }) const loading = idle.load() idle.dispose() read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })) - await loading + await Promise.all([loading, mirror.load()]) expect(idle.store.getSnapshot().status).toBe('loading') - const rejectedRead = Promise.withResolvers>>() - const disposedRead = new PermissionPresetSettingsController({ - settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never, - }) - const reading = disposedRead.load() - disposedRead.dispose() - rejectedRead.reject(new Error('late read')) - await reading - expect(disposedRead.store.getSnapshot().status).toBe('loading') - const mutation = Promise.withResolvers>>() - const activeDescribe = vi.fn(() => Promise.resolve(ok({ - writable: true, - hasDocument: false, - namespaces: [view('read-only')], - }))) - const active = new PermissionPresetSettingsController({ - settings: { - describe: activeDescribe, - mutate: () => mutation.promise, - } as never, + const { controller: active } = permissionController({ + describe: () => Promise.resolve(ok({ + writable: true, + hasDocument: false, + namespaces: [view('read-only')], + })), + mutate: () => mutation.promise, }) await active.load() - refreshPermissionIfLoaded(active) - await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) }) const saving = active.select('workspace-write') active.dispose() mutation.resolve(ok(view('workspace-write', 1))) @@ -240,11 +225,9 @@ describe('permission settings store', () => { expect(active.store.getSnapshot().status).toBe('saving') const rejectedMutation = Promise.withResolvers>>() - const disposedWrite = new PermissionPresetSettingsController({ - settings: { - describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), - mutate: () => rejectedMutation.promise, - } as never, + const { controller: disposedWrite } = permissionController({ + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), + mutate: () => rejectedMutation.promise, }) await disposedWrite.load() const writing = disposedWrite.select('workspace-write') From 380030c48f564c3e49611e7e70c4407a27efdcf6 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 17 Aug 2026 17:31:00 +0800 Subject: [PATCH 43/95] fix(workflow): preserve disclosure intent across completion --- .../workflow-run/ui-live.expected.md | 17 --- apps/web/tests/workflow-run.e2e.ts | 2 +- .../src/client/WorkflowRunPanel.tsx | 134 +++++++++--------- .../tests/workflow-run.client.spec.tsx | 35 ++++- 4 files changed, 104 insertions(+), 84 deletions(-) diff --git a/apps/web/tests/snapshots/workflow-run/ui-live.expected.md b/apps/web/tests/snapshots/workflow-run/ui-live.expected.md index 9e7f7b0ddc..52ac5c75fc 100644 --- a/apps/web/tests/snapshots/workflow-run/ui-live.expected.md +++ b/apps/web/tests/snapshots/workflow-run/ui-live.expected.md @@ -1,23 +1,6 @@ -- 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): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" -- button "Copy": - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:": - - img - - img - - text: "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:" -- text: Running -- button "Tool call workflow ·": - - img - - img - - text: Tool call workflow · - button "snapshot-flow 1 member Running" [expanded]: - img - text: snapshot-flow 1 member Running - button "Run 1 member Running 1": - img - text: Run 1 member Running 1 -- status: Deep diving... diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index 04f79cccd4..ef787a60da 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -93,7 +93,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await phaseDisclosure.click() expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('false') expect(await member.count()).toBe(0) - const liveSnapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) + const liveSnapshot = await captureStableAria(page, '[data-workflow-run]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_LIVE_EXPECTED, liveSnapshot, MODE) await phaseDisclosure.press('Enter') await member.waitFor() diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index a839403266..9df3452775 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -147,6 +147,16 @@ function collapsePending(state: DisclosureState): DisclosureState { return { ...state, open: false, pendingCleanCollapse: false } } +function existingPhaseState( + phases: ReadonlyMap, + key: string, +): DisclosureState { + const phase = phases.get(key) + /* v8 ignore next -- mounted phase callbacks are created from this owner map. */ + if (phase === undefined) throw new Error(`Missing disclosure state for phase ${key}`) + return phase +} + function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { const counts = new Map() for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1) @@ -226,20 +236,8 @@ function MemberRow({ member, navigable, openSession, t }: { readonly t: WorkflowRunPanelProps['t'] }) { const name = readableMember(member.label, t) - const buttonRef = useRef(null) - const [keepFocusedButton, setKeepFocusedButton] = useState(navigable) - const renderButton = navigable || keepFocusedButton - - useLayoutEffect(() => { - if (navigable) { - if (!keepFocusedButton) setKeepFocusedButton(true) - return - } - const button = buttonRef.current - if (button === null || button.ownerDocument.activeElement !== button) { - if (keepFocusedButton) setKeepFocusedButton(false) - } - }, [keepFocusedButton, navigable]) + const [focused, setFocused] = useState(false) + const renderButton = navigable || focused const content = ( <> @@ -253,14 +251,14 @@ function MemberRow({ member, navigable, openSession, t }: { } return ( +
    {(candidates ?? []).map(candidate => (
  • diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 5203524517..6b8c2047a4 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -644,6 +644,12 @@ select.input { --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +.candidateActions { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} + .candidateList { display: flex; flex-direction: column; diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index a49a4f0ef2..856ef64c7b 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -72,6 +72,8 @@ export const en = { fetchEmpty: 'The provider listed no models. Add them by hand.', fetchTitle: 'Choose models to add', fetchDescription: 'These are the models this provider has available. Choose the ones to add.', + fetchSelectAll: 'Select all', + fetchDeselectAll: 'Deselect all', fetchAdopt: 'Add selected', customAdd: 'Add a custom provider', customTitle: 'Custom provider', @@ -172,6 +174,8 @@ export const zh: { [Key in keyof typeof en]: string } = { fetchEmpty: '该提供方没有列出任何模型,请手动添加。', fetchTitle: '选择要添加的模型', fetchDescription: '以下是模型提供方的可用模型,勾选要添加的模型。', + fetchSelectAll: '全选', + fetchDeselectAll: '取消全选', fetchAdopt: '添加所选', customAdd: '添加自定义提供方', customTitle: '自定义提供方', diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index e5e32498d1..4bc30f558a 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -605,6 +605,27 @@ describe('endpoint interrogation', () => { // A disclosed output cap rides along with the candidate that has one. expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b', maxTokens: 2048 }]) }) + + it('selects and clears every discovered candidate in one action', async () => { + const discover = vi.fn(() => Promise.resolve(ok({ + models: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }))) + await mountSection({ discover }) + openEditor('openai') + + fireEvent.click(screen.getByText(en.fetchModels)) + const dialog = await screen.findByRole('dialog') + const boxes = [...dialog.querySelectorAll('input[type="checkbox"]')] + expect(boxes.map(box => box.checked)).toEqual([true, true, true]) + + fireEvent.click(within_(dialog, en.fetchDeselectAll)) + expect(boxes.map(box => box.checked)).toEqual([false, false, false]) + expect(within_(dialog, en.fetchSelectAll)).toBeTruthy() + + fireEvent.click(within_(dialog, en.fetchSelectAll)) + expect(boxes.map(box => box.checked)).toEqual([true, true, true]) + expect(within_(dialog, en.fetchDeselectAll)).toBeTruthy() + }) }) describe('provider rows', () => { From 8e2785d9eb4f8ca3181a781fbdfb25437b23cf0f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 13:28:18 +0800 Subject: [PATCH 72/95] fix(locale): cover direct register() dictionaries and assert assembled The parity gate recognized only a `[['zh',{...}],['en',{...}]]` array, so the two separate ctx.locale.register(NS, 'zh'|'en', {...}) calls in ui-permission-presets were unchecked: deleting a key from one side left the gate green. Pair those calls by their namespace argument. Widen the pre-filter to admit zhSettings/accessZh spellings, which a bare \b(zh|en)\b misses and would have skipped before parsing. Assert document.documentElement.lang in the assembled app. The served markup already ships lang="en", so the fr-FR scenario passes whether or not the sync runs; the zh scenario is the discriminating half and now asserts zh-CN before the switch and en after it. Drop the dead vi.unstubAllGlobals() from the document-language spec, which manages navigator with defineProperty and never calls vi.stubGlobal. --- apps/web/tests/settings-chrome.e2e.ts | 11 ++++++ .../tests/document-language.client.spec.ts | 3 +- scripts/locale-dictionary-parity.spec.ts | 38 +++++++++++++++---- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 216dae4dbb..61f5af88c5 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -400,6 +400,11 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const zhDialog = page.getByRole('dialog', { name: '设置' }) await zhDialog.waitFor({ timeout: 10_000 }) + // The document language follows the active locale in the assembled app, not + // only on a directly-mounted plugin. This is a zh browser, so the served + // markup's `en` must already have been replaced — asserting it here (rather + // than only in an English scenario) is what makes the check discriminating. + expect(await page.evaluate(() => document.documentElement.lang)).toBe('zh-CN') // The Language selector pill shows the active locale's own name. const selector = zhDialog.getByRole('button', { name: '中文' }) expect(await selector.getAttribute('aria-haspopup')).toBe('menu') @@ -410,6 +415,8 @@ describe('web e2e: settings modal and General preferences', () => { // the rest of the app's copy is intentionally out of this row's scope.) const enDialog = page.getByRole('dialog', { name: 'Settings' }) await enDialog.waitFor({ timeout: 10_000 }) + // ...and the attribute follows that switch, in the assembled app. + await expect.poll(() => page.evaluate(() => document.documentElement.lang), { timeout: 5_000 }).toBe('en') expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() @@ -498,6 +505,10 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + // The markup already ships `en`, so this alone cannot prove the sync ran + // — the zh scenario above is the discriminating half. Asserted here too + // so a future change that resolves en but writes the wrong tag is caught. + expect(await frPage.evaluate(() => document.documentElement.lang)).toBe('en') // Golden of the English fallback dialog — the visible output this change // produces. The zh golden above covers the detected-locale surface, so // the pair pins both directions of the resolution. diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index b10a3e8e69..dc375ec4ce 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -61,7 +61,8 @@ describe('document language', () => { }) afterEach(() => { - vi.unstubAllGlobals() + // navigator properties are installed with defineProperty above, so they + // are removed the same way; nothing here goes through vi.stubGlobal. const own = navigator as unknown as Record delete own.languages delete own.language diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index 5284801ae4..b240c564ae 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -93,9 +93,12 @@ interface Dictionary { */ function dictionariesIn(file: string): Dictionary[] { const text = readFileSync(file, 'utf8') - // Cheap pre-filter: parsing every package source is wasteful, and a file - // with no locale token cannot declare a dictionary under any shape below. - if (!/\b(zh|en)\b/.test(text)) return [] + // Cheap pre-filter: parsing every package source is wasteful. The pattern + // must admit every shape `localeOf` accepts, or a file would be skipped + // before parsing — the silent narrowing this gate exists to prevent. A bare + // `\b(zh|en)\b` misses `zhSettings`/`accessZh`, because `\b` does not hold + // between `h` and an uppercase letter. + if (!/\b(zh|en)\b|\b(zh|en)[A-Z]|(Zh|En)\b/.test(text)) return [] const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true) const found: Dictionary[] = [] const rel = relative(file) @@ -112,10 +115,29 @@ function dictionariesIn(file: string): Dictionary[] { } } - // Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a - // registration loop in the plugin body. Both halves key off the enclosing - // array's line so they pair with each other and not across sites. + // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair + // handed to a registration loop keys off the enclosing array; separate + // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the + // namespace argument, so the two calls pair with each other. const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression + const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined + if (name === 'register' && node.arguments.length >= 3) { + const [ns, tag, dict] = node.arguments + const literal = unwrap(dict) + if ( + ns !== undefined && tag !== undefined && ts.isStringLiteral(tag) + && (tag.text === 'zh' || tag.text === 'en') + && literal !== undefined && ts.isObjectLiteralExpression(literal) + ) { + // The namespace expression's source text identifies the pair, so the + // zh and en calls for one namespace meet and calls for different + // namespaces stay apart. + found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(literal) }) + } + } + } if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 for (const element of node.elements) { @@ -167,7 +189,9 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin for (const locale of ['zh', 'en'] as const) { const other = locale === 'zh' ? 'Zh' : 'En' if (name === locale) return { locale, pair: '' } - if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) } + // Synthetic names for inline shapes carry their own pair key after the + // first ':' (the enclosing array's line, or the namespace expression). + if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) } if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { return { locale, pair: name.slice(2) } } From 5849c57c0cbbe3e45569bc32f3cc4340b473b8e4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 18 Aug 2026 14:00:00 +0800 Subject: [PATCH 73/95] fix(images): align provider-safe payload defaults --- .../2026-08-18-request-image-payload-bound.i18n.yaml | 4 ++-- .../bug-fix/2026-08-18-request-image-payload-bound.md | 2 +- .../2026-08-18-request-image-payload-bound.zh.md | 2 +- ...modal-image-input-and-durable-attachments.i18n.yaml | 4 ++-- ...b-multimodal-image-input-and-durable-attachments.md | 2 +- ...ultimodal-image-input-and-durable-attachments.zh.md | 2 +- ...-12-web-image-intake-and-limits-alignment.i18n.yaml | 4 ++-- ...2026-08-12-web-image-intake-and-limits-alignment.md | 2 +- ...6-08-12-web-image-intake-and-limits-alignment.zh.md | 2 +- packages/attachment/attachment-local/src/index.ts | 2 +- .../attachment/attachment-local/tests/index.spec.ts | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/config.ts | 10 +++++----- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 1 + 16 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml index cd601d6c9f..f797c08086 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.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-18-request-image-payload-bound.md -2026-08-18-request-image-payload-bound.md: caeb584b230740d0daff0603cec8b1638a9066f2 -2026-08-18-request-image-payload-bound.zh.md: 0c4de2a63055fcf743b71b4143bf3315deeb970b +2026-08-18-request-image-payload-bound.md: df10df39d18c7da4660b566e8f0b6a5a60ff8dc1 +2026-08-18-request-image-payload-bound.zh.md: 070f2d194f1459f3f2728fdf9d5d2db2c3a24385 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md index caeb584b23..df10df39d1 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md @@ -10,7 +10,7 @@ Every image in session history is base64-inlined into every model request by the ## Decision -The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 24MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). The default stays under the smallest documented provider request-body cap (30MiB) with headroom for text and JSON structure; deployments behind stricter gateways lower the value per route. +The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB request-image default therefore retains four such images and reserves the rest of a 32MiB request for system prompts, history, tools, and JSON. Deployments behind stricter gateways lower the value per route. ## Offload is conversion, not history diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md index 0c4de2a630..070f2d194f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md @@ -10,7 +10,7 @@ pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型 ## Decision -pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 24MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。默认值低于已知最小的提供方请求体上限(30MiB)并为文本与 JSON 结构留出余量;网关更严格的部署按路由调低。 +pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 请求图片默认上限因此可保留四张这样的图片,并在 32MiB 请求内为系统提示词、历史、工具与 JSON 保留其余容量。网关更严格的部署按路由调低该值。 ## offload 是转换而非历史 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 8695a95bec..842f2c9cd3 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 8c07b8b786aeeb87a4c2db7c0e6e49928b0ddf2c -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5d2d2963d6bee729362852701ea58f191f9721b2 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a12380992c3205d6bb3f5701f093b3335fae2f9 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a8e94154e435a5359e7928d3b11eba7d1aee92f7 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 8c07b8b786..6a12380992 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -140,7 +140,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, 40 million intrinsic pixels per image, and 2000 pixels on either side. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 3.5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, 40 million intrinsic pixels per image, and 2000 pixels on either side. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, excess per-side dimensions, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 5d2d2963d6..a8e94154e4 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -140,7 +140,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数、每张图片 4,000 万个固有像素,以及任一边 2,000 像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 3.5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数、每张图片 4,000 万个固有像素,以及任一边 2,000 像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、超出单边尺寸限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml index 1ea48bbef6..01144af297 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.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-12-web-image-intake-and-limits-alignment.md -2026-08-12-web-image-intake-and-limits-alignment.md: 2f8b99bb4850d9875dcba0a03ae8ad9f340d1506 -2026-08-12-web-image-intake-and-limits-alignment.zh.md: 62d5ebd54275ae8de0e0b9ba701ba34042dfcde7 +2026-08-12-web-image-intake-and-limits-alignment.md: 00cf7ea99d63e848c4b5839da1d97d94c9fb8464 +2026-08-12-web-image-intake-and-limits-alignment.zh.md: d88ba25a3f3e3be4a3655080a45123ada6cdebcd diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md index 2f8b99bb48..00cf7ea99d 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md @@ -16,7 +16,7 @@ The second alignment step for issue #2248, after the [attachment display note](2 **History thumbnails (DeepSeek Chat rules).** A message's lone image renders at 240px on its long edge with the displayed ratio clamped to [0.25, 4], cropped by `cover` with the anchor at the top of very tall images and the left of very wide ones, never upscaled; several images render as fixed 64px square tiles in one wrapping row (10px gap, user messages right-aligned). Consecutive assistant `image` blocks merge into one gallery so they tile instead of each opening a one-image row. -**Limits aligned and projected.** Defaults are 20 images / 5 MiB per image / 100 MiB aggregate (`attachment-local`), with the HTTP carrier cap raised to one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` (http-bridge, previously two independent 32 MiB literals) to satisfy the load-time capacity assertion (aggregate × 4/3 + headroom ≈ 134.3 MiB). Consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), and a vision-model image costs roughly 1300–4800 tokens, so 50 images can fill a 200k context in one message. The 5 MiB per-image default admits images across Anthropic routes that impose either a 5 MiB or 10 MiB maximum; deployments using only routes with the larger limit can override it. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would need a single JSON string past V8's ~512 MiB string ceiling. The limits reach clients as the `imageLimits` session projection — a constant-per-boot unit (`apply` returns the same state reference, so baselines alone carry it and no change frames exist) registered by **apiproxy**, not the attachment Service Definition: `dsh-llm` depends on `dsh-attachment` (`ImageBlock` → `ImageAttachmentRef`), so the seam package referencing `dsh-session-projection` (whose graph reaches `dsh-llm` through `dsh-session`) closes a project-reference cycle, and the per-message count/aggregate rules the value describes are the proxy's own admission checks anyway. The `SessionProjectionMap` merge rides the proxy's sessions wire-contract file, which every client program already includes through the carrier's type re-exports. +**Limits aligned and projected.** Defaults are 20 images / 3.5 MiB per image / 100 MiB aggregate (`attachment-local`), with the HTTP carrier cap raised to one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` (http-bridge, previously two independent 32 MiB literals) to satisfy the load-time capacity assertion (aggregate × 4/3 + headroom ≈ 134.3 MiB). Consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), and a vision-model image costs roughly 1300–4800 tokens, so 50 images can fill a 200k context in one message. Including base64 padding, a 3.5 MiB encoded file occupies at most 4.67 MiB and leaves 0.33 MiB below a 5 MiB route check. Deployments using only routes with larger limits can override it. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would need a single JSON string past V8's ~512 MiB string ceiling. The limits reach clients as the `imageLimits` session projection — a constant-per-boot unit (`apply` returns the same state reference, so baselines alone carry it and no change frames exist) registered by **apiproxy**, not the attachment Service Definition: `dsh-llm` depends on `dsh-attachment` (`ImageBlock` → `ImageAttachmentRef`), so the seam package referencing `dsh-session-projection` (whose graph reaches `dsh-llm` through `dsh-session`) closes a project-reference cycle, and the per-message count/aggregate rules the value describes are the proxy's own admission checks anyway. The `SessionProjectionMap` merge rides the proxy's sessions wire-contract file, which every client program already includes through the carrier's type re-exports. **Intake pre-check and error copy.** Both intake gestures converge on one `intakeImages` wrapper in InputBar that checks count, per-image bytes, and aggregate bytes against the projection before `addImages`: a violating batch is refused whole (DeepSeek Chat semantics) with an immediate banner naming the limit — no submit-time rollback theater. The host checks stay as the backstop for callers that bypass the composer. Banner copy follows one principle the user set: reasons a user can act on (model without vision, count, size, resolution, format — now a positive list of supported formats instead of echoing the rejected MIME type) get product sentences naming the way out; reasons they cannot act on (corrupt base64, lost references, read failures) fold into one send-failed sentence that keeps the reason code, because the product currently faces developers and a reportable code beats a dead end. Non-attachment error codes keep the raw message + code presentation. diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md index 62d5ebd542..d88ba25a3f 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md @@ -16,7 +16,7 @@ issue #2248 的第二步对齐,接在[附件展示 note](2026-08-11-web-attach **历史缩略图(DeepSeek Chat 规则)。** 一条消息仅有的一张图长边 240px、展示比例钳制在 [0.25, 4],`cover` 裁切,特别高的图锚定顶部、特别宽的锚定左侧,从不放大;多张图渲染为固定 64px 方块,单个可换行的横排(10px 间距,用户消息右对齐)。assistant 连续的 `image` 块合并进同一个画廊,平铺而不是各占一行。 -**上限对齐并投影。** 默认值为每条消息 20 张、单图 5 MiB、总量 100 MiB(`attachment-local`),HTTP 载体上限提为唯一共享的 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`(http-bridge,原先是两个独立的 32 MiB 字面量),以满足加载时的容量断言(总量 × 4/3 加余量 ≈ 134.3 MiB)。消费级产品集中在 10 到 20 个附件(ChatGPT 10、Gemini 10、Claude 20;DeepSeek Chat 的 50 是例外),且视觉模型一张图约 1300 到 4800 token,因此 50 张图可在一条消息中填满 200k 上下文。默认单图上限采用 5 MiB,可适用于分别采用 5 MiB 或 10 MiB 上限的 Anthropic 路由;仅使用较大上限路由的部署可以覆盖该值。512 MiB 总量无法通过当前传输,因为 base64 进 JSON 需要一个超过 V8 约 512 MiB 字符串上限的单个 JSON 字符串。限额以 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元(`apply` 返回同一状态引用,因此只靠基线携带、不存在变更帧),由 **apiproxy** 而非 attachment Service Definition 注册:`dsh-llm` 依赖 `dsh-attachment`(`ImageBlock` → `ImageAttachmentRef`),seam 包引用 `dsh-session-projection`(其图谱经 `dsh-session` 到达 `dsh-llm`)会闭合 project-reference 环,而该值描述的每消息数量与总量规则本来就是 proxy 自己的准入检查。`SessionProjectionMap` 合并放在 proxy 的 sessions 协议文件里,每个客户端程序都经载体的类型再导出包含它。 +**上限对齐并投影。** 默认值为每条消息 20 张、单图 3.5 MiB、总量 100 MiB(`attachment-local`),HTTP 载体上限提为唯一共享的 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`(http-bridge,原先是两个独立的 32 MiB 字面量),以满足加载时的容量断言(总量 × 4/3 加余量 ≈ 134.3 MiB)。消费级产品集中在 10 到 20 个附件(ChatGPT 10、Gemini 10、Claude 20;DeepSeek Chat 的 50 是例外),且视觉模型一张图约 1300 到 4800 token,因此 50 张图可在一条消息中填满 200k 上下文。3.5 MiB 编码文件包括 base64 填充在内最多占 4.67 MiB,在 5 MiB 路由检查下保留 0.33 MiB 余量。仅使用较大上限路由的部署可以覆盖该值。512 MiB 总量无法通过当前传输,因为 base64 进 JSON 需要一个超过 V8 约 512 MiB 字符串上限的单个 JSON 字符串。限额以 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元(`apply` 返回同一状态引用,因此只靠基线携带、不存在变更帧),由 **apiproxy** 而非 attachment Service Definition 注册:`dsh-llm` 依赖 `dsh-attachment`(`ImageBlock` → `ImageAttachmentRef`),seam 包引用 `dsh-session-projection`(其图谱经 `dsh-session` 到达 `dsh-llm`)会闭合 project-reference 环,而该值描述的每消息数量与总量规则本来就是 proxy 自己的准入检查。`SessionProjectionMap` 合并放在 proxy 的 sessions 协议文件里,每个客户端程序都经载体的类型再导出包含它。 **加入预检与错误文案。** 两种加入手势汇合到 InputBar 的一个 `intakeImages` 包装:在 `addImages` 之前按投影检查数量、单图字节与总字节,违规的一批整体拒收(DeepSeek Chat 语义)并立刻弹出点名上限的横幅——不再有提交时的回滚戏码。宿主检查保留,兜底绕过 composer 的调用方。横幅文案遵循用户定下的一条原则:用户能解决的原因(模型不支持视觉、数量、大小、分辨率、格式——格式改为正面列出支持列表而不是回显被拒的 MIME 类型)用点明出路的产品句子;用户无法解决的原因(base64 损坏、引用丢失、读取失败)折叠为一条保留原因码的发送失败句子,因为产品当前面向开发者,可上报的码好过死胡同。非附件错误码保留原文加错误码的展示。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 344830daee..a4047da1f1 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -11,7 +11,7 @@ import { readImageFile, saveImageFile, validateImageFile } from './store.ts' export { readImageFile, saveImageFile, validateImageFile } from './store.ts' /** Default maximum encoded bytes for one image. */ -export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024 +export const DEFAULT_MAX_IMAGE_BYTES = 3.5 * 1024 * 1024 /** Default maximum images in one prompt. */ export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20 /** Default maximum aggregate image bytes in one prompt. */ diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 859008c7d7..92bbe3c0aa 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -15,7 +15,7 @@ import LocalAttachmentStore, { describe('local attachment service', () => { it('resolves every omitted admission limit explicitly', () => { const service = new LocalAttachmentStore(new Context(), {}) - expect(DEFAULT_MAX_IMAGE_BYTES).toBe(5 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGE_BYTES).toBe(3.5 * 1024 * 1024) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 384b66871e..d4ac70ff20 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: aa37ef7f06f54f18dedcdd732eb1eba60da0a960 -README.zh.md: 53b90f9314ee0b12a01584eaf73ca868c1b2153d +README.md: 5dbcb905451f72a700dd09b4052dcb2f88e858c9 +README.zh.md: 217244c7b4d7ecd5aa88427990feb58c96e5acaf diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index aa37ef7f06..5dbcb90545 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -113,7 +113,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. `maxRequestImageBytes` bounds one request's base64-encoded image payload (default 24MiB, a positive integer): every image in history is re-encoded into every request, so when the accumulated payload exceeds the bound, the oldest images are replaced by a fixed text placeholder until the request fits, keeping an image-heavy session serviceable instead of permanently rejected by a gateway request-size cap. Deployments behind stricter gateways lower it per route. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. `maxRequestImageBytes` bounds one request's base64-encoded image payload (default 20MiB, a positive integer): every image in history is re-encoded into every request, so when the accumulated payload exceeds the bound, the oldest images are replaced by a fixed text placeholder until the request fits, keeping an image-heavy session serviceable instead of permanently rejected by a gateway request-size cap. The default leaves capacity for system prompts, history, tools, and JSON; deployments behind stricter gateways lower it per route. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 53b90f9314..217244c7b4 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -114,7 +114,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。`maxRequestImageBytes` 约束单个请求的 base64 编码图片载荷(默认 24MiB,正整数):历史中的每张图片都会重新编码进每个请求,累积载荷超过上限时,从最老的图片开始替换为固定文本占位,直到请求装得下,使图片较多的会话保持可用,而不是被网关请求体上限永久拒绝。网关更严格的部署按路由调低该值。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。`maxRequestImageBytes` 约束单个请求的 base64 编码图片载荷(默认 20MiB,正整数):历史中的每张图片都会重新编码进每个请求,累积载荷超过上限时,从最老的图片开始替换为固定文本占位,直到请求装得下,使图片较多的会话保持可用,而不是被网关请求体上限永久拒绝。默认值为系统提示词、历史、工具与 JSON 保留请求容量;网关更严格的部署按路由调低该值。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index c39bcddda2..95fce2a31f 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -38,12 +38,12 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 * Default request-level bound on base64-encoded image payload. Every image in * history is re-encoded into every request body, so an unbounded conversation * eventually exceeds a provider or gateway request-size cap and the session - * can never complete another request. 24MiB of base64 stays under the - * smallest documented provider request-body cap (30MiB) with headroom for - * text and JSON structure; deployments behind stricter gateways lower it per - * route. + * can never complete another request. The 20MiB default admits four images at + * the attachment store's 3.5MiB raw-image default after base64 expansion and + * reserves request capacity for system prompts, history, tools, and JSON. + * Deployments behind stricter gateways lower it per route. */ -export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 24 * 1024 * 1024 +export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 /** Context capacity assumed for a model neither configuration nor the catalog sizes. */ export const DEFAULT_CONTEXT_WINDOW = 262_144 diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 7b00056d3a..4f4d1aaf21 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -697,6 +697,7 @@ describe('provider profile lifecycle', () => { }) it('validates empty, underspecified, legacy-shaped, and explicitly blank profiles', () => { + expect(DEFAULT_MAX_REQUEST_IMAGE_BYTES).toBe(20 * 1024 * 1024) // Empty and omitted dicts are the dormant zero-route posture, not errors. expect(resolveProfiles({}).size).toBe(0) expect(resolveProfiles(undefined).size).toBe(0) From 1dbafe29736e20ddcf814280dedb6a9de28dd351 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 15:14:09 +0800 Subject: [PATCH 74/95] refactor(llm): centralize deployment retry defaults --- ...26-07-24-provider-retry-policies.i18n.yaml | 4 +- .../2026-07-24-provider-retry-policies.md | 8 ++- .../2026-07-24-provider-retry-policies.zh.md | 8 ++- apps/web/tests/shipped-composition.e2e.ts | 24 +++++++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 61 ++++++++++++++++--- docs/config-catalog.zh.md | 61 ++++++++++++++++--- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 16 ++--- docs/subsystems/llm-streaming.zh.md | 16 ++--- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 13 +--- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 8 +-- packages/llm/llm-deepseek/README.zh.md | 8 +-- packages/llm/llm-deepseek/src/adapter.ts | 6 +- packages/llm/llm-deepseek/src/index.ts | 6 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 12 ++-- packages/llm/llm-pi-ai/README.zh.md | 12 ++-- packages/llm/llm-pi-ai/src/config.ts | 36 ++++------- packages/llm/llm-pi-ai/src/index.ts | 10 +-- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 21 ------- .../llm-pi-ai/tests/dynamic-config.spec.ts | 20 ------ packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 10 ++- packages/llm/llm/README.zh.md | 10 ++- packages/llm/llm/src/index.ts | 30 ++++++--- packages/llm/llm/src/retry-policy.ts | 10 +-- packages/llm/llm/tests/service.spec.ts | 29 ++++++++- 36 files changed, 281 insertions(+), 200 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index c0694ef835..03486a909a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.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-24-provider-retry-policies.md -2026-07-24-provider-retry-policies.md: d85a6eb285e3c4dd47c5d0d3fa27fbbb51490449 -2026-07-24-provider-retry-policies.zh.md: e353624801252d4fc5e083852800bd6c58a7f01d +2026-07-24-provider-retry-policies.md: aa72e05f8ca74621d2ab37f9c2f3bea86cbcf7d9 +2026-07-24-provider-retry-policies.zh.md: a3b998196133ce5880bfacb6985d6c95e64bb650 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md index d85a6eb285..aa72e05f8c 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -12,7 +12,7 @@ Provider policy must follow the request that actually failed, including a route ## Decision -Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. A multi-provider adapter may also expose a composition default that it resolves into every route without an explicit policy; `llm-pi-ai` names this field `defaultRetryPolicy`, and a profile policy wins over it. This remains route-owned registration state rather than a global retry-executor setting. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. A provider without either policy uses the normal defaults of two retries. The shipped Web bundle configures its `deepseek-official` route and the pi-ai adapter default for five normal-mode retries; this deployment choice tolerates longer transient gateway outages while remaining bounded. The `llm-deepseek` user-settings layer or a pi-ai profile policy can override its corresponding default. Other profiles and provider routes retain their own policies. +Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration, validates an explicit value, and exposes that resolved route override through `providerRetryPolicy()`. `LlmRuntime` accepts one deployment-wide `defaultRetryPolicy`, resolves it at service construction, and applies it only when the adapter supplies no route override; omitting both preserves the normal defaults of two retries. The resulting effective policy remains route-owned registration state rather than a retry-executor setting. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. The shipped Web bundle configures `LlmRuntime.defaultRetryPolicy` for five normal-mode retries; every current or future Web adapter route inherits this larger bounded budget unless its provider configuration overrides it. Other profiles retain the core default unless their composition chooses another value. ```yaml providers: @@ -44,12 +44,14 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Alternatives considered -**One global `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. +**One retry-executor-level `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. The LLM deployment default is different: provider route overrides remain authoritative, and the effective policy is captured only after routing selects a registration. **A separate exact-provider list on `dsh-llm-retry`** — rejected because it duplicates provider route names outside their owning adapter configuration and lets provider registration drift from recovery policy. **A very large finite retry count** — rejected because it eventually violates the requested keep-retrying contract and serializes an arbitrary operational limit as if it were meaningful. +**Adapter-level shared defaults** — rejected because a deployment-wide budget would have to be repeated by every adapter family and every future adapter. Putting a discriminated policy object into an adapter's settings base also lets recursive settings layering retain normal-only keys when a user switches the policy to always mode. + **Raise the provider omission default to five** — rejected because it would also increase request cost and latency for headless and custom profiles. The Web bundle's provider configuration expresses the interactive deployment's larger bounded budget without changing other profiles. **Stamp five retries into profiles when the Web UI writes them** — rejected because existing profiles and settings written outside that UI would retain two retries. The deployment default belongs in the Web composition and is inherited uniformly. @@ -60,7 +62,7 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Verification -Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. The pi-ai tests prove its adapter default reaches omitted profiles, explicit profile policies win, invalid defaults fail even while dormant, and settings changes atomically re-register the route set while changing only inherited policy values. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. The shipped Web composition snapshot pins both the resolved `deepseek-official` policy and a settings-added pi-ai route at five retries. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. +Adapter tests validate nested policies at provider load, prove explicit profile policies reach registration, and retain the serving policy across in-flight route replacement. LLM service tests prove adapter overrides win over the deployment default, omission retains the core two-retry behavior, and an invalid deployment policy fails before any route registers. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. The shipped Web composition snapshot pins omitted DeepSeek and pi-ai policies at five retries, then proves settings can replace either with always mode without inheriting normal-only fields. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index e353624801..a3b9981961 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该特定提供方路由注册时捕获策略。多提供方适配器还可以公开一个组合默认值,并将它解析进每条未显式配置策略的路由;`llm-pi-ai` 将该字段命名为 `defaultRetryPolicy`,profile 策略优先于它。这仍然是路由拥有的注册状态,而不是全局重试执行器设置。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。两层策略都未配置的提供方使用两次重试的 normal 默认值。随附的 Web bundle 为自己的 `deepseek-official` 路由和 pi-ai 适配器默认值配置五次 normal 模式重试;这项部署选择能容忍更长的短暂 gateway 故障,同时仍然有界。`llm-deepseek` 用户设置层或 pi-ai profile 策略可以覆盖各自对应的默认值。其他 profile 与提供方路由保留各自的策略。 +每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`,校验显式值,并通过 `providerRetryPolicy()` 公开解析后的路由覆盖。`LlmRuntime` 接受一个部署级 `defaultRetryPolicy`,在服务构造时解析它,并且只在适配器未提供路由覆盖时应用;两者都省略时保留两次重试的 normal 默认值。最终得到的有效策略仍然是路由拥有的注册状态,而不是重试执行器设置。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。随附的 Web bundle 为 `LlmRuntime.defaultRetryPolicy` 配置五次 normal 模式重试;当前及未来的每条 Web 适配器路由都会继承这项更大的有界预算,除非其提供方配置覆盖它。其他 profile 保留核心默认值,除非自身组合选择其他值。 ```yaml providers: @@ -44,12 +44,14 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 曾考虑的替代方案 -**单一全局 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。 +**重试执行器级的单一 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。LLM 部署默认值不同:提供方路由覆盖仍然权威,而且只有在路由选定注册后才捕获有效策略。 **在 `dsh-llm-retry` 上维护单独的指定提供方列表**:不予采纳,因为它会在所属适配器配置之外重复提供方路由名称,并让提供方注册与恢复策略发生偏差。 **设置很大的有限重试次数**:不予采纳,因为它最终仍会违反持续重试的约定,并把任意选取的运维上限序列化成看似有意义的数值。 +**适配器级共享默认值**:不予采纳,因为部署级预算必须在每种适配器族以及未来的每个适配器中重复配置。把可辨识策略对象放入适配器的 settings base,还会让 settings 递归分层在用户把策略切换为 always 模式时保留仅属于 normal 的字段。 + **将提供方省略配置时的默认值提高到五次**:不予采纳,因为这也会增加 headless 与自定义 profile 的请求成本和延迟。Web bundle 的提供方配置能表达互动式部署所需的更大有界预算,而不改变其他 profile。 **在 Web UI 写入 profile 时把五次重试写死进去**:不予采纳,因为现有 profile 与从该 UI 之外写入的 settings 仍会保留两次重试。部署默认值应位于 Web 组合中,并被统一继承。 @@ -60,7 +62,7 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 验证 -适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。pi-ai 测试会证明适配器默认值抵达省略策略的 profile、显式 profile 策略优先、无效默认值即使在休眠状态也会失败,以及 settings 变更会原子地重新注册路由集合,同时只改变继承的策略值。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。随附的 Web 组合快照将解析后的 `deepseek-official` 策略和一条由 settings 新增的 pi-ai 路由都固定为五次重试。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 +适配器测试会在提供方加载时校验嵌套策略,证明显式 profile 策略抵达注册流程,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。LLM 服务测试会证明适配器覆盖优先于部署默认值、省略配置时保留核心的两次重试行为,以及无效部署策略会在任何路由注册前失败。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。随附的 Web 组合快照会把省略配置的 DeepSeek 与 pi-ai 策略固定为五次重试,再证明 settings 可以把任一策略替换为 always 模式,且不会继承仅属于 normal 的字段。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 ## 后果 diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 0e873f4be4..92618c75d8 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -93,8 +93,22 @@ it('assembles the shipped Web catalog, file-reference guidance, retry policy, an ], } `) + await ctx.settings.update(settingsNamespace('llm-deepseek'), { + retryPolicy: { mode: 'always' }, + }) + expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(` + { + "initialDelayMs": 500, + "jitterRatio": 0.1, + "maxDelayMs": 10000, + "mode": "always", + } + `) await ctx.settings.update(settingsNamespace('llm-pi-ai'), { - providers: { openai: {} }, + providers: { + openai: {}, + anthropic: { retryPolicy: { mode: 'always' } }, + }, }) expect(ctx.llm.providerRetryPolicy('openai')).toMatchInlineSnapshot(` { @@ -112,6 +126,14 @@ it('assembles the shipped Web catalog, file-reference guidance, retry policy, an ], } `) + expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchInlineSnapshot(` + { + "initialDelayMs": 500, + "jitterRatio": 0.1, + "maxDelayMs": 10000, + "mode": "always", + } + `) // The catalog belongs to an AGENT, not to the process: every model-facing row // now lives in a preset mounted under one session's scope, so the global // layer holds nothing and a caller must name the agent to see anything. This diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 0f79950a23..6578853f9a 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: b3dc82000c5b11be0dccf6e0132911a283547513 -config-catalog.zh.md: 4165d92956fdbfe6b6de6ec98289ceee5362d2ca +config-catalog.md: 9a330b26cc57302be1b782df7f1d1f29879681b5 +config-catalog.zh.md: 453d9dcc6347c1b984b57f92c5bbeaef7b002b83 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b3dc82000c..9a330b26cc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -836,6 +836,53 @@ export interface Config { Source: [`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) + + +## `@deepseek-ai/dsh-llm` + +```ts config-catalog +/** Deployment-wide defaults applied to provider routes without adapter-owned overrides. */ +export interface Config { + /** Model-request retry policy inherited by routes whose adapter omits one; omission uses normal defaults. */ + defaultRetryPolicy?: RetryPolicyConfig +} + +/** Model-request retry policy configuration for a provider route or deployment default. */ +export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig + +/** Current bounded transient retry behavior for one provider route. */ +export interface NormalRetryPolicyConfig { + /** Retry only configured transient failure codes. */ + mode: 'normal' + /** Maximum eligible retries after the first request (default 2). */ + maxRetries?: number + /** Stable failure codes eligible for this policy. */ + retryableCodes?: string[] + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Unbounded retry behavior for every model-request failure on one provider route. */ +export interface AlwaysRetryPolicyConfig { + /** Retry every model-request failure until success, cancellation, or disposal. */ + mode: 'always' + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Bounded exponential backoff with symmetric jitter around each local delay. */ +export interface BackoffConfig { + /** Initial local exponential-backoff delay in milliseconds (default 500). */ + initialDelayMs?: number + /** Maximum locally scheduled or accepted provider delay in milliseconds (default 10000). */ + maxDelayMs?: number + /** Symmetric random multiplier range around one (default 0.1). */ + jitterRatio?: number +} +``` + +Source: [`packages/llm/llm/src/index.ts:176`](../packages/llm/llm/src/index.ts) + ## `@deepseek-ai/dsh-llm-deepseek` @@ -868,7 +915,7 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ retryPolicy?: RetryPolicyConfig } @@ -898,13 +945,8 @@ Source: [`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepse Requires: `llm` ```ts config-catalog -/** Plugin configuration: the provider routes this instance owns and their shared defaults. */ +/** Plugin configuration: the provider routes this instance owns. */ export interface Config { - /** - * Retry policy inherited by every provider profile that omits its own - * `retryPolicy`; omission here uses the bounded normal defaults. - */ - defaultRetryPolicy?: RetryPolicyConfig /** * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is * the dormant settings-driven posture: the adapter mounts with no routes @@ -988,7 +1030,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission inherits the adapter default, then normal defaults. */ + /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ retryPolicy?: RetryPolicyConfig } @@ -1084,7 +1126,7 @@ type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:174`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts) @@ -3076,7 +3118,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `webServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-host-plugin-inventory` — requires `loader` ([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) -- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4165d92956..453d9dcc63 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -838,6 +838,53 @@ export interface Config { 来源:[`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) + + +## `@deepseek-ai/dsh-llm` + +```ts config-catalog +/** Deployment-wide defaults applied to provider routes without adapter-owned overrides. */ +export interface Config { + /** Model-request retry policy inherited by routes whose adapter omits one; omission uses normal defaults. */ + defaultRetryPolicy?: RetryPolicyConfig +} + +/** Model-request retry policy configuration for a provider route or deployment default. */ +export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig + +/** Current bounded transient retry behavior for one provider route. */ +export interface NormalRetryPolicyConfig { + /** Retry only configured transient failure codes. */ + mode: 'normal' + /** Maximum eligible retries after the first request (default 2). */ + maxRetries?: number + /** Stable failure codes eligible for this policy. */ + retryableCodes?: string[] + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Unbounded retry behavior for every model-request failure on one provider route. */ +export interface AlwaysRetryPolicyConfig { + /** Retry every model-request failure until success, cancellation, or disposal. */ + mode: 'always' + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Bounded exponential backoff with symmetric jitter around each local delay. */ +export interface BackoffConfig { + /** Initial local exponential-backoff delay in milliseconds (default 500). */ + initialDelayMs?: number + /** Maximum locally scheduled or accepted provider delay in milliseconds (default 10000). */ + maxDelayMs?: number + /** Symmetric random multiplier range around one (default 0.1). */ + jitterRatio?: number +} +``` + +来源:[`packages/llm/llm/src/index.ts:176`](../packages/llm/llm/src/index.ts) + ## `@deepseek-ai/dsh-llm-deepseek` @@ -870,7 +917,7 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ retryPolicy?: RetryPolicyConfig } @@ -900,13 +947,8 @@ export interface DeepSeekCatalogModel { 需要:`llm` ```ts config-catalog -/** Plugin configuration: the provider routes this instance owns and their shared defaults. */ +/** Plugin configuration: the provider routes this instance owns. */ export interface Config { - /** - * Retry policy inherited by every provider profile that omits its own - * `retryPolicy`; omission here uses the bounded normal defaults. - */ - defaultRetryPolicy?: RetryPolicyConfig /** * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is * the dormant settings-driven posture: the adapter mounts with no routes @@ -990,7 +1032,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission inherits the adapter default, then normal defaults. */ + /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ retryPolicy?: RetryPolicyConfig } @@ -1086,7 +1128,7 @@ type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' 依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) -来源:[`packages/llm/llm-pi-ai/src/config.ts:174`](../packages/llm/llm-pi-ai/src/config.ts) +来源:[`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts) @@ -3078,7 +3120,6 @@ export interface Config { - `@deepseek-ai/dsh-host-directory-picker-auto` — 需要 `webServer` · `loader`([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native`([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-host-plugin-inventory` — 需要 `loader`([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) -- `@deepseek-ai/dsh-llm`([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp`([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 7e67006a40..78670a641b 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: 6a79e6f7ce5addc64b10efa8da7a886dcfb36dc2 -event-producer-consumer.zh.md: f7576a8e28e4f1db2c65c324595c05c98b8fe488 +event-producer-consumer.md: c17a6163ef829f0176163024a4632b712a7195aa +event-producer-consumer.zh.md: 5e6073ad3dbe1e240b5bc6f5fbc55b499c9373c2 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6a79e6f7ce..c17a6163ef 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-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`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f7576a8e28..5e6073ad3d 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -38,7 +38,7 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-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`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 8f287e2a0f..35e513dcd7 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/llm-streaming.md -llm-streaming.md: 7c0e0865f8dcc0e7722bb2205d0129d9e0ca3086 -llm-streaming.zh.md: 5c31909ee79137c6c5eef101235b43a2419b1339 +llm-streaming.md: 4627f3cd9e224ef2cced3253b82d2b8461d73986 +llm-streaming.zh.md: a161267162aa5773279ca4a417caec57cd721115 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 7c0e0865f8..4627f3cd9e 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -240,7 +240,7 @@ Every adapter MUST obey these, and every consumer may rely on them: ## `ResolvedRetryPolicy` -Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmRuntime.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the value captured from the serving registration after the call selects that registration, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) lists the optional input fields. +Retry configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. An explicit adapter route policy overrides `LlmRuntime.defaultRetryPolicy`; omitting both uses the normal default of two retries. `LlmRuntime.providerRetryPolicy(provider)` returns that effective registered value, and `llmRetryPolicyOf(stream)` returns the value captured from the serving registration after the call selects it, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) lists the optional input fields. ## `AppIdentity` — app attribution @@ -655,7 +655,7 @@ interface LlmCallConfigAdapterDefaults { ## Service and provider contracts -`LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn. +`LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` supplies an explicit route override; omission inherits the LLM deployment default. `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn. ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -694,9 +694,9 @@ declare abstract class LlmAdapter { */ providerInfo(provider: string): LlmProviderInfo; /** - * Return the provider-owned retry policy captured with this route. + * Return an explicit provider-owned retry policy override for this route. * @param _provider - a route passed to `registerAdapter()` for this instance. - * @returns a resolved policy, or `undefined` to use the normal defaults. + * @returns a resolved override, or `undefined` to inherit the LLM deployment default. */ providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined; /** @@ -803,9 +803,9 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise /** - * Resolve the retry policy captured when one provider route was registered. + * Read the effective retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. - * @returns the provider-owned policy, with normal defaults already resolved. + * @returns the adapter override or deployment default, fully resolved. */ providerRetryPolicy(provider: string): ResolvedRetryPolicy @@ -864,7 +864,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:291`](../../packages/llm/llm/src/index.ts) @@ -913,5 +913,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t 'llm/stream'(this: LlmRuntime, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` -Source: [`packages/llm/llm/src/index.ts:64`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:65`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 5c31909ee7..a161267162 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -242,7 +242,7 @@ interface LlmFailure { ## `ResolvedRetryPolicy` -提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。`LlmRuntime.providerRetryPolicy(provider)` 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用选定该注册后,`llmRetryPolicyOf(stream)` 返回为该调用服务的注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选配置输入字段由[生成的配置目录](../config-catalog.md)列出。 +重试配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。显式适配器路由策略会覆盖 `LlmRuntime.defaultRetryPolicy`;两者都省略时使用两次重试的 normal 默认值。`LlmRuntime.providerRetryPolicy(provider)` 返回注册的该有效值;调用选定实际提供服务的注册后,`llmRetryPolicyOf(stream)` 返回从中捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选配置输入字段由[生成的配置目录](../config-catalog.md)列出。 ## `AppIdentity`:应用归属 @@ -661,7 +661,7 @@ interface LlmCallConfigAdapterDefaults { ## 服务与提供方约定 -`LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.md#turn-flow)。 +`LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 提供显式路由覆盖;省略时继承 LLM 部署默认值。`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.md#turn-flow)。 ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -700,9 +700,9 @@ declare abstract class LlmAdapter { */ providerInfo(provider: string): LlmProviderInfo; /** - * Return the provider-owned retry policy captured with this route. + * Return an explicit provider-owned retry policy override for this route. * @param _provider - a route passed to `registerAdapter()` for this instance. - * @returns a resolved policy, or `undefined` to use the normal defaults. + * @returns a resolved override, or `undefined` to inherit the LLM deployment default. */ providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined; /** @@ -809,9 +809,9 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise /** - * Resolve the retry policy captured when one provider route was registered. + * Read the effective retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. - * @returns the provider-owned policy, with normal defaults already resolved. + * @returns the adapter override or deployment default, fully resolved. */ providerRetryPolicy(provider: string): ResolvedRetryPolicy @@ -870,7 +870,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:291`](../../packages/llm/llm/src/index.ts) @@ -919,5 +919,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t 'llm/stream'(this: LlmRuntime, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` -Source: [`packages/llm/llm/src/index.ts:64`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:65`](../../packages/llm/llm/src/index.ts) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 3db5227434..db9ae65f97 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/web-app/README.md -README.md: 1593b801154bcd43625f8aadbf7ca2a0f6df69a2 -README.zh.md: 605d207f3c7870fad9cf1174f8873d1904684c39 +README.md: 30ad018ef60ea2c1c8926f482dd839dec2ee61d8 +README.zh.md: ccc7acff3027ce7cd973f3d9e9a48d831ade00e5 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 1593b80115..30ad018ef6 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -6,7 +6,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove ## Model retry defaults -The Web layer configures normal-mode retries with `maxRetries: 5`, allowing at most five eligible retries after the initial request. This applies to the shipped `deepseek-official` route and to every pi-ai provider profile that omits its own `retryPolicy`. The `llm-deepseek` user-settings section, a pi-ai profile-level policy, or a later profile patch may replace the corresponding deployment default; non-Web profiles retain their own policies. +The Web layer configures `LlmRuntime.defaultRetryPolicy` with normal mode and `maxRetries: 5`, allowing at most five eligible retries after the initial request. Every adapter route without an explicit provider policy inherits it, including `deepseek-official`, settings-added pi-ai routes, and future adapters. A `llm-deepseek` or pi-ai provider `retryPolicy` overrides the deployment default; non-Web profiles retain the core two-retry default unless their own composition chooses another value. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 605d207f3c..ccc7acff30 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -6,7 +6,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 ## 模型重试默认值 -Web 层会配置 normal 模式重试与 `maxRetries: 5`,使其在初次请求后最多再重试 5 次符合条件的失败。该默认值适用于随附的 `deepseek-official` 路由,以及每个未自行配置 `retryPolicy` 的 pi-ai 提供方 profile。`llm-deepseek` 用户设置分节、pi-ai profile 级策略或更后面的 profile patch 可以替换对应的部署默认值;非 Web profile 仍使用各自的策略。 +Web 层会为 `LlmRuntime.defaultRetryPolicy` 配置 normal 模式与 `maxRetries: 5`,使其在初次请求后最多再重试 5 次符合条件的失败。每条没有显式提供方策略的适配器路由都会继承它,包括 `deepseek-official`、由 settings 新增的 pi-ai 路由及未来适配器。`llm-deepseek` 或 pi-ai 提供方的 `retryPolicy` 会覆盖部署默认值;非 Web profile 保留核心的两次重试默认值,除非自身组合选择其他值。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c3b84bf2f7..445095284b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -40,16 +40,9 @@ # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE -# The Web deployment gives its model routes a larger bounded transient-failure -# budget than the provider omission default. DeepSeek settings can override -# its policy, and each pi-ai provider profile can override the inherited one. -- id: llm-deepseek - config: - retryPolicy: - mode: normal - maxRetries: 5 - -- id: llm-pi-ai +# The Web deployment gives every model route without an explicit provider +# policy a larger bounded transient-failure budget than the core default. +- id: llm config: defaultRetryPolicy: mode: normal diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7fb624d21f..b0db0ed002 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -826,9 +826,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', - description: 'Resolve the retry policy captured when one provider route was registered.', + description: 'Read the effective retry policy captured when one provider route was registered.', parameters: [{ name: 'provider', description: 'registered provider route to inspect.' }], - returns: 'the provider-owned policy, with normal defaults already resolved.', + returns: 'the adapter override or deployment default, fully resolved.', }, { signature: 'async listModels(provider: string): Promise', @@ -3315,7 +3315,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmRuntime', - declaration: 'export class LlmRuntime extends Service {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export class LlmRuntime extends Service {\n static Config: z;\n constructor(ctx: Context, config: Config = {});\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'LspHover', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 4ac4cf4ed0..d0caf1d4ff 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: af89e7ee3bab6ec209349d047f81308eb6e87cef -README.zh.md: e9ce3206027ffeee9bc49eb7a2ed76ddcbe7bfc8 +README.md: 9087c57844a3502f8e1ba650979722638eb231f0 +README.zh.md: aea543563d1294d953b36210327cd1bb1d92b961 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index af89e7ee3b..9087c57844 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -20,7 +20,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default - retryPolicy: # optional; omission uses bounded normal defaults + retryPolicy: # optional; omission inherits the LLM deployment default mode: always # normal | always backoff: initialDelayMs: 500 @@ -35,7 +35,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire contextWindow: 512000 ``` -The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek-official` together with its explicit `retryPolicy` override when configured; `LlmRuntime` supplies the deployment default otherwise. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -45,7 +45,7 @@ The same exact-model result exposes ordered `off`, `low`, `high`, and `max` effo `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `low`, `high`, or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers only an explicit provider policy override, `LlmRuntime` captures the effective route policy, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. ## Dynamic configuration (settings + credentials) @@ -54,7 +54,7 @@ Connection facts are not frozen at load. `resolveAdapterOptions` is the one expl - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. - **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. -The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. +The one adapter-owned registration fact is the explicit retry policy override: when its resolved value changes or is removed, the plugin re-registers the route in place (same adapter instance, one synchronous section). `ctx.llm.providerRetryPolicy('deepseek-official')` therefore reports either the current override or `LlmRuntime`'s deployment default. The plugin also declares its route in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`): provider `deepseek-official`, settings namespace `llm-deepseek`, empty settings path — the whole section is the profile. Configuration surfaces use that entry to offer this adapter alongside dormant pi-ai providers. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index e9ce320602..aea543563d 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -20,7 +20,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default - retryPolicy: # optional; omission uses bounded normal defaults + retryPolicy: # optional; omission inherits the LLM deployment default mode: always # normal | always backoff: initialDelayMs: 500 @@ -35,7 +35,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: contextWindow: 512000 ``` -该插件注册唯一提供方路由 `deepseek-official`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 +该插件注册唯一提供方路由 `deepseek-official`,并在配置存在时一同注册显式 `retryPolicy` 覆盖;否则由 `LlmRuntime` 提供部署默认值。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 @@ -45,7 +45,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `low`、`high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 -`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它只注册显式提供方策略覆盖,`LlmRuntime` 捕获有效路由策略,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 ## 动态配置(settings + credentials) @@ -54,7 +54,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 - **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 -唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 +适配器拥有的唯一注册事实是显式重试策略覆盖:其解析值变化或被移除时,插件原地重新注册该路由(同一适配器实例、一个同步区段)。因此,`ctx.llm.providerRetryPolicy('deepseek-official')` 会报告当前覆盖或 `LlmRuntime` 的部署默认值。 该插件还会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`)中声明自己的路由:提供方为 `deepseek-official`,settings namespace 为 `llm-deepseek`,settings path 为空——整个分节就是 profile。配置界面借助该条目,把本适配器与休眠的 pi-ai 提供方一并呈现。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index bc30d46c3b..ca76130d5a 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -66,8 +66,8 @@ export interface DeepSeekConnectionOptions { models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs: number - /** Provider-owned model-request retry policy, already resolved. */ - retryPolicy: ResolvedRetryPolicy + /** Explicit provider-owned model-request retry policy, already resolved. */ + retryPolicy?: ResolvedRetryPolicy } /** Constructor options for {@link DeepSeekAdapter}: the operation-local resolution hooks the plugin owns. */ @@ -166,7 +166,7 @@ export class DeepSeekAdapter extends LlmAdapter { return { id: provider, name: 'DeepSeek' } } - override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined { return this.config.options().retryPolicy } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index a7b22ddd88..88a45ce165 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -76,7 +76,7 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ retryPolicy?: RetryPolicyConfig } @@ -193,7 +193,9 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, models: resolveModels(config.models), streamIdleTimeoutMs, - retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), + ...config.retryPolicy === undefined ? {} : { + retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), + }, } } diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 7f81e5c853..7657430d3c 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: d67db4014fabf0139df938c1a68132cadb7ad118 -README.zh.md: 12808defab58b651b5329d8bb19a15f67ea1963e +README.md: f7e9421d6c782e0419b0a1ba4db659b8b2adeaf2 +README.zh.md: 4a502a504fef2c02f4647ec1b9526e2054d2d53f diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index d67db4014f..f7e9421d6c 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -8,16 +8,12 @@ The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `support ## Config -Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. An optional top-level `defaultRetryPolicy` applies to every profile that does not configure `retryPolicy`; when both are omitted, the core bounded normal defaults apply. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. +Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Each profile may set an explicit `retryPolicy`; omission inherits `LlmRuntime`'s deployment default. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: - # Deployment default inherited by profiles without retryPolicy. - defaultRetryPolicy: - mode: normal - maxRetries: 5 providers: # Catalog route: endpoint, protocol, and models all come from pi-ai. openai: @@ -107,7 +103,7 @@ Resolution still fails loud, naming the offending route and model, when a route ## Dynamic configuration (settings + credentials) -The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. The top-level `defaultRetryPolicy` participates in the same layering; changing it changes every inheriting route's registration fact and atomically replaces the adapter registration, while a profile-level policy's resolved value remains unchanged. Without a mounted settings service the entry config alone drives the adapter, unchanged. +The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. Credentials resolve per stream call through `apiKeyEnv` and the optional `ctx.credentials` seam; without that seam, the adapter reads exactly the referenced environment variable. A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every resolved key is trimmed and format-checked before use, so a value no HTTP header can carry is refused instead of surfacing as an opaque `fetch` `TypeError`; the refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. @@ -117,7 +113,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -The top-level configuration supports `defaultRetryPolicy`. Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's resolved retry policy is captured with that provider route: its own policy wins, then the adapter default, then the bounded normal omission defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. An explicit profile retry policy is captured with that provider route; omission inherits the LLM deployment default, which itself falls back to bounded normal behavior. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -205,4 +201,4 @@ Recorded response content appends to the next request and does not invalidate it - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. -- **Retry policy is provider-owned, not an SDK retry** — the adapter may supply `defaultRetryPolicy`, and each provider profile may override it with nested `retryPolicy`; the resolved route policy is what `dsh-llm-retry` executes at the agent failed-step extension point. pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. +- **Retry policy is provider-owned, not an SDK retry** — each provider profile may supply nested `retryPolicy`; omission inherits `LlmRuntime`'s deployment default, and the effective route policy is what `dsh-llm-retry` executes at the agent failed-step extension point. pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 12808defab..4a502a504f 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -8,16 +8,12 @@ ## 配置 -按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。可选的顶层 `defaultRetryPolicy` 会应用于每个未配置 `retryPolicy` 的 profile;两者都省略时使用核心层有界的 normal 默认值。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 +按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。每个 profile 都可以设置显式 `retryPolicy`;省略时继承 `LlmRuntime` 的部署默认值。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: - # Deployment default inherited by profiles without retryPolicy. - defaultRetryPolicy: - mode: normal - maxRetries: 5 providers: # Catalog route: endpoint, protocol, and models all come from pi-ai. openai: @@ -108,7 +104,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 ## 动态配置(settings + credentials) -适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。顶层 `defaultRetryPolicy` 参与同一套分层;修改它会改变每条继承路由的注册事实,并原子地替换适配器注册,而 profile 级策略的解析值保持不变。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 +适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 凭据在每次流调用时通过 `apiKeyEnv` 与可选的 `ctx.credentials` seam 解析;未挂载该 seam 时,适配器只读取该引用指向的环境变量。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个解析出的密钥都会在使用前去除首尾空白并校验格式,因此 HTTP 标头无法承载的值会被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;这种拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(该协议将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 @@ -118,7 +114,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -顶层配置支持 `defaultRetryPolicy`。受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 解析后的重试策略都会与该提供方路由一同捕获:自身策略优先,其次是适配器默认值,最后是有界的 normal 省略默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。显式 profile 重试策略会随该提供方路由一同捕获;省略时继承 LLM 部署默认值,而该默认值自身会回退到有界 normal 行为。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -206,4 +202,4 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 - **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 -- **重试策略由提供方持有,而不是 SDK 重试**:适配器可以提供 `defaultRetryPolicy`,每个提供方 profile 也可以用嵌套的 `retryPolicy` 覆盖它;`dsh-llm-retry` 在 agent 的失败步骤扩展点上执行解析后的路由策略。pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。 +- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以提供嵌套的 `retryPolicy`;省略时继承 `LlmRuntime` 的部署默认值,`dsh-llm-retry` 会在 agent 的失败步骤扩展点上执行有效路由策略。pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 75f32c8f4a..0641649c08 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -1,9 +1,7 @@ /** * Configuration schema and provider-profile validation for the pi-ai adapter. * Profiles are a dict keyed by provider route, so the composition base and a - * user-settings layer merge per provider and the route set is structural. An - * adapter-level retry default lets a deployment choose one policy for every - * route that does not override it without changing the core omission default. + * user-settings layer merge per provider and the route set is structural. * * A route key is not required to name an installed pi-ai provider. When it does, * that provider's endpoint, protocol, display name, and model catalog are the @@ -138,7 +136,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission inherits the adapter default, then normal defaults. */ + /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ retryPolicy?: RetryPolicyConfig } @@ -153,8 +151,8 @@ export interface ResolvedPiAiProviderProfile apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number - /** Immutable retry policy captured with this provider route. */ - retryPolicy: ResolvedRetryPolicy + /** Explicit immutable retry policy captured with this provider route. */ + retryPolicy?: ResolvedRetryPolicy /** * The pi-ai provider this route registers, built from the resolved models. * Construction happens here so an unserviceable protocol or an underspecified @@ -170,13 +168,8 @@ export interface ResolvedPiAiProviderProfile configuredMaxTokens: ReadonlyMap } -/** Plugin configuration: the provider routes this instance owns and their shared defaults. */ +/** Plugin configuration: the provider routes this instance owns. */ export interface Config { - /** - * Retry policy inherited by every provider profile that omits its own - * `retryPolicy`; omission here uses the bounded normal defaults. - */ - defaultRetryPolicy?: RetryPolicyConfig /** * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is * the dormant settings-driven posture: the adapter mounts with no routes @@ -260,7 +253,6 @@ const profile = z.object({ /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ - defaultRetryPolicy: RetryPolicySchema, providers: z.dict(profile).default({}), }) @@ -277,7 +269,7 @@ export const Config: z = z.object({ * @throws Error naming the route and model that cannot be served. */ export function assertServiceable(config: Config): void { - resolveProfiles(config.providers, config.defaultRetryPolicy) + resolveProfiles(config.providers) } /** Reject removed pre-release profile fields and name their replacements. */ @@ -302,23 +294,17 @@ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): voi * Validate profiles and return a detached route-keyed map suitable for * per-request reads. This is the one explicit resolve step, so an omitted dict * resolves to the empty (dormant) route set here rather than through a hidden - * fallback, and each route's models, retry policy, and pi-ai provider are - * materialized once. + * fallback, and each route's models, explicit retry policy, and pi-ai provider + * are materialized once. * @param providers - configured provider profiles keyed by route. - * @param defaultRetryPolicy - adapter policy inherited by profiles that omit one. * @returns validated profiles in configuration order. */ export function resolveProfiles( providers: Readonly> | undefined, - defaultRetryPolicy?: RetryPolicyConfig, ): Map { if (Array.isArray(providers)) { throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') } - const resolvedDefaultRetryPolicy = resolveRetryPolicy( - defaultRetryPolicy, - 'llm-pi-ai: defaultRetryPolicy', - ) const entries = Object.entries(providers ?? {}) const resolved = new Map() for (const [provider, source] of entries) { @@ -369,9 +355,9 @@ export function resolveProfiles( displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, - retryPolicy: retryPolicy === undefined - ? resolvedDefaultRetryPolicy - : resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), + ...retryPolicy === undefined ? {} : { + retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), + }, ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, configuredMaxTokens: catalog.configuredMaxTokens, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 956a03fddd..23133ee44b 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -5,7 +5,7 @@ * pi-ai does not ship is declared outright. Profile facts resolve per request * over the optional `llm-pi-ai` user-settings section and the optional * credential seam, so a changed key, endpoint, model, or knob reaches the next - * request without a restart; a changed *route set* (or a route's resolved, + * request without a restart; a changed *route set* (or a route's explicit, * registration-captured retry policy) re-registers the same adapter instance * in place. * @@ -13,10 +13,6 @@ * - id: llm * name: '@deepseek-ai/dsh-llm-pi-ai' * config: - * # Optional deployment default; a provider profile may override it. - * defaultRetryPolicy: - * mode: normal - * maxRetries: 5 * providers: * # Catalog route: everything but the credential comes from pi-ai. * openai: @@ -103,7 +99,7 @@ function registrationFacts(profiles: ReadonlyMap ({ provider, displayName: profile.displayName, - retryPolicy: profile.retryPolicy, + ...profile.retryPolicy === undefined ? {} : { retryPolicy: profile.retryPolicy }, })) .sort((left, right) => left.provider.localeCompare(right.provider)) } @@ -169,7 +165,7 @@ export function apply(ctx: Context, config: Config): void { const profiles = (): ReadonlyMap => { const raw = current() if (raw === lastRaw && memoized !== undefined) return memoized - const next = resolveProfiles(raw.providers, raw.defaultRetryPolicy) + const next = resolveProfiles(raw.providers) lastRaw = raw memoized = next return next diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index bfc186f10a..51ce347e9a 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -752,27 +752,6 @@ describe('provider profile lifecycle', () => { expect(ctx.llm.listProviders()).toEqual([]) }) - it('inherits the adapter retry default unless a provider profile overrides it', () => { - const profiles = resolveProfiles({ - openai: {}, - anthropic: { retryPolicy: { mode: 'normal', maxRetries: 1 } }, - }, { mode: 'normal', maxRetries: 5 }) - - expect(profiles.get('openai')?.retryPolicy).toMatchObject({ mode: 'normal', maxRetries: 5 }) - expect(profiles.get('anthropic')?.retryPolicy).toMatchObject({ mode: 'normal', maxRetries: 1 }) - }) - - it('rejects an invalid adapter retry default even while dormant', async () => { - const invalid = { mode: 'normal', maxRetries: -1 } as const - expect(() => resolveProfiles(undefined, invalid)).toThrow(/defaultRetryPolicy/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmPiAi, { defaultRetryPolicy: invalid })) - .rejects.toThrow(/defaultRetryPolicy|retryPolicy/) - expect(ctx.llm.listProviders()).toEqual([]) - }) - it('constructs the adapter directly and rejects routes it does not own', async () => { const adapter = adapterOf({ openai: {} }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 0f90ad8802..1019354156 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -156,26 +156,6 @@ describe('request-level dynamic profiles', () => { expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) }) - it('re-registers inherited policies when the adapter retry default changes', async () => { - const dir = await home() - const ctx = await boot(dir, { - defaultRetryPolicy: { mode: 'normal', maxRetries: 5 }, - providers: { - openai: {}, - anthropic: { retryPolicy: { mode: 'normal', maxRetries: 1 } }, - }, - }) - - expect(ctx.llm.providerRetryPolicy('openai')).toMatchObject({ mode: 'normal', maxRetries: 5 }) - expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({ mode: 'normal', maxRetries: 1 }) - - await ctx.settings.update(NS, { - defaultRetryPolicy: { mode: 'normal', maxRetries: 4 }, - }) - expect(ctx.llm.providerRetryPolicy('openai')).toMatchObject({ mode: 'normal', maxRetries: 4 }) - expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({ mode: 'normal', maxRetries: 1 }) - }) - it('refuses a settings write this adapter could not serve, leaving its routes alone', async () => { const dir = await home() const ctx = await boot(dir, { providers: { openai: {} } }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index fce8fa059b..1df7d050e4 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: fb6bd84240b41dd730d45b3eb34c35827dc4c991 -README.zh.md: 5c22767a7c654972cbf614505382fa4755d7d318 +README.md: 91d4b745c2e179c1d943628ea7c4337cb48291f1 +README.zh.md: f9492e6cd3edb5b6b969cfde0fb8a759f95ae77c diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index fb6bd84240..91d4b745c2 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -8,6 +8,10 @@ Provider-neutral LLM vocabulary and abstract service. This package defines the c An adapter registry plus a single streaming call API, interceptable via a waterfall event. +### Configuration + +`defaultRetryPolicy` is the deployment policy inherited by every provider route whose adapter supplies no explicit override. Omitting it preserves the bounded normal default of two retries. An adapter override wins, and the effective resolved policy is captured with the route registration; this service stores that policy but does not execute retries. + ### Public API - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration. @@ -17,7 +21,7 @@ An adapter registry plus a single streaming call API, interceptable via a waterf - `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` Offer to interrogate provider endpoints for the settings namespace this plugin owns. One offer per namespace (`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`), disposed with the calling fiber. - `ctx.llm.listModelDiscoveryNamespaces(): string[]` List the namespaces that can interrogate an endpoint, so a surface offers the action only where it works. - `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise` Ask one endpoint which models it advertises. -- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the effective retry policy captured during registration: the adapter override when present, otherwise the deployment default. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize adapter-configured call defaults without clamping. @@ -28,7 +32,7 @@ An adapter registry plus a single streaming call API, interceptable via a waterf Interrogating an endpoint is configuration-time work over a *draft*, keyed by settings namespace rather than by provider route — the provider a surface is adding does not exist yet, so there is no route to name. The request may still *name* a route it is editing, and an adapter that already describes that route answers from its own knowledge without a network call; `baseURL` is optional and one of the two is required. The request otherwise carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and a request naming neither a route nor an endpoint fails with `INVALID_DISCOVERY`. -Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. +Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures each route's effective retry policy, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. Every topology commit point — adapter routes registering or disposing, directory entries appearing or withdrawing — emits the payload-free `llm/adapters-updated` event after the mutation, so consumers re-read `listProviders()`/`listModels()`/`listConfigurableProviders()` instead of polling. Observer failures are contained (logged, non-vetoing); only `INVARIANT`-coded failures rethrow after the fan-out. @@ -44,7 +48,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, an output default, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use bounded normal retry policy, use the route and model ids as names, advertise no models, and return no capacity, output default, or reasoning metadata. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` only for an explicit provider-owned recovery policy; omission inherits `LlmRuntime`'s deployment default. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, an output default, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use the route and model ids as names, advertise no models, and return no capacity, output default, or reasoning metadata. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Messages (`message.ts`) and content blocks (`types.ts`) diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 5c22767a7c..f9492e6cd3 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -8,6 +8,10 @@ 一个适配器注册表加单一流式调用接口,可通过 waterfall(瀑布式事件)拦截。 +### 配置 + +`defaultRetryPolicy` 是每条未由适配器提供显式覆盖的提供方路由所继承的部署策略。省略它会保留两次重试的有界 normal 默认值。适配器覆盖优先,解析后的有效策略会随路由注册一同捕获;本服务存储该策略,但不执行重试。 + ### 公开 API - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的句柄还提供 `replace(providers)`:候选路由集合会在注册状态发生任何变化前完成整体验证,因此与另一适配器发生冲突时,当前路由仍保持注册并继续提供服务。替换会在一次同步操作中完成,不会出现可观察的空档。`replace([])` 合法,表示保留注册但不持有任何路由;初始注册则不得为空。 @@ -17,7 +21,7 @@ - `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` 为本插件拥有的 settings namespace 提供查询提供方端点的能力。每个 namespace 只能有一个(`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`),并随调用 fiber dispose。 - `ctx.llm.listModelDiscoveryNamespaces(): string[]` 列出可以询问端点的 namespace,让界面只在可用之处提供该动作。 - `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise` 询问某个端点它公布了哪些模型。 -- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方自身的重试策略,并解析 normal 默认值。 +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的有效重试策略:有适配器覆盖时使用该覆盖,否则使用部署默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有该精确路由的适配器中,解析并校验确切模型身份,以及可用上下文、输出默认值和推理(reasoning)元数据;异步适配器可选地支持取消。 - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的调用默认值,但不自动调整。 @@ -28,7 +32,7 @@ 询问端点属于配置期针对**草稿**的操作,以 settings namespace 而非提供方路由为键——界面正在新增的提供方还不存在,也就没有路由可点名。但请求仍可**点名**它正在编辑的路由,而已经描述该路由的适配器会用自己的知识作答,无需联网;路由名称和 `baseURL` 至少需要提供一项。除此之外,请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据。这里既不读取也不写入 settings 或 credentials;返回内容是界面可以提供给用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,既不点名路由也不给端点的请求以 `INVALID_DISCOVERY` 失败。 -提供方和模型元数据用于发现,不构成路由白名单。`registerAdapter()` 仍拥有提供方路由的排他性,并为每条路由捕获适配器的重试策略;适配器可以接受未出现在 `listModels()` 中的模型 id,消费方不得仅因模型未列出而拒绝请求。返回的 selector 元数据已分离;无效或重复的适配器条目会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 +提供方和模型元数据用于发现,不构成路由白名单。`registerAdapter()` 仍拥有提供方路由的排他性,并为每条路由捕获有效重试策略;适配器可以接受未出现在 `listModels()` 中的模型 id,消费方不得仅因模型未列出而拒绝请求。返回的 selector 元数据已分离;无效或重复的适配器条目会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 每个拓扑提交点——适配器路由注册或 dispose、目录条目出现或撤回——都会在变更之后发出无载荷的 `llm/adapters-updated` 事件,消费方因此会重新读取 `listProviders()`/`listModels()`/`listConfigurableProviders()`,而不是轮询。观察者故障会被记录并隔离,不能否决变更;只有带 `INVARIANT` 码的故障会在通知完所有观察者后重新抛出。 @@ -44,7 +48,7 @@ ### 扩展点 -- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 以提供由提供方定义的恢复配置,覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量、输出默认值或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界的 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量、输出默认值或推理元数据。 +- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。仅在提供方拥有显式恢复策略时覆盖 `providerRetryPolicy()`;省略时继承 `LlmRuntime` 的部署默认值。覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量、输出默认值或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现将路由和模型 id 用作名称,不公布模型,也不返回容量、输出默认值或推理元数据。 - 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。包装层如果在已经发出分片后重试,就没有可持久记录的尝试边界;因此,随产品交付的 agent 重试策略改用 `agent/request-error`。 ### 消息(`message.ts`)与内容块(`types.ts`) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index e87c428d06..9e4ce473d0 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,6 +7,7 @@ */ import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { GenerateOptions, LlmConfigurableProvider, @@ -21,8 +22,8 @@ import type { StreamChunk, } from './types.ts' import { freezeMessage, type Message } from './message.ts' -import { resolveRetryPolicy } from './retry-policy.ts' -import type { ResolvedRetryPolicy } from './retry-policy.ts' +import { resolveRetryPolicy, RetryPolicySchema } from './retry-policy.ts' +import type { ResolvedRetryPolicy, RetryPolicyConfig } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' @@ -171,6 +172,12 @@ export interface PreparedLlmCall { stream(options: GenerateOptions): AsyncIterable } +/** Deployment-wide defaults applied to provider routes without adapter-owned overrides. */ +export interface Config { + /** Model-request retry policy inherited by routes whose adapter omits one; omission uses normal defaults. */ + defaultRetryPolicy?: RetryPolicyConfig +} + /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include @@ -188,9 +195,9 @@ export abstract class LlmAdapter { } /** - * Return the provider-owned retry policy captured with this route. + * Return an explicit provider-owned retry policy override for this route. * @param _provider - a route passed to `registerAdapter()` for this instance. - * @returns a resolved policy, or `undefined` to use the normal defaults. + * @returns a resolved override, or `undefined` to inherit the LLM deployment default. */ providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined { return undefined @@ -282,15 +289,22 @@ export interface DirectoryRegistrationHandle { * API, interceptable via the `llm/stream` waterfall. */ export class LlmRuntime extends Service { + static Config: z = z.object({ + defaultRetryPolicy: RetryPolicySchema, + }) + private adapters = new Map() private directory = new Map() private discoveries = new Map< string, (request: LlmModelDiscoveryRequest) => Promise >() + /** Resolved once because service configuration is fixed for this runtime instance. */ + private readonly defaultRetryPolicy: ResolvedRetryPolicy - constructor(ctx: Context) { + constructor(ctx: Context, config: Config = {}) { super(ctx, 'llm') + this.defaultRetryPolicy = resolveRetryPolicy(config.defaultRetryPolicy, 'llm: defaultRetryPolicy') } /** Notify topology observers without letting one broken listener veto the commit. */ @@ -385,7 +399,7 @@ export class LlmRuntime extends Service { } unique.add(provider) const retryPolicy = adapter.providerRetryPolicy(provider) - ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) + ?? this.defaultRetryPolicy registrations.push({ adapter, provider: { id: info.id, name: info.name }, @@ -559,9 +573,9 @@ export class LlmRuntime extends Service { } /** - * Resolve the retry policy captured when one provider route was registered. + * Read the effective retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. - * @returns the provider-owned policy, with normal defaults already resolved. + * @returns the adapter override or deployment default, fully resolved. */ providerRetryPolicy(provider: string): ResolvedRetryPolicy { return this.registration(provider).retryPolicy diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts index ad9c7af65c..99f38e84b3 100644 --- a/packages/llm/llm/src/retry-policy.ts +++ b/packages/llm/llm/src/retry-policy.ts @@ -1,5 +1,5 @@ /** - * Provider-owned request-retry policy configuration and resolution. + * Provider-route request-retry policy configuration and resolution. * * Adapters expose one resolved policy per registered provider route; the * optional dsh-llm-retry plugin executes it on the agent's failed-step extension point. @@ -53,7 +53,7 @@ export interface AlwaysRetryPolicyConfig { backoff?: BackoffConfig } -/** Provider-owned model-request retry policy configuration. */ +/** Model-request retry policy configuration for a provider route or deployment default. */ export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig /** Fully resolved backoff shared by both retry modes. */ @@ -137,9 +137,9 @@ function resolveBackoff(config: BackoffConfig | undefined, path: string): Resolv } /** - * Validate, default, and detach one provider-owned retry policy. - * @param config - optional provider configuration; omission selects normal defaults. - * @param path - diagnostic path naming the provider config that owns the value. + * Validate, default, and detach one retry policy. + * @param config - optional route or deployment configuration; omission selects normal defaults. + * @param path - diagnostic path naming the configuration that owns the value. * @returns an immutable policy safe to capture in provider registration state. */ export function resolveRetryPolicy( diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index ed7d8d9bf8..2b8827aff5 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -203,7 +203,7 @@ describe('LlmRuntime', () => { expect(adapter.lastOptions?.messages[0]).toBe(message) }) - it('captures provider-owned retry policy at registration and defaults omission', async () => { + it('captures an adapter retry override ahead of the deployment default', async () => { const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy') const adapter = new class extends ScriptedAdapter { override providerRetryPolicy(provider: string) { @@ -211,19 +211,42 @@ describe('LlmRuntime', () => { } }(SCRIPT) const ctx = new Context() - await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmRuntime, { + defaultRetryPolicy: { mode: 'normal', maxRetries: 5 }, + }) ctx.llm.registerAdapter(['configured', 'defaulted'], adapter) expect(ctx.llm.providerRetryPolicy('configured')).toBe(configured) expect(ctx.llm.providerRetryPolicy('defaulted')).toMatchObject({ mode: 'normal', - maxRetries: 2, + maxRetries: 5, }) expect(() => ctx.llm.providerRetryPolicy('missing')).toThrow( expect.objectContaining({ code: 'NO_ADAPTER' }), ) }) + it('uses bounded normal defaults when the deployment omits a retry policy', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + ctx.llm.registerAdapter(['defaulted'], new ScriptedAdapter(SCRIPT)) + + expect(ctx.llm.providerRetryPolicy('defaulted')).toMatchObject({ + mode: 'normal', + maxRetries: 2, + }) + }) + + it('rejects an invalid deployment retry policy before any route registers', async () => { + const ctx = new Context() + await expect(ctx.plugin(LlmRuntime, { + defaultRetryPolicy: { + mode: 'normal', + backoff: { initialDelayMs: 10, maxDelayMs: 5 }, + }, + })).rejects.toThrow(/llm: defaultRetryPolicy/) + }) + it('keeps a prepared registration and retry policy after route replacement', async () => { const oldPolicy = resolveRetryPolicy({ mode: 'always' }, 'old retryPolicy') const newPolicy = resolveRetryPolicy({ mode: 'normal', maxRetries: 0 }, 'new retryPolicy') From cf7d485b5ecd0cecc98e5b0534ef939d12b1e3b3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 15:25:08 +0800 Subject: [PATCH 75/95] fix(client): harden settings describe mirror --- ...12-plugin-owned-settings-surface.i18n.yaml | 4 +- ...026-08-12-plugin-owned-settings-surface.md | 4 +- ...-08-12-plugin-owned-settings-surface.zh.md | 4 +- ...6-08-17-settings-describe-mirror.i18n.yaml | 4 +- .../2026-08-17-settings-describe-mirror.md | 6 +- .../2026-08-17-settings-describe-mirror.zh.md | 6 +- ...8-06-host-backed-web-preferences.i18n.yaml | 4 +- .../2026-08-06-host-backed-web-preferences.md | 4 +- ...26-08-06-host-backed-web-preferences.zh.md | 4 +- ...seek-onboarding-credential-setup.i18n.yaml | 4 +- ...30-deepseek-onboarding-credential-setup.md | 2 +- ...deepseek-onboarding-credential-setup.zh.md | 2 +- apps/web/tests/startup-rpc-budget.e2e.ts | 4 +- .../src/client/settings-store.ts | 2 +- .../permission-presets-row.client.spec.tsx | 10 +-- .../tests/settings-store.client.spec.ts | 17 +++- .../src/client/settings-document-store.ts | 2 +- .../tests/components.client.spec.tsx | 4 +- .../settings-document-store.client.spec.ts | 12 +-- .../ui-settings-models/src/client/index.ts | 9 +- .../ui-settings-models/src/client/store.ts | 11 +-- .../src/client/welcome-store.ts | 20 ++++- .../tests/apply.client.spec.ts | 52 +++++++++++- .../tests/store.client.spec.ts | 39 ++++++++- .../src/client/tab-store.ts | 2 +- .../ui-settings/src/client/settings-mirror.ts | 29 +++++-- .../tests/settings-mirror.client.spec.ts | 82 ++++++++++++++++--- .../tests/settings-scope.client.spec.ts | 22 +++++ 28 files changed, 285 insertions(+), 80 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml index d77b2c75c7..ce1925c821 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.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-12-plugin-owned-settings-surface.md -2026-08-12-plugin-owned-settings-surface.md: 3137cfe81ef3cb78a940f085c559ab4a7b62cce3 -2026-08-12-plugin-owned-settings-surface.zh.md: 8dd5e5ccebf1cfb80b55a615f6049dd391943bd7 +2026-08-12-plugin-owned-settings-surface.md: 722e6cfbe890418e8305f89790e76976027d7775 +2026-08-12-plugin-owned-settings-surface.zh.md: 93e5227d5f6a629fd32f5a2fe22e9882c7f7c5ac diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md index 3137cfe81e..722e6cfbe8 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -22,7 +22,7 @@ Together the two meant a user-authored plugin was configurable only by hand-edit **`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`. The slot is declared by the Plugins section's `configurable` tab, which owns the card list. -**The tab drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer. +**The tab drives dispatch from the served namespaces.** It derives the current served set from `ctx.settingsScope.describe()` and follows that shared settings mirror, while its own listener follows the card slot ledger. It dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the mirror answer. The later [settings describe mirror decision](2026-08-17-settings-describe-mirror.md) owns the browser-wide read and invalidation lifecycle. Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the tab counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line. @@ -56,6 +56,6 @@ A plugin distributed outside this repository is configurable from the settings p Deferred, and larger than this change: the redactor returns a `role('secret')` reachable only through a union, intersection, or transform verbatim (its own `TODO(settings-wire-redaction)`), and `schema.toJSON()` carries a secret's default. That gap predates this change, but serving every registered namespace widens its blast radius from schemas audited in this repository to any third-party schema, so the wire should refuse a namespace it cannot prove it can redact. Also deferred: an assembled-composition test of the headline capability — an overlay-mounted fixture plugin whose Host half registers a namespace and whose `dsh.client` half registers a card, asserted end-to-end. The current coverage proves each half separately; the shipped cards' unchanged output cannot prove the new path. -The wire read the section adds is one `settings.describe` beside the per-scope reads the cards already make. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the section's read joins on the next commit or reconnect. +The section and its cards add no `settings.describe` reads: both derive from the browser-wide mirror. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the mirror's current answer joins on the next commit or reconnect. Two frictions remain for an author outside this repository, both recorded in the section's README. The browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package. The bundle-purity gate forbids importing this package's card chrome or staged-form model as values, so such a card reimplements staging and revision fencing. Sharing them would mean either publishing the preset or declaring a child slot inside the card so the section supplies the chrome; neither is built. diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md index 8dd5e5cceb..93e5227d5f 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -22,7 +22,7 @@ Status: implemented **`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。该 slot 由「插件」分区的 `configurable` 标签页声明,卡片列表归它所有。 -**标签页以被服务的命名空间驱动派发。** 它读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与协议答复算出。 +**标签页以被服务的命名空间驱动派发。** 它从 `ctx.settingsScope.describe()` 派生当前被服务的集合并跟随该共享 settings 镜像,自身的监听器只跟随卡片 slot 账本;随后为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与镜像应答算出。后续的 [settings describe 镜像决策](2026-08-17-settings-describe-mirror.md)持有浏览器全局的读取与失效生命周期。 以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme`、`permission`、`llm-*`、`agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:标签页数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。 @@ -56,6 +56,6 @@ Status: implemented 以下延后,且都大于本次改动:脱敏器对只能经由 union、intersection 或 transform 抵达的 `role('secret')` 原样返回(其自身的 `TODO(settings-wire-redaction)`),而 `schema.toJSON()` 会携带 secret 的默认值。该缺口早于本次改动,但服务每一个已注册命名空间,把它的影响面从本仓库内经审计的 schema 扩大到任意第三方 schema,因此协议应当拒绝服务它无法证明可安全脱敏的命名空间。同样延后的还有:对本次头号能力的组装态测试——用 overlay 挂载一个 fixture 插件(Host 半注册命名空间、`dsh.client` 半注册卡片)并在端到端断言。当前覆盖分别证明了两个半侧;已发卡片输出未变这一点,证明不了新路径。 -分区新增的协议读取是一次 `settings.describe`,与卡片各自已有的 per-scope 读取并列。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在分区读取之后才被注册的命名空间,要等下一次提交或重连才会加入。 +分区与其中的卡片都不再新增 `settings.describe` 读取:两者都从浏览器全局的镜像派生。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在镜像当前应答之后才被注册的命名空间,要等下一次提交或重连才会加入。 对仓库之外的作者仍留有两处摩擦,均记在该分区的 README 里。浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`,并非已发布的包。bundle 纯净度门禁禁止以值的形式导入本包的卡片外观与暂存表单模型,因此这样的卡片要重新实现暂存与 revision 设栅。要共享它们,要么发布该预设,要么在卡片内部声明一层子 slot 让分区提供外观;两者都尚未构建。 diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml index 245a6770c7..35b8cb3aa3 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.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-17-settings-describe-mirror.md -2026-08-17-settings-describe-mirror.md: c845c8ab65c526f27d09d1efdfd584a757ef00ba -2026-08-17-settings-describe-mirror.zh.md: 1229aaf9900178b6a86d7f3cdedeebc2ac99663c +2026-08-17-settings-describe-mirror.md: a3774699ff328a44aed192a16dea0fa19d03c83c +2026-08-17-settings-describe-mirror.zh.md: c57f5630b4bee0cd77a29a6f5458cb439c7f0585 diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md index c845c8ab65..a3774699ff 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md @@ -10,9 +10,11 @@ A cold web boot issued `settings.describe` fifteen times inside ~200ms, and the ## Decision -**One reader, many derivations.** `dsh-client-ui-settings` owns `SettingsDescribeMirror`, the single `settings.describe` reader in the browser: one snapshot store holding the whole answer, refreshed by the owning plugin's two subscriptions (`settings/document-updated`, `connection/reset`). Concurrent `load()` calls fold into the in-flight read plus at most one rerun — the in-flight slot clears inside the run's own try/finally, in the same synchronous segment that observes the rerun flag, because a `.finally()` on the returned promise runs one microtask later and a `load()` landing in that gap marked a rerun nobody read. +**One reader, many derivations.** `dsh-client-ui-settings` owns `SettingsDescribeMirror`, the single `settings.describe` reader in the browser: one snapshot store holding the whole answer, refreshed by the owning plugin's two subscriptions (`settings/document-updated`, `connection/reset`). Concurrent `load()` calls fold into the in-flight read plus at most one rerun. The in-flight slot owns a run before its loading publication can synchronously reenter `load()`, then clears inside the run's own try/finally in the same synchronous segment that observes the rerun flag; a `.finally()` on the returned promise would run one microtask later and let a refresh landing in that gap mark a rerun nobody reads. -`bind()` still returns the unchanged `SettingsScope` face, but the controller is now a selector over the mirror: no read path of its own, the same decode rules, and the write queue kept. A committed write folds its answered view back into the mirror (`acceptView`), so sibling scopes see the new revision with no re-read; a failed latest write triggers one mirror recovery read. Cross-namespace surfaces — the plugin-directory tab, the permission row (its dynamic enum lives in the namespace SCHEMA, which scopes deliberately do not carry), the models join, the agent-preset row's writability, and `hasDocument` — consume `ctx.settingsScope.describe()`, a read-only face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`). +`bind()` still returns the unchanged `SettingsScope` face, but the controller is now a selector over the mirror: no read path of its own, the same decode rules, and the write queue kept. A committed write folds its answered view back into the mirror (`acceptView`), so sibling scopes see the new revision with no re-read; the fold invalidates any older in-flight answer, and a write before the first held document reruns that read instead of publishing a partial document. A failed latest write triggers one mirror recovery read. Cross-namespace surfaces — the plugin-directory tab, the permission row (its dynamic enum lives in the namespace schema, which scopes deliberately do not carry), the models join, the agent-preset row's writability, and `hasDocument` — consume `ctx.settingsScope.describe()`, the shared read/fold face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`). + +This decision updates the browser read and invalidation mechanics recorded by [Host-backed Web preferences](../bug-fix/2026-08-06-host-backed-web-preferences.md) and [plugin-owned settings surface](2026-08-12-plugin-owned-settings-surface.md), while preserving their preference-ownership and namespace-exposure decisions. It also replaces the direct settings-read description in [official DeepSeek first-run credential setup](../feature/2026-07-30-deepseek-onboarding-credential-setup.md); that join now derives its settings half from this mirror. The cold-boot budget is pinned at two reads by `apps/web/tests/startup-rpc-budget.e2e.ts`: the mirror's eager bind-time read, plus the first-connection reset read, which is kept deliberately — it closes the window where a document commit lands between the eager HTTP read and the SSE subscription and its invalidation is lost. The plan's original target of one read is unreachable without either accepting that lost-invalidation window or delaying the first read until after the SSE stream opens. diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md index 1229aaf990..c57f5630b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md @@ -10,9 +10,11 @@ Status: implemented ## 决定 -**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated`、`connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读——在飞槽位在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空,因为挂在返回 promise 上的 `.finally()` 要晚一个微任务执行,落入该间隙的 `load()` 会标记一个无人读取的 rerun。 +**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated`、`connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读。在飞槽位会在 loading 发布同步重入 `load()` 之前先取得 run 的所有权,随后在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空;若把清理挂在返回 promise 的 `.finally()` 上,它要晚一个微任务执行,落入该间隙的刷新会标记一个无人读取的 rerun。 -`bind()` 返回的 `SettingsScope` 面保持不变,但 controller 现在是镜像上的 selector:自身没有读路径,decode 规则不变,写队列保留。提交成功的写入把应答的 view 折回镜像(`acceptView`),兄弟 scope 无需重读即可看到新 revision;失败的最新写入触发一次镜像恢复读取。跨命名空间的表面——插件目录 tab、permission 行(其动态枚举位于命名空间 **schema** 中,而 scope 有意不携带 schema)、models join、agent-preset 行的可写性、以及 `hasDocument`——消费 `ctx.settingsScope.describe()` 只读面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。 +`bind()` 返回的 `SettingsScope` 面保持不变,但 controller 现在是镜像上的 selector:自身没有读路径,decode 规则不变,写队列保留。提交成功的写入把应答的 view 折回镜像(`acceptView`),兄弟 scope 无需重读即可看到新 revision;这次折叠会废弃更早发出的在飞应答,而首次完整文档尚未建立时到达的写入会让该读取重跑,不会把单个 namespace 发布成残缺文档。失败的最新写入触发一次镜像恢复读取。跨命名空间的表面——插件目录 tab、permission 行(其动态枚举位于命名空间 schema 中,而 scope 有意不携带 schema)、models join、agent-preset 行的可写性、以及 `hasDocument`——消费 `ctx.settingsScope.describe()` 提供的共享读/折叠面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。 + +本决策更新了[通过 Host settings 持久化 Web 用户偏好](../bug-fix/2026-08-06-host-backed-web-preferences.md)和[由插件自己拥有的设置表层](2026-08-12-plugin-owned-settings-surface.md)所记录的浏览器读取与失效机制,同时保留其中关于偏好所有权与命名空间暴露的决策。它也取代了 [DeepSeek 官方首次使用凭据配置](../feature/2026-07-30-deepseek-onboarding-credential-setup.md)中的设置直读描述;该联接的 settings 部分现在从本镜像派生。 冷启动预算由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉在两次读取:镜像在绑定时的急切读取,加上首连 reset 触发的读取——后者是有意保留的:它关闭了「文档提交落在急切 HTTP 读取与 SSE 订阅之间、其失效通知丢失」的窗口。方案最初的一次读取目标,若不接受该失效丢失窗口、或不把首次读取推迟到 SSE 流建立之后,无法达成。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 87101e680a..10e09d4a49 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.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-06-host-backed-web-preferences.md -2026-08-06-host-backed-web-preferences.md: 5d90f2be7c8b4030e9bdc00eed2769491ec009e5 -2026-08-06-host-backed-web-preferences.zh.md: c861c45bff299e06841165a2b36d0781e8f54d99 +2026-08-06-host-backed-web-preferences.md: 2e33d05417bf6c347a57b5c0b6c7281ff1392b5b +2026-08-06-host-backed-web-preferences.zh.md: 1d3518bb33d334d89408916a5f5210a45b938a01 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md index 5d90f2be7c..2e33d05417 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -12,9 +12,9 @@ The first theme implementation moved only Appearance to Host settings but awaite ## Decision -The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary. +The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy serves every registered namespace to a loopback client; field roles still redact secrets. -`dsh-client-ui-settings` provides `ctx.settingsScope.bind(spec)`, which owns one lifecycle per namespace as the browser mirror of the Host-side settings owner seam. It installs `settings/document-updated` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. +`dsh-client-ui-settings` owns one browser-wide settings describe mirror and provides `ctx.settingsScope.bind(spec)` as a per-namespace selector over it. The mirror installs `settings/document-updated` and `connection/reset` listeners before starting its background read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Each bound scope publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to, without adding a wire read or listener of its own. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. The shared read and invalidation lifecycle is specified by the later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md). User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md index c861c45bff..1d3518bb33 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -12,9 +12,9 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo ## 决策 -各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。 +各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会向回环客户端服务每一个已注册的 namespace;字段角色仍会脱敏机密值。 -`dsh-client-ui-settings` 提供 `ctx.settingsScope.bind(spec)`,为每个 namespace 持有一份生命周期,作为 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/document-updated` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。 +`dsh-client-ui-settings` 持有一个浏览器全局的 settings describe 镜像,并提供 `ctx.settingsScope.bind(spec)` 作为该镜像上的逐 namespace selector。镜像在开始后台读取之前安装 `settings/document-updated` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。每个绑定的 scope 会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式),自身不再增加协议读取或监听器。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。共享读取与失效生命周期由后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)规定。 用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 5ef2099535..d48293405c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.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-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: 823d10a723af70ec4ff51018b8b86198db0f5c29 -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 7e8d79c23c4b1489bfd818c90558f36509635486 +2026-07-30-deepseek-onboarding-credential-setup.md: 87533e7a55f9b1f05f6a4ba58c3c9888780c158c +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 575d8232b4837b57d9508ed613530606c05db1f7 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index 823d10a723..87533e7a55 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Decision -**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. +**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, the redacted namespace views held by the shared settings describe mirror, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. The later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md) owns that settings read and its invalidation ordering. **The settings shell contributes ordering, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-settings-models` registers the DeepSeek step, the preceding welcome notice, and its Models section through `slots.inject()`, so every contribution follows one client Cordis plugin's lifecycle and the dialogs cannot stack. Their common presentation is owned by the [shared-modal onboarding decision](2026-08-13-shared-modal-product-onboarding.md). diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 7e8d79c23c..575d8232b4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、共享 settings describe 镜像持有的已脱敏 namespace views 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)持有这次 settings 读取及其失效顺序。 **设置外壳只贡献排序,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-settings-models` 通过 `slots.inject()` 注册 DeepSeek 步骤、排在它之前的欢迎声明及 Models 分区,因此所有贡献都跟随同一个 client Cordis 插件的生命周期,两个弹窗也无法堆叠。它们的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.md)持有。 diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index 22d109d1d9..135ff15e23 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -35,7 +35,7 @@ afterAll(async () => { }) describe('startup RPC budget', () => { - it('keeps cold-boot settings.describe within the mirror budget', async () => { + it('keeps cold-boot settings.describe at the mirror count', async () => { page = await newEnglishPage(browser) watchConsole(page) const calls: string[] = [] @@ -49,6 +49,6 @@ describe('startup RPC budget', () => { await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) await page.waitForTimeout(3000) const describeCount = calls.filter(method => method === 'settings.describe').length - expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBeLessThanOrEqual(DESCRIBE_BUDGET) + expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET) }) }) diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index e2762f0a59..cf770151bd 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -194,7 +194,7 @@ export class AgentPresetSettingsController { /** * @param api - the agent-preset and settings wire faces (roster and default write). - * @param describeFace - the shared mirror's read-only face (writability source). + * @param describeFace - the shared mirror's describe face (writability source). */ constructor( private readonly api: IApiClient, diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 70816f483f..84a9065792 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -73,7 +73,7 @@ describe('PermissionRow', () => { settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, - } as never, + }, }) mount(controller) const button = await screen.findByRole('button', { name: 'Read Only' }) @@ -100,7 +100,7 @@ describe('PermissionRow', () => { settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, - } as never, + }, }) mount(controller) fireEvent.click(await screen.findByRole('button', { name: 'Read Only' })) @@ -124,7 +124,7 @@ describe('PermissionRow', () => { settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), mutate: vi.fn(), - } as never, + }, }) const rendered = mount(absent) await waitFor(() => { expect(rendered.container.textContent).toBe('') }) @@ -134,7 +134,7 @@ describe('PermissionRow', () => { settings: { describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })), mutate: vi.fn(), - } as never, + }, }) mount(readonly) expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true) @@ -155,7 +155,7 @@ describe('PermissionRow', () => { error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, }, }), - } as never, + }, }) mount(controller) expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true) diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index ee8c342769..4e37cbb65b 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -182,15 +182,26 @@ describe('permission settings store', () => { expect(mutate).not.toHaveBeenCalled() const thrown = permissionController({ - // Promise consumers must contain unknown rejection values from a - // transport implementation, including non-Error legacy clients. - describe: () => Promise.reject('disconnected' as never), + describe: async () => { throw 'disconnected' }, mutate, }).controller await thrown.load() expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) }) + it('hides the row in a remote browser instead of loading forever', async () => { + const describeCall = vi.fn() + const mutate = vi.fn() + const wire = { settings: { describe: describeCall, mutate } } as never + const mirror = new SettingsDescribeMirror(wire, 'memory') + const controller = new PermissionPresetSettingsController(mirror, wire, schema) + await controller.load() + expect(controller.store.getSnapshot().status).toBe('unavailable') + await controller.select('workspace-write') + expect(describeCall).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) + it('follows a mirror refresh without an own read once loaded', async () => { const describe = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('read-only', 1)] })) diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index 2e8c8eb192..c6e2109f4d 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -29,7 +29,7 @@ export class SettingsDocumentStore { /** * @param api - loopback settings wire face that opens the provider document. - * @param describeFace - the shared mirror's read-only face (`hasDocument` source). + * @param describeFace - the shared mirror's describe face (`hasDocument` source). */ constructor( private readonly api: Pick, diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 94b1f953c4..9dc6043004 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -82,7 +82,7 @@ describe('SettingsDocumentAction', () => { })), openDocument, }, - } as never) + }) render( { result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } }, })), }, - } as never) + }) render( { it('loads provider metadata and asks the settings domain to open its document', async () => { const describe = vi.fn(() => Promise.resolve(response(true))) const openDocument = vi.fn(() => Promise.resolve(opened())) - const controller = derivedDocumentStore({ settings: { describe, openDocument } } as never) + const controller = derivedDocumentStore({ settings: { describe, openDocument } }) await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', opening: false, error: null, @@ -54,7 +54,7 @@ describe('SettingsDocumentStore', () => { const openDocument = vi.fn(() => Promise.resolve(opened())) const absent = derivedDocumentStore({ settings: { describe: () => Promise.resolve(response()), openDocument }, - } as never) + }) await absent.load() await absent.open() expect(absent.store.getSnapshot().status).toBe('unavailable') @@ -62,13 +62,13 @@ describe('SettingsDocumentStore', () => { const failed = derivedDocumentStore({ settings: { describe: () => Promise.reject(new Error('offline')), openDocument }, - } as never) + }) await failed.load() expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) const rejected = derivedDocumentStore({ settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument }, - } as never) + }) await rejected.load() expect(rejected.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'provider failed', @@ -80,7 +80,7 @@ describe('SettingsDocumentStore', () => { const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) const controller = derivedDocumentStore({ settings: { describe: () => Promise.resolve(response(true)), openDocument }, - } as never) + }) await controller.load() const first = controller.open() const second = controller.open() @@ -102,7 +102,7 @@ describe('SettingsDocumentStore', () => { describe: vi.fn(() => Promise.resolve(response(true))), openDocument: () => new Promise((_, reject) => { rejectOpen = reject }), }, - } as never) + }) await controller.load() expect(controller.store.getSnapshot().status).toBe('ready') const opening = controller.open() diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 1ffd457ee8..6bb403a90f 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -99,10 +99,11 @@ export function apply(ctx: ClientContext): void { t, }) - // Pushed invalidations converge every open surface without polling: any - // settings/credentials/topology change refetches once the page loaded. The - // welcome notice follows its settings scope, so the shared mirror already - // keeps it fresh without a subscription here. + // Pushed invalidations converge every open surface without polling. The + // settingsScope injection makes ui-settings activate first, and remote + // dispatch preserves listener order; its listener therefore starts the + // mirror refresh before this store joins that refresh. The welcome notice + // follows its settings scope, so it needs no subscription here. ctx.effect(() => { const refreshModels = (): void => { refreshIfLoaded(controller) } const disposers = [ diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 59fbe5458b..5b688db919 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -1,6 +1,6 @@ /** * Models settings page store: one snapshot joining the configurable-provider - * directory (`llm.providers`), the settings namespaces (`settings.describe`), + * directory (`llm.providers`), the settings namespaces (shared settings mirror), * and the referenced credentials (`credentials.describe`). The host stays the * single fact source — every mutation writes through the wire and the page * re-renders from the next describe, pushed or refetched. @@ -116,7 +116,7 @@ export class ModelsSettingsStore { /** * @param api - the wire face (credentials/llm domains, and settings writes). - * @param describeFace - the shared mirror's read-only face (namespace views and writability). + * @param describeFace - the shared mirror's describe face (namespace views and writability). */ constructor( private readonly api: Pick, @@ -127,8 +127,9 @@ export class ModelsSettingsStore { /** * Refresh the whole page snapshot: the provider directory and the mirror's * settings answer in parallel, then one batched credential describe over - * every referenced ref. A failure keeps the last good rows and surfaces the - * error. + * every referenced ref. Provider failure or absence of an initial settings + * answer keeps the last good rows and surfaces an error; a failed settings + * refresh reuses the mirror's held view. * @returns nothing; the snapshot carries the outcome. */ async load(): Promise { @@ -145,7 +146,7 @@ export class ModelsSettingsStore { if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message) const mirrored = this.describeFace.getSnapshot() if (mirrored.view === undefined) { - throw new Error(mirrored.error ?? 'settings have not answered yet') + throw new Error(mirrored.error ?? 'settings are unavailable in this browser') } providers = providersResponse.result.value.providers writable = mirrored.view.writable diff --git a/packages/client/ui-settings-models/src/client/welcome-store.ts b/packages/client/ui-settings-models/src/client/welcome-store.ts index 9edd54a9cb..82b8e0a662 100644 --- a/packages/client/ui-settings-models/src/client/welcome-store.ts +++ b/packages/client/ui-settings-models/src/client/welcome-store.ts @@ -34,11 +34,16 @@ export function decodeWelcomeSection(section: unknown): WelcomeSection { : {} } +/* v8 ignore next 3 -- closed-union default only defends future source widening */ +function assertNever(_value: never): never { + throw new Error('unexpected welcome settings status') +} + /** Coordinates durable Host acknowledgement or a process-local remote fallback. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ - readonly store: SnapshotStore = createSnapshotStore({ - status: 'idle' as const, acknowledged: false, error: null, + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', acknowledged: false, error: null, }) private localAcknowledged = false @@ -51,10 +56,14 @@ export class WelcomeNoticeStore { */ constructor(private readonly scope: SettingsScope) {} - /** Begin following the bound scope (idempotent) and publish its current answer. */ - async load(): Promise { + /** + * Begin following the bound scope (idempotent) and publish its current answer. + * @returns settlement after the current answer is published. + */ + load(): Promise { this.following ??= this.scope.subscribe(() => { this.derive() }) this.derive() + return Promise.resolve() } /** @@ -122,7 +131,10 @@ export class WelcomeNoticeStore { state.acknowledged = acknowledged state.error = null }) + return } + /* v8 ignore next -- every current settings scope status is handled above */ + default: return assertNever(scope.status) } } } diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 65c33dd0ca..3fd154d8a0 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -18,7 +18,7 @@ import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -async function bench(isLoopback = true, settings?: object) { +async function bench(isLoopback = true, settings?: object, services: object = {}) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) @@ -29,7 +29,10 @@ async function bench(isLoopback = true, settings?: object) { // Without a settings face the mirror's reads fail and stay contained; the // Models join itself never fetches until a section actually loads. The real // ui-settings apply also provides the settingsSchema service. - ctx.provide('connection', { api: settings === undefined ? {} : { settings }, isLoopback } as never) + ctx.provide('connection', { + api: settings === undefined ? services : { ...services, settings }, + isLoopback, + } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale } } @@ -253,4 +256,49 @@ describe('pushed invalidations', () => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) }) }) + + it('joins the refreshed mirror view on a settings invalidation', async () => { + let revision = 1 + const describe = vi.fn(() => Promise.resolve({ + rpcId: `apply-models-${revision}` as never, + result: { + ok: true as const, + value: { + writable: true, + hasDocument: false, + namespaces: [{ + ns: 'llm-test', + schema: {}, + value: {}, + applies: 'live' as const, + secrets: [], + revision, + }], + }, + }, + })) + const providers = vi.fn(() => Promise.resolve({ + rpcId: 'apply-models-providers' as never, + result: { ok: true as const, value: { providers: [] } }, + })) + const b = await bench(true, { describe }, { llm: { providers } }) + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.section') + .find(candidate => candidate.options.id === 'models')! + const injected = ( + entry.inject as unknown as + () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected + )() + await injected.controller.load() + expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1) + + revision = 2 + b.ctx.remote.$dispatch('settings/document-updated', ['llm-test', revision]) + + await vi.waitFor(() => { + expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2) + }) + expect(describe).toHaveBeenCalledTimes(2) + }) }) diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 3a2164fd1c..c71457e474 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -125,8 +125,7 @@ describe('ModelsSettingsStore', () => { it('stringifies a non-Error credential transport rejection', async () => { const { face, mirror } = api({ - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario - describeCredentials: () => Promise.reject('credential transport refusal'), + describeCredentials: async () => { throw 'credential transport refusal' }, }) const store = new ModelsSettingsStore(face, settingsSchema, mirror) await expect(store.load()).resolves.toBeUndefined() @@ -223,10 +222,42 @@ describe('edge joins', () => { expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' }) }) + it('reports a terminally unavailable settings mirror precisely', async () => { + const { face } = api() + const store = new ModelsSettingsStore( + face, + settingsSchema, + new SettingsDescribeMirror(face, 'memory'), + ) + await store.load() + expect(store.store.getSnapshot()).toMatchObject({ + status: 'error', + error: 'settings are unavailable in this browser', + }) + }) + + it('reuses a held settings view after its refresh fails', async () => { + let settingsCall = 0 + const { face, mirror } = api({ + describeSettings: () => { + settingsCall += 1 + return Promise.resolve(settingsCall === 1 + ? ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }) + : fail('settings refresh down')) + }, + }) + const store = new ModelsSettingsStore(face, settingsSchema, mirror) + await store.load() + await mirror.load() + expect(mirror.getSnapshot().error).toBe('settings refresh down') + await store.load() + expect(store.store.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + expect(store.store.getSnapshot().rows).toHaveLength(4) + }) + it('stringifies a non-Error load failure', async () => { // The wire can surface non-Error throwables; the store must stringify them. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario - const { face, mirror } = api({ providers: () => Promise.reject('plain refusal') }) + const { face, mirror } = api({ providers: async () => { throw 'plain refusal' } }) const store = new ModelsSettingsStore(face, settingsSchema, mirror) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' }) diff --git a/packages/client/ui-settings-plugins/src/client/tab-store.ts b/packages/client/ui-settings-plugins/src/client/tab-store.ts index ff9d7b4b74..5cd26c69fc 100644 --- a/packages/client/ui-settings-plugins/src/client/tab-store.ts +++ b/packages/client/ui-settings-plugins/src/client/tab-store.ts @@ -49,7 +49,7 @@ export class ConfigurablePluginsTabController { private readonly unsubscribe: () => void /** - * @param describeFace - the shared mirror's read-only face; its refreshes + * @param describeFace - the shared mirror's describe face; its refreshes * (document commits, reconnects) are what keep the served set current. * @param entries - reads the cards currently registered into the section's slot. */ diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index 61dc21e287..8e570b6512 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -2,7 +2,7 @@ * Client mirror of the Host settings document: the one `settings.describe` * reader in the browser. Every settings consumer derives from this store — * per-namespace scopes through `SettingsScopeBinder.bind`, cross-namespace - * surfaces through the binder's read-only describe face — so startup cost and + * surfaces through the binder's shared describe face — so startup cost and * freshness are properties of this class, not of how many features own a * preference. The Host stays the fact source: the mirror re-reads on the * invalidations its owning plugin subscribes to and folds write answers in @@ -60,7 +60,7 @@ export interface SettingsDescribeFace { ensure(): Promise /** * Fold one write answer's namespace view into the held view without a wire - * read. + * read, invalidating any older read still in flight. * @param view - the namespace view a settings write answered with. */ acceptView(view: SettingsNamespaceView): void @@ -117,7 +117,8 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { this.rerun = true return this.inFlight } - const run = this.run() + // Own the slot before the loading publication can synchronously reenter load(). + const run = Promise.resolve().then(() => this.run()) this.inFlight = run return run } @@ -137,12 +138,15 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { /** * Fold one write answer's namespace view into the held view without a wire - * read. A no-op until a first answer exists — a write cannot precede the - * read that supplied its `expectedRevision`. + * read, and invalidate any read still in flight. With no held document, the + * answer is not published as a partial document; an in-flight read reruns so + * it cannot publish a document fetched before the write committed. * @param view - the namespace view a settings write answered with. */ acceptView(view: SettingsNamespaceView): void { const before = this.store.getSnapshot() + this.generation += 1 + if (this.inFlight !== undefined) this.rerun = true if (before.view === undefined) return const namespaces = before.view.namespaces.some(row => row.ns === view.ns) ? before.view.namespaces.map(row => row.ns === view.ns ? view : row) @@ -166,10 +170,14 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { // that gap would mark a rerun nobody reads, losing the read. try { do { - this.rerun = false - const generation = ++this.generation const before = this.store.getSnapshot() if (before.status === 'idle') this.store.set({ ...before, status: 'loading' }) + // Cleared immediately before the wire read goes out: a load() marked + // earlier (including one reentering from the loading publish above) + // is covered by this very read, while one landing after needs the + // rerun. + this.rerun = false + const generation = ++this.generation let outcome: { view: SettingsDescribeView } | { failure: string } try { const response = await this.api.settings.describe({}) @@ -179,6 +187,7 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { } catch (error) { outcome = { failure: error instanceof Error ? error.message : String(error) } } + // A write answer invalidates a document read before that write committed. if (generation !== this.generation) continue if ('view' in outcome) { this.store.set({ status: 'ready', view: outcome.view, error: null }) @@ -192,9 +201,13 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { error: outcome.failure, }) } - } while (this.rerun) + } while (this.shouldRerun()) } finally { this.inFlight = undefined } } + + private shouldRerun(): boolean { + return this.rerun + } } diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts index 972b3cea73..6439a3000e 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -33,17 +33,22 @@ function deferred() { } describe('SettingsDescribeMirror', () => { - it('folds concurrent load calls into the in-flight read plus one rerun', async () => { + it('folds loads before the wire read into it, and mid-flight loads into one rerun', async () => { const gate = deferred>() const describeCall = vi.fn() .mockReturnValueOnce(gate.promise) .mockResolvedValue(described([view('theme', 1)])) const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) const first = mirror.load() - const second = mirror.load() - const third = mirror.load() + // Issued before the wire read goes out: covered by that read, no rerun. + const early = mirror.load() + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(1) + // Issued while the read is on the wire: exactly one rerun, however many. + const mid = mirror.load() + const midToo = mirror.load() gate.resolve(described([view('theme', 0)])) - await Promise.all([first, second, third]) + await Promise.all([first, early, mid, midToo]) expect(describeCall).toHaveBeenCalledTimes(2) expect(mirror.getSnapshot().status).toBe('ready') expect(mirror.namespace('theme')?.revision).toBe(1) @@ -139,16 +144,73 @@ describe('SettingsDescribeMirror', () => { await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) }) - it('suppresses a stale answer that lost to a newer generation', async () => { + it('starts no second run for a load issued inside the loading publish', async () => { + const gate = deferred>() + const describeCall = vi.fn().mockReturnValue(gate.promise) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + let reentered = false + const unsubscribe = mirror.subscribe(() => { + if (reentered) return + reentered = true + void mirror.load() + }) + const loading = mirror.load() + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(1) + gate.resolve(described([view('theme', 1)])) + await loading + unsubscribe() + // The reentrant load folded into the first run rather than racing it. + expect(describeCall).toHaveBeenCalledTimes(1) + expect(mirror.getSnapshot().status).toBe('ready') + }) + + it('lets the first read cover a write folded inside the loading publish', async () => { + const describeCall = vi.fn().mockResolvedValue(described([view('theme', 2)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const unsubscribe = mirror.subscribe(() => { + unsubscribe() + mirror.acceptView(view('theme', 2)) + }) + + await mirror.load() + + expect(describeCall).toHaveBeenCalledTimes(1) + expect(mirror.getSnapshot().status).toBe('ready') + expect(mirror.namespace('theme')?.revision).toBe(2) + }) + + it('re-reads after a folded write invalidates an in-flight document', async () => { + const slow = deferred>() + const describeCall = vi.fn() + .mockResolvedValueOnce(described([view('theme', 4), view('locale', 1)])) + .mockReturnValueOnce(slow.promise) + .mockResolvedValueOnce(described([view('theme', 5), view('locale', 2)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.load() + expect(describeCall).toHaveBeenCalledTimes(1) + const stale = mirror.load() + await Promise.resolve() + mirror.acceptView(view('theme', 5)) + slow.resolve(described([view('theme', 4), view('locale', 2)])) + await stale + expect(describeCall).toHaveBeenCalledTimes(3) + expect(mirror.namespace('theme')?.revision).toBe(5) + expect(mirror.namespace('locale')?.revision).toBe(2) + }) + + it('re-reads after a pre-answer write invalidates the in-flight document', async () => { const slow = deferred>() const describeCall = vi.fn() .mockReturnValueOnce(slow.promise) - .mockResolvedValue(described([view('theme', 8)])) + .mockResolvedValueOnce(described([view('theme', 2)])) const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) - const first = mirror.load() - const second = mirror.load() + const loading = mirror.load() + await Promise.resolve() + mirror.acceptView(view('theme', 2)) slow.resolve(described([view('theme', 1)])) - await Promise.all([first, second]) - expect(mirror.namespace('theme')?.revision).toBe(8) + await loading + expect(describeCall).toHaveBeenCalledTimes(2) + expect(mirror.namespace('theme')?.revision).toBe(2) }) }) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index c5fb104e04..0437f602b5 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -188,6 +188,28 @@ describe('SettingsScopeController', () => { expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 }) }) + it('re-reads after a revisionless first write lands during the initial read', async () => { + const initial = deferred>() + const describeCall = vi.fn() + .mockReturnValueOnce(initial.promise) + .mockResolvedValueOnce(described({ preference: 'dark' }, 2)) + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + const loading = mirror.load() + await Promise.resolve() + + await scope.set('preference', 'dark') + initial.resolve(described({ preference: 'system' }, 1)) + await loading + + expect(mutate).toHaveBeenCalledWith({ + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + }) + expect(describeCall).toHaveBeenCalledTimes(2) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 }) + }) + it('recovers the latest rejected or thrown write from Host state', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'system' }, 2)) From c1c955bb0992c4d20b106b2ea67ca1009db34018 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:24:15 +0800 Subject: [PATCH 76/95] feat(community,infra): publish README assets through CDN --- .../2026-08-17-readme-assets-on-cdn.i18n.yaml | 6 ++++ .../2026-08-17-readme-assets-on-cdn.md | 35 +++++++++++++++++++ .../2026-08-17-readme-assets-on-cdn.zh.md | 35 +++++++++++++++++++ README.i18n.yaml | 2 +- README.zh.md | 6 ++-- .../request-response.expected.json | 2 +- 6 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md create mode 100644 .agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md diff --git a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.i18n.yaml b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.i18n.yaml new file mode 100644 index 0000000000..f77854a30a --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.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/process/2026-08-17-readme-assets-on-cdn.md +2026-08-17-readme-assets-on-cdn.md: 2cc4736224315c38be933a1b29f669b8ad29749d +2026-08-17-readme-assets-on-cdn.zh.md: 04fe1650c11ee723b8915e88484960bd13e2205e diff --git a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md new file mode 100644 index 0000000000..2cc4736224 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md @@ -0,0 +1,35 @@ +# Agent Note: README assets publish from a dedicated repository + +Status: implemented + +English | [中文](2026-08-17-readme-assets-on-cdn.zh.md) + +## Problem + +The public Chinese README embeds three community QR codes. Repository-relative images make each replacement depend on a source change and the separate public-repository publication flow, even though the image bytes do not change product code or documentation text. + +The images need stable public URLs while their source bytes, publication credentials, cache behavior, and update history remain explicit and reviewable. + +## Decision + +The README references fixed URLs under `https://cdn.deepseek.com/harness/readme/`. The private [`deepseek-harness/readme-cdn-assets`](https://github.com/deepseek-harness/readme-cdn-assets) repository owns the three allowlisted PNG files, their tests, and their publication code. A push to its `master` branch runs `publish.yml`, which installs the pinned Huawei OBS SDK, tests `scripts/upload.mjs`, and publishes the images. + +The uploader accepts only the three README filenames, verifies each source is a PNG file, and uploads it to `dp-cdn-deepseek/harness/readme/` with `Content-Type: image/png` and `Cache-Control: no-store`. It checks the OBS response status, reports the resulting public URL, and closes the client on both success and failure. Repository Actions Secrets supply `OBS_DSH_README_ACCESS_KEY_ID` and `OBS_DSH_README_SECRET_ACCESS_KEY`; the OBS identity needs write access only to that object prefix. + +The assets repository history is the update ledger and rollback source. The public README keeps the same URLs across image replacements, so ordinary image updates do not require a product-repository change or a public-repository synchronization. + +## Alternatives considered + +**Keep repository-relative images on `master`.** This preserves GitHub as the only image host, but every operational QR-code replacement remains coupled to the code review and public-repository publication path. + +**Keep a long-lived assets branch in the product repository.** A branch avoids product `master` changes, but it leaves image ownership, OBS credentials, and publication workflow attached to the product repository and its repository-wide automation. A dedicated repository gives that operational source one default branch and one narrow responsibility. + +**Use content-addressed CDN object names.** Immutable objects avoid stale caches, but each image replacement must also change the README URL, which removes the independent update path this workflow exists to provide. + +**Allow the uploader to publish arbitrary paths.** A generic uploader could serve future assets without code changes, but the same credentials could then overwrite unrelated CDN objects. The fixed allowlist keeps this publication job limited to the README images it owns. + +## Consequences + +Community QR codes can change through one assets-repository push while the public README remains unchanged. The product repository carries no OBS dependency or credential, uploads retain an auditable git source, and both the object metadata and CDN route prevent stale cached QR codes. + +The README depends on the public CDN and GitHub's image proxy, while publication depends on a second private repository and its two Actions Secrets. `no-store` gives up edge and browser caching for these small files. diff --git a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md new file mode 100644 index 0000000000..04fe1650c1 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md @@ -0,0 +1,35 @@ +# Agent Note: README 资产通过专用仓库发布 + +Status: implemented + +[English](2026-08-17-readme-assets-on-cdn.md) | 中文 + +## 问题 + +公开中文 README 嵌入了 3 张社区二维码。使用仓库相对路径时,每次替换都依赖源码变更以及独立的公开仓库发布流程,即使图片字节并未改变产品代码或文档文字。 + +这些图片需要稳定的公开 URL,同时必须明确并可评审地保存源文件字节、发布凭证、缓存行为和更新历史。 + +## 决策 + +README 引用 `https://cdn.deepseek.com/harness/readme/` 下的固定 URL。私有仓库 [`deepseek-harness/readme-cdn-assets`](https://github.com/deepseek-harness/readme-cdn-assets) 负责管理 3 张允许发布的 PNG 文件、相应测试和发布代码。向该仓库的 `master` 分支 push 会运行 `publish.yml`,安装固定版本的华为云 OBS SDK、测试 `scripts/upload.mjs` 并发布图片。 + +上传脚本只接受 3 个 README 图片文件名,验证每个源文件均为 PNG,并以 `Content-Type: image/png` 和 `Cache-Control: no-store` 上传到 `dp-cdn-deepseek/harness/readme/`。脚本检查 OBS 响应状态、报告对应公开 URL,并在成功或失败后关闭客户端。仓库级 GitHub Actions Secret 提供 `OBS_DSH_README_ACCESS_KEY_ID` 和 `OBS_DSH_README_SECRET_ACCESS_KEY`;OBS 身份只需拥有该对象前缀的写权限。 + +资产仓库历史充当更新记录和回滚真源。图片替换后,公开 README 继续使用相同 URL,因此常规图片更新无需修改产品仓库或同步公开仓库。 + +## 曾考虑的替代方案 + +**继续在 `master` 上使用仓库相对图片。**这种做法只使用 GitHub 托管图片,但每次运营二维码替换仍与代码评审和公开仓库发布流程耦合。 + +**在产品仓库中保留长期资产分支。**资产分支可以避免修改产品 `master`,但图片所有权、OBS 凭证和发布工作流仍依附于产品仓库及其全仓自动化。专用仓库为这项运营资源提供单一默认分支和单一职责。 + +**使用内容寻址的 CDN 对象名。**不可变对象不会产生陈旧缓存,但每次替换图片还必须修改 README URL,无法提供此工作流所需的独立更新路径。 + +**允许上传脚本发布任意路径。**通用上传脚本可以在不改代码的情况下支持未来资产,但同一组凭证也能覆盖无关 CDN 对象。固定允许列表将发布任务限制在它负责的 README 图片内。 + +## 后果 + +社区二维码可以通过一次资产仓库 push 更新,公开 README 无需改变。产品仓库不携带 OBS 依赖或凭证;上传内容保留可审计的 git 真源;对象元数据和 CDN 路由均会阻止二维码使用陈旧缓存。 + +README 依赖公开 CDN 和 GitHub 图片代理,发布流程则依赖另一个私有仓库及其 2 个 GitHub Actions Secret。`no-store` 为这些小文件放弃边缘节点和浏览器缓存。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 47ae1591e8..8daa789977 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md README.md: 8a4bd01332a23ce4144c661784bc549e0ba72d21 -README.zh.md: b7bc214bfb1fd8a76a47de3f0aa242122aeb7603 +README.zh.md: c507bf884bd426feead6a96adbdb5c136456e3b5 diff --git a/README.zh.md b/README.zh.md index b7bc214bfb..c507bf884b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -50,9 +50,9 @@ pnpm dsh web - DeepSeek Harness 企微小助手二维码 - DeepSeek Harness 入群问卷二维码 - DeepSeek Harness 团队微信公众号二维码 + DeepSeek Harness 企微小助手二维码 + DeepSeek Harness 入群问卷二维码 + DeepSeek Harness 团队微信公众号二维码 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 286c07b62a..ddffa0c246 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -12,7 +12,7 @@ }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会启动 Web UI,默认地址为 `http://127.0.0.1:3080`。详见 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    企微小助手入群问卷微信公众号
    \"DeepSeek\"DeepSeek\"DeepSeek
    \n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.md)与[架构文档](docs/architecture.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会启动 Web UI,默认地址为 `http://127.0.0.1:3080`。详见 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    企微小助手入群问卷微信公众号
    \"DeepSeek\"DeepSeek\"DeepSeek
    \n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.md)与[架构文档](docs/architecture.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" }, { "role": "user", From 49b48369dbd61e9889012f0b683c2da63d5d3019 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:55:20 +0800 Subject: [PATCH 77/95] fix(community,llm): remove duplicate README assets --- .../2026-08-17-readme-assets-on-cdn.i18n.yaml | 4 ++-- .../process/2026-08-17-readme-assets-on-cdn.md | 4 ++-- .../2026-08-17-readme-assets-on-cdn.zh.md | 4 ++-- assets/community-wechat-official-account.png | Bin 35586 -> 0 bytes assets/community-wecom-assistant.png | Bin 60324 -> 0 bytes .../llm/llm-pi-ai/tests/fixtures/qr-code.png | Bin .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) delete mode 100644 assets/community-wechat-official-account.png delete mode 100644 assets/community-wecom-assistant.png rename assets/community-wecom-survey.png => packages/llm/llm-pi-ai/tests/fixtures/qr-code.png (100%) diff --git a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.i18n.yaml b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.i18n.yaml index f77854a30a..61a6e6de7c 100644 --- a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.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-17-readme-assets-on-cdn.md -2026-08-17-readme-assets-on-cdn.md: 2cc4736224315c38be933a1b29f669b8ad29749d -2026-08-17-readme-assets-on-cdn.zh.md: 04fe1650c11ee723b8915e88484960bd13e2205e +2026-08-17-readme-assets-on-cdn.md: 8918e2118123382553e1ea295351482030e0e640 +2026-08-17-readme-assets-on-cdn.zh.md: a1314068ff0bbebfef5bb0053f04aebe650f6101 diff --git a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md index 2cc4736224..8918e21181 100644 --- a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md +++ b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md @@ -16,7 +16,7 @@ The README references fixed URLs under `https://cdn.deepseek.com/harness/readme/ The uploader accepts only the three README filenames, verifies each source is a PNG file, and uploads it to `dp-cdn-deepseek/harness/readme/` with `Content-Type: image/png` and `Cache-Control: no-store`. It checks the OBS response status, reports the resulting public URL, and closes the client on both success and failure. Repository Actions Secrets supply `OBS_DSH_README_ACCESS_KEY_ID` and `OBS_DSH_README_SECRET_ACCESS_KEY`; the OBS identity needs write access only to that object prefix. -The assets repository history is the update ledger and rollback source. The public README keeps the same URLs across image replacements, so ordinary image updates do not require a product-repository change or a public-repository synchronization. +The assets repository provides the update history and rollback source. The public README keeps the same URLs across image replacements, so ordinary image updates do not require a product-repository change or a public-repository synchronization. ## Alternatives considered @@ -30,6 +30,6 @@ The assets repository history is the update ledger and rollback source. The publ ## Consequences -Community QR codes can change through one assets-repository push while the public README remains unchanged. The product repository carries no OBS dependency or credential, uploads retain an auditable git source, and both the object metadata and CDN route prevent stale cached QR codes. +Community QR codes can change through one assets-repository push while the public README remains unchanged. The product repository carries no OBS dependency or credential, uploads retain an auditable git source, and CDN responses carry `Cache-Control: no-store`. The README depends on the public CDN and GitHub's image proxy, while publication depends on a second private repository and its two Actions Secrets. `no-store` gives up edge and browser caching for these small files. diff --git a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md index 04fe1650c1..a1314068ff 100644 --- a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md +++ b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md @@ -16,7 +16,7 @@ README 引用 `https://cdn.deepseek.com/harness/readme/` 下的固定 URL。私 上传脚本只接受 3 个 README 图片文件名,验证每个源文件均为 PNG,并以 `Content-Type: image/png` 和 `Cache-Control: no-store` 上传到 `dp-cdn-deepseek/harness/readme/`。脚本检查 OBS 响应状态、报告对应公开 URL,并在成功或失败后关闭客户端。仓库级 GitHub Actions Secret 提供 `OBS_DSH_README_ACCESS_KEY_ID` 和 `OBS_DSH_README_SECRET_ACCESS_KEY`;OBS 身份只需拥有该对象前缀的写权限。 -资产仓库历史充当更新记录和回滚真源。图片替换后,公开 README 继续使用相同 URL,因此常规图片更新无需修改产品仓库或同步公开仓库。 +资产仓库提供更新记录和回滚真源。图片替换后,公开 README 继续使用相同 URL,因此常规图片更新无需修改产品仓库或同步公开仓库。 ## 曾考虑的替代方案 @@ -30,6 +30,6 @@ README 引用 `https://cdn.deepseek.com/harness/readme/` 下的固定 URL。私 ## 后果 -社区二维码可以通过一次资产仓库 push 更新,公开 README 无需改变。产品仓库不携带 OBS 依赖或凭证;上传内容保留可审计的 git 真源;对象元数据和 CDN 路由均会阻止二维码使用陈旧缓存。 +社区二维码可以通过一次资产仓库 push 更新,公开 README 无需改变。产品仓库不携带 OBS 依赖或凭证;上传内容保留可审计的 git 真源;CDN 响应携带 `Cache-Control: no-store`。 README 依赖公开 CDN 和 GitHub 图片代理,发布流程则依赖另一个私有仓库及其 2 个 GitHub Actions Secret。`no-store` 为这些小文件放弃边缘节点和浏览器缓存。 diff --git a/assets/community-wechat-official-account.png b/assets/community-wechat-official-account.png deleted file mode 100644 index 222a051dc89dfcecd4d80d8992ded2b9108ed4f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35586 zcmW(-1yGb-7hbv%>F(}WLb|)VyFoytM7kR(>F!P`=}t*0K^j3o8Y$tw-@n5QJAzy9 zx#v7}h*npX!$2iQg+L$}3i8sL5C{yo3j;xh1>Y91#JC`km~jPZ32mRX({Aq^T$@%= z(+!i{uM9}s=$yzQjPknLA*hH$C#HJZ@V8lBdQ-+3SpN?4Lp zp|s;4Y%_8MBR46KX2@rZZoE7p>;E>MLuWX(OmtjDx8KZI#SB|E>a!BZ8?xf>FJEwF zDqr}lTXQqTU?+~U;VTJ~C(0Jz@fhPLj!xV17)KC{T5@#-{X`YL@F9J6;>)HWltiCp+_EQw}T+^nDU5hoT%HA-)=+9t~je7^S)CPxkLqYghnC*mG* z{IYiH<>w(G(GT8rW4A-V)<4&deS96ATvdiHW?`4pwJ)=HM`FkiS4nc@jfS=C>Bozz zJfsPp$4kSz@9^;L1xTNHNfkKB8;+aR1V}L>2RV5Thj-HY&9T@=@F-BjrL(Ds zuh#Lem@!82Ux(IlDq{?&-o9PjudjY$b#t9hK_7{`WV<4MerlbwY~0$$eEBZ4zwP2? zgKCoMhUw5sj6rVCYh1AW`+7s-#0WoZ>`tri?oBukM!f!Az2XM$yD8Oy*m(t}Z=4VG zcXM5u9%>i92aio6;_gznme;=BWHLDyD36~+vP@>kRs6MR-Y<^XST0nwrIhioc_F+# zt6cNChn+R5XLz$kvq`@lYMND5W&T;ZSR)R%5MyxU6-Q|PHs;aL?)lu}+wf)ic7?(1 zP$g~m?)JT{>AC#=4a2y!egpvy-fR@FblWR%xS;AM%3OK2=QTp(3(D*I!=I~svm48( z0_dD)O2<6pVEJRVkei;pscWK`=KSeqfDQ2XCL~NGNN^bRr!#4 zYq{IB{?2}`fiHU+C6uj&WyN&s!e^-bu=KS(C)+CS(0Gn5u}A@tc?7amy=gaRo)5Kd z`KohWWu?Jaui7WZFuk)MZD(`Ea;IKG2`%H+hF=`N|JnHZ?wt|q=g!VfamqehFB6lE zo#EKIxP;MNudn|iDFWnw?WePw7=ojLjyPB0DM0!w?_=GxZ3-)#Pq1pP;LXWu3b}E= zd4tn(oh;v+JniMGT|OQjo`o+veyok1-76yQu(bgCl*+Cyzkd%mP|u1F39F}G$cs&+ zJM*PVEX>Ry{RKaXgXBJT{{+R)VAE!CzSZB+(czYwTYv*vMQU0anXnJmKaqgf$`Y+C z>+eR%E^uB9nr$&|2DazGQQ(Z1Q54o2D8zt(uLkD4*|{I25@yr-qEu5Mh~{~=H}EB_5>ZCO_vW`uFLHgi{ajH>L-Xg)pXWJSXjdN2 z2VX6?gc0?EAE$6m|LM9?mC>ltWr=T=$k9s+8=+*)u*Fw*zcpaRU#X3^!>KHN9WP6U zi9~vB`J~Eo#`aD`OF;A8LI{Cdhw;c@0`=37k7aBt0{N+%05rX2)j0xLQ`5BFG%N(QO#;vPg?PJK2K{*y;6Ycm?nGh|s2w_9(OX!{24#U6i)kj_Q zOg#v?4Z(5BDqECsGP~LDL`S!Ult;Z!r8}bZhUBn$T*$(7X6 zSrWHJZSvsWNONd`)VCPc`uI!;;g4jU8r#3x&BN$Fgl-o(|Fd#i@(D`weStP+pT|0C zHjAkQQZ{?Z3zNIr+nYCPIChmaAm*r+4_m^dW=l5U?Pv|DSy@?md9@OL#`C6$94sWV zkXGhKyvxqc9+ry@t*WXLfRZQ5DPJ*!e<&OY{kaUfJQYLAb5iUTum5@FMMbKk-=D1M zgBj6B=cD;jtXOEzPQL{kBV&f}ho))U$%6f>Ut%x!D~(nSDE%l4>+8br52nh>=nsfU zjC1n+Zw@lktK_iziTZ;U`Zq$0B7WjY@;zt?C zZ#$K7H6uo0S-jHtO8C4FG~jo>nbWqtn_}Q`J39qYrv==$`u_dhRNEZ2ZGOAiL$-t+ zz>gmrPt5y!Czb;mcq>gpIF!Jv{Q4Ht4gcoz8bm`b9nt-Uxu zKOpJ38Swa4&_exU_wS?87e|EJvBgh*m*Whw4-Vqr`ai#Fd_9fy z#dR}}%p;kNR1`lJp*7E8p^WHc{J%-1OphLhOF)1G3!`w&7tVRezv%}@P@uB1xCl>^ zvG(g#;2oR?O*r<;AMaizLG-Ho;N~BWUo7v2qh??bZB$iNM^eb_Nw#i{wX{Ne;fsY= z$LMnl+#5C=UqiGkeqaTU8b1%`FYs4!K;*SPKT#Fgvslxt65lChps3RcdlP!_+({HAFQvh zS5-0V=%h^GNVVe(G>urTJ2tf<9cZN(LeQ0JtT9C<4n9xY7JrXpih~J-2~~mp!}`tS z0S~z)R3$OHZEp@8Rkj#SAd`~^^&^IieM}x4{@eLsdP#JPw|yUff6r<}Za-}8UmnI@ zm_nS`B7|b5_Ib+pDB5E{x?8jWf-VW$>T~kM)z&JD+=eZmZEYC^ zz1ZAl@>Bq^V9+$lWXNzuk^N~wwe<1&5rND#@^iZGhuGme)=$Ztmr*}zGtTkR9FH*B z#;F>j90wo}f9RSgw8w!vv%!VlA|ssrTZwAWB4$cS$Ymu*klA1zhc_b_7S_x0VL^R_ zpJ+X{?L27Rs2#Jg^mA*7n)$h3rfi!D>7;P^e`+-cdz6-1Da>!V0+5Jc-)}y9gJwSVZ^A;y|m|iWf%lpifILu3c^!=}I;f^j`AEIxAzB`&US&!EkG;8a`OH)TV zcWg(HxO5)TM_~`d#mN@`d;E*ki{;JRyy|z>{rAs$2TrqG0X#1;tPwVN*lH zhbf*J`?Y`0SEkLj)8HJ>!35O{|GQoQbD>zBsO`tUgKXCaKhbM%9i0sT(ft2zlp82B zn7x{<%jB`E({CJb{Gyrtm>~3rk&&_Bi7}w}<@Sebc_My3LgN@1n44hgFucbo>Ef-< zR5{BR=0i(C8MyuU_fKh{dxtR^KC9c;4HVa%zmIm)S;JM;-voB!l-N6@U5jSd&(qBU zI{Y8KRFonFA@}$9RwHp-^A)8^>CgYp0_}QBq7gh|IeP9ay;g5ee=w-!u_i<%Swdwr zbZZT7j>_|!#6+I&cDq0RE$h9w_iNkmGE!1f8fe`+IQR)(Q)YN~PC?qas~;?*SbFcQ z2@eZPbz>s{#`c|wghrtKX2GG@@T0>iDMemHoZAbF>vPK8+nA)iHfQzYtc=ug-6@#g z;vlq|o}MOrt5is0!cNeNkX|?QE3^*6-{;K7&&TK6{ZpJ$oMK3qENoytEb2KQC3P#T zAA=+o``yTvbuz@aR=3);7N^sH>Zs(IF}B@7mfYZzDNfl4cRkh+d@8j8g)H;ho&S-B@)AByL0f*|_5%#pjfz|vqPc;s1S1Pr+e z26)YfC@y^BoWev^ff-H%*+K5RJm`*eu(7?z_VRuQ+LNG@`@|7}${3kw_|DTgQap9H}>kC334V<3h3&GFx10<*C;#*A5x?(6vru$Cx;)cZ$&=d4*69LTcU(Z_C9W$JwGh>Mhsf$&v54d>3w+?`qLiTq{S4& z?XupklFb)v#|l$^{b%Fj@lvfl6PWE^H=h6YzW5J>p%7KthyOU(+w;4c=D(wFU~PN* z!|P(0fFZo&p%>xP#I_N*nV8fPQtH)IRG9bH)YL!;IhkTZxoBEi37o7Q= zbo&@b?88?u;DRCXEs{#Lx-4%gd$L!ZWyz`TK9@xwx_9ig^WU-4`}5$Tgh!d7hOw;R zDG;@cvju_H^7r?5G)d2qZk7t4-?N2MBjbsxLZ{HSA1ZD0`mC+*f2s1-W@lwhOiU2o zb@cQ+*ED}ClAx{r4uHR@h6W4?b3ixf*l*sv@o3CcE(0aS$r--ka<){P-5#K?2jSWs zi6`NjqI(1N6V1;!6gX{JFn8ZYnQgQh1w}Qds*YRQ+4*0S9(pO-g-MKPgdl;lr093u z`u2zU)^&o*xro>4TAD#I;C$Cd3k+gnV&O3j>WqtpQjvS>+M&Gt<|IKQ614ZP{FG{N zcr|g*^;B~NUVD>yVDY=GBWIJwaA<35`AIO;NQw)%iABVt*!W|m^TkF3A2DuFvVClud+sqTH+~rdEExXAr-9g zVn+uRO=a;qL8hcge5QGqA$gg6=QDxNJT*EoqVpbN=Mm=dV`j;Q95F@88JxirzX&T) z%EE^)MHe3nc2HFHG8z>Y;bV=G1mXj961YzaTAp(&h2`UL9LXB3OwflJ#q{8QYxwtD zj5)J)Y+Hvp$}}5sQjXw4r8$tzqTl?>!V(b~+ZeZVRn1IPmv-Yqi9(kSp5I##e(i+# zi8CrPMJ!7ek7)|gwP@LzX5OVnUPW_I;dPn2bMg~Y%PH=ttkU<{4m+Dsl+nd49~L+N&bfe4?>oGw zqNSE4o~Nzez<7>T)lXKKM+LPg)p2zhjm8<>lJAlEEVh=4+R@WRfnKf>*HBy zI%&8H_Sk=zGVAQ$%qd96Y}iqmtcl;j=!IWSTADSoWMyZsreR=U0BYzP7Kx%)d7z}J zbTo96XbL4FHTG?_m+i9%FTYJTFfd@S$dahh<=HCxUD(d&`tu1$Dfj=LFqjRLN+Hyg}EZ&$49dmfk{_xIDyo&g(gGy{)uAII}e9bTIFhswv>lQ&)``W=mE z+qds_M6?{x{!GCf?78v=tS>Vq>1G*DnjX{uHJk zX$1I9AHt-6Z`=Ig;rfVSbV&|I=540-IEs(EDN7+qR)}Ny<>Y`dt1Cj56EwK*T%7E6 zJK>cyU=Fns6ZyMW_gjkaG*Bqi+Qx<#3N_cTffC>Nb|cz_Zk|PwXo{FeyI1#Dx?02c z=M3tGioq>mhFX1JUM9fDx2!SI(b=LZ3!hV%d(lEH$0<~fGf?g3QB3RFSAcwQT57Q6 z6PJd2B76=#6iBlly$unGA!0{vEWQtO3M%`w)$0~T#Y-(bIo2p^qFjbse}qSi#}93? zDezX}#hU#yfG&7|a}gBI#%+LiVcbx|^RFjV0xL*%Wx zlOc5QkevSY4lj0}4V7UlV%&`Lw4qOn1Kp4V;%>l(dKB% zKOZJ8)-yJ6b%wck)Hos_bofUSbR~z}A!3Si1r2{=H7GfQDn2fr`4JGGj4 z2XCq5XC=?W5RJ~~l-FaX_0Y+RI`O&hxKSwDsn#iQU3nQ;LW~K+dAn%Y<_UUT{E&j* zkGQnKd3U%gve*V}!vTUAN1#@68+;!|$i4mDdSD_+VRCW!V=_Z*eDKtBu4XUX+U|=L}^DOxt zz1ovvgvXJ(%FNU*_TI(nX`})MSq|K(}s1=7J_?9ldus%iAn37nMQ5f~&-0b&Br+80)Gq&KscWYe6QdB{Ld<={(Kd;4-|rRJ57eRft)d8(7xeza3Ns!ZLU8?l z5Q9I8KZn~U<|*RR9&g_z_!lN2CnPEQvUMF0jiYuECk*{S8_z1sn6q-Kps!KNf7BzfCn)64`KW>DbzYosFX-ZZPC8;aXGGO&6 zAt_|MW=Y(hHK$IY7?wK@Z@4blD00L3<|#sENzXdzDD+w}UcbzG6(7owW82BU`+Lqe z3neQVp~}?weyCela7DVa0 z*J-&Ink`tIHTWTMA@$D#%Ju>>uFQE{mpo)GS$#l|iIg`&>xhZ@)d#EU!p2l$Q3Fx< zVM0dzq3YvPZiW5qck#j0z!b62*X75_x$v@;MpUz=y?y*w(;z~KuBoJu?AyHR+`^sv zIWo2^A-O7qZjWv+H#%jWa$CGfk+WqX4$TH`m!WojR3|B^BiMV?G9yM&KRi;cN>L(zJ5>L@@+k6kK2O8P9Pxgb9l#YuP8Z5ds zLEVZo>|gX*Ra8{|ioHR9MYOcp=eid`f<)W4vk>3^1jBv^Z@LqIvb&DLrEYv$cUk?=cUas}A zeLC0D)P(g`V$MOexxDas0+b#9ti$m;bEZ0--N+$ zgVKz>2xvId#0cTK2Y}H7)7ZqwdoV z>-9vZx71|CzhHZ3DsGwMB*=Bsb&Kmrsh-NId4!EK%x9FnMwHnVoYHUkR1-y4OqeW$ zep!BqV#A=6{;7eD&{C3%uwygln)1`YfN~ zFo@Kxr$)4)^zC@z@QxEtl{)^Wb>b4Kb7)J6ui3cscM{w7qkoxEDfTCGF0XkAlM7!T z88ps*PEsF3k)rEPTEkXk6t;;XQjL47A>v_JJJrrl>z4(+{@f7Dn<+AEPwAelh^;J8 zDAVE4c$kezrDPTDF3XgPq>sTOmF*4MXt-Lw;UTnf}+ly%RONx>!5H!T$3h2 z^XXJN>zas(k#u3DrA)d}!`CB&D_9)D0R)(!g{D>KzK|#yz%KPkG1P5}Fd}PKQ4(#_ zCuK&ly8|xL0!IZg+08D6qbof1o)*$mTh zNZ&rYpEYLTsO870ejy?a^4dtt49v$k44_UZr2P~m#RO(q+mAUccfs6XV7noU-;z0o zg!WT<&}dr0!@{8F&F7(qOA?mIo>P<4GvVs&m~RJ=#SRzPB@4hz*-&45vG)o-M5@18oBvz@xzwsroKnta)sy|$f7v& zz+nF&ul=rbsA5m z!@`WHYh86F;d5@^iD9w7H-ZwAaaj(r)OW$^+(wOzPfkj`s67{W|NMYdTA@83J7I3=seLlcdStu80y#!}CTjV3(2z80CSojWx_b?Balc{F!gRLtE-QJP2=Uo_s>Q}CEn-6*3K@r-b7uU@`1$C;DPBW zW7_te@$cWihqmIDkHdo;99^8iuwHe&yu7^qv+-kgwhuF=&g3ichbybm1TxUmLQUq@ z*ZmG>sV|;FXIG9J@82hHBjKO3Py9ei95jGZlNkYCXHkS1rlkuoL`YgAr#{@ab2(4?o zQY><-I;;(K3aOQ$h&l>qoZopjRH5J~C%woXQ*tc9y$=_mS%R3tyCSG?D6So4fo_m# zNV4)nc|*z-hLaNGJ$wA1v%R>&*_?7{9zK#4MGQmGYc*|LTDt{9*3;29q35F7TSFgW z&+o#nbW6R<6^IB?q+d(C3;2Q*pUZ@qJLyI*5f?;tyy0atje< znuDqHhA7LW1S!65xTy2V#_jMJ%2cIU?%kkAM2Pc5gi~rHP$HZ(l(-te^sG9UwPIi| zExe)E*bF{%%sa0tVsvTy>blv(nsMm$b#h`tvJgvCdexcF!*U2CLL(x_bIAZpE@g}) zVsJZwOen-0rL}+Cja1Mh3XfvMvJn76JY3wM_eBhtM6tuDsHn8#=DusXdU~!uyE6G^ zx820yGEcz>$x2Hb=Jp5Y4+#BZl(t?k&;N|tT*4bFhen$9S%HQN$H?+Le(Lr2XaOl3 z(Nv<%WdoZ$;=CLf_mECiOof`t%EA)7bJpI?UY89))~w(+ph6*vY$!w;UpyArvokQ zjmIID7o`_{Du?BZ+_)TOE!+Ui;s%x&>px|=$misPF?!V;M#rZDN%Xhh00DHH=(hoa zEtaljkB~pL@P4uqyTm^E>_GIeD@G!A{qi={Cf zw2V(Q{eXekT#1PwqHxZ*s0A&2rdUJtwcz-iVcp@;J&5WJQze`9j@?iC_rw`1CVUrP zo>fg|<(qWNR-O7NbgP%F^_I&CSC84sml!9hW@%Y#qaq^)8P0;}Ql`Sb=*FQV7CrYR z5B%ZS{aLaIfqCgib#~@y2ezI>MnqQZk>bH=H`c7dI13@e-t>!h^#Nv{ZnRIKNwsvl z-_f$>2hic@EoBcO<8TGO7lMKq_8a+O0cbQIanD0w(D_^q*o?3jqq5rhRiZM&wnxoV z<2&!sH>rcY#!G+FS@o3|(0*?$(kI+-m>+73_GQbeU=D>bILMywR>#4#hW}m8kL8OO zxAU2yz7zK%0iCF_BdIaiN*4o7$vj9&vZp#mbN3KCD2;Wy!1_*e+RdWaP_XkBn8IBDErq4}~I|gogJb zWr*%yj}X~iAI`z?M9fYBzf_DA6HXaJ&g=ei zpO=^Sl7^i;@z=pY0wGt(+Ipe7J%|M)udukh(5D=MV<_ythb(gvE=eVBlv8r@QJCE9 zi(}f|N5*5QsbA)Ky^!}G+6-tejXo({RwGf% zpb@>uz`f0nuIBRB+XhP0;lV>F54hn(9=m_*#tiyHXjdqDA^u~T2f zo%r0WOMimjJ`Rw3!*~HHdE%&qyDY5tcdUsKS<4vQTL(vb#ZP#Mc%rQMv7@^ekN*~v zkZ__Re5HS(yZvHx!W5$T4Yj?k8A>*2SlSxygO_9@Qie#ps4p0B^~u*%G3z5U5iHO5 zhqzq(2)pT9%npK#Os%em10)AMjXVnikH1qq@gYl&6hX|2drqJhQ>0Rw>E3Y9Y#2gvAASpOU`MNvq$xQp3^g}hBr}F z?CLDVSB?GwVk8=iz?F(w`cJWax5<)4B%wR-KB=k-B|?c1vG1>S=?5a*@s!y)x>*#y zitdy(rC84qUk!O0d!FtU)d#W(-Pepdd>Jx=@ZiP@v;ZGd+e$N? z7)d?=d3TS?9la^4XHR=>k?1u6p5X?!DdjHeh%7bLg-=&wBfvmd$b^3IQ=Wp@+irx` zpLzR}D^F`w%e9<&CDAJ1X65GQh72@ICkW-L`TL8cv=X40n(tOX>;tSJ7kena<+nv7IVt6q{2I4(RMHtOg3{$`3EPtfS#d%GqKz}nQVABY%GVnu%p{c)PdX%RBIn{+kz z8)F5)Fddz6*!R{yHh>zUqBa0?_jEM_^vT!ZX>k++ZZ9CLqo0t*9RM3PxD5pV*k~OU z)Sot!pJCSYc#vaj8}wPhA^c2R{;rFeg(ctbQo+|!x_D01|H0UOoX&UKVPf;$f(pOq z$qE-K=J7DLc`~Vxm-F+}J<2ut^ns6Xz|RlA{tM7xM$%A~RGEDBy6`cM9^D1G9~Fkr zK%>bgq+(lqsxj?e+tR{k)i;->0#a0Zdb-ugcOOD16=tL)`h1+w22ey81W6mGvT&GS^s?+5-{4xBr0Aw(P|^9M;GSwrvhEeS`Peihk7a+SdXro-7GOD-K( z9K|t*I2Y248(G10yiiFZv8vPGg~{ex5)RUF^I$~cvkdQEbai#5QcXaaM-TZ4Pg5&+ zKH*N&l-zDbDi6D9Fv+dc5P8j03S+)%3qwz787vG2$G2eF6eJ5heXo%z3x01Rr_Ksw z|Lw)Kq{Z`<#SgmJ&E~@I|*wv^V?+7jIEsYCWi+M(I&t{qh@Dlf~CprhIysWouT zxu*P__0OdQhShVpLO8RlFlo5Jprs%OQb??uAZ<}4`-FPSCh{@yl65xmbsSeHx%@57 zE}{E~OywIo>~8@{x=9=AsY$pJHJ922v~O9xirHDh%D#`q$PBIE*NQ0;8VnZscl^#S z5tg|+AEqI6q5)N9k6%n=d|xw4c`ZxFNGDp#cwQDsYqKfSp>zR1i+0rTbBR^2e2QOo z%4zC;YdvT}^WCL-250QTq)zGnLqe9^=MmV$rP?1LUFIN?G;RC1m!bv)is=C(90t|@ zjt5pGviH+g=&QUB2SjhsETP127s{sBJKj@?h8qQdI2#b^Sh`5|JbwBf*Xo)1>sXFk zFq(t%jE|4kejK-L2DHcyo+3u|TmH+_FEsXch(DfBq;Wx z+u~IzWDo;p9w3r{t~v;iF6p!RCKZRH5zK!<6MH&!4aBJ@edAt|G`?7+Q+;q?OVd~d zl0ctb%xBTS!=a+J5=Vca!$VY2q_W-W3r?1F_d5-9(+lHz{u}txb_ZLnS3jaF_3`-k zZ}<1_wK(?*#L88&>3ari+kPsJ5(_GZebv0l#r48bd6y*-iWsf;XYETY`Pqe!-{faD zCM8@Ju}L&AdNB%}xmNqJVy|(~5vi!7zsQT%($vR?U_fqUu46p7pe6VABJ`{(WJgn3 z;`$ttYHwcmGmoJ6eG-qh6ltLK1L~7_g6@=c^O>!U&D{IMA`N?kI9q8n@tQFo6tcR? zDLJEfJhG8p(zq5hv}5y2^PvHkw%xs8Gr?TdPy{X%u5v=Da%HWx^@6O+V(bQ56NvrL zF&hL0S0gS^fjqarv&tHS3Vww9i^;@hNk>;@PaEw_ywR&4a@R3%OyT5XK{07 zy`w~w-J^8WC>xev)I=xVIZ$M3d%kV!{l2smB3??Ho|>LsK29@|)8Qj5`fg_k#Ij&0 z#Cn7|_h!lFPQ5sc+p(f2w}<^i&1kV592^W9%=-aoDm)As=Cm*EvTcyBCzKN2;U;{G zBlpIDNxob1W714;S{!*fq2dy~Y!4M5r-znx$ouvooF#D!U-{+H6aj&Q4{-j9oFzZc9`3Fg|j<5j!dYvtLrK7`B$Lm z;|UMGk<=Em33^N5^-k~Ivh{oAk?^L>I^_Y&S1KQ4RN62AOGHri};*lnA)^oE?h2DjBjY3kwh{%B38E^rNT)S@o0t2au;v~ck zp!|WjU*jHz7u`9GKBN=Ge|}xTtWvgsdvu|=@DnqB#q8;5hZx%(?CF!{0mSgmhmI1f zgso0}T{2$??r;Q1sxI`~$F2W6t((5bY^W{TIyXRBZ81cBcmP>EG2mH$npm*rCKK`X z0OmpwlgVv5jL%SCr zt_~!oLJcPijtkr73XXX}nh*gEAEE{;_!TH#xm;F=HH;^H<$1o2-*VpAbM)C>Y)4W6 zJ;q3->hl?hM^*F{j(aB9DuCZ5(q3?B1OGNyU|Yc`2qH`pBe}Wq)Y5i9u2CKy62NV# zsF0V=*a^l0te~puDIidQ^z(bVl23JN`X0=Iq}pg7g!77usH1XX=7roESK_mlhWkU2 z)FihgvlS(c$nWXWW3gh9+&BSg8&P|Tn6zQRxMxn zbrqe>Rm&cJg2KH&xEJ~_Be~y-tML#eI0mJbXJA){OPv-5BE-dqhYWA7y0^CnjG7hc z;Z;htNs@PXdp)SlBo3aQlvov&l@89%C+i(N0t-F?0|;Z-E?*3}NZ4fQXEz2)bOiV7 zQGO^zzoc*zGhq?`rgr8dc}uYIm1vt(^pJI{bCn|}yMAYa00Ri_tknw>}gI2)d7T?H@K~gNMctn4h@3(=u(As+27Yqj^ zJ6TUX^f=33>V0LlQRqUSP<--`8%Z13jtUuI$Ql>&+)*_V2@L6!eua+R-`BT*o=S-z zG~F8IF~Ld{Twlj98=1xBn=4O5WWV43Q&dr5WlnY4{6du68;~^+8`7!PMR#GFTT&fe zRne!cW)Lr7R164s1_A@HnU`AM8cnqJeS{%m#Xc;~D>7sk*Y~YrFMA!!iH z^slS?E>l;*XNukTDukRWJ_7QyM<5jfe27$GZOzs~hJ_FX1(l^g%4zJh%60t+s^uwV z)kH8>f*fU8${^QN2JCmuId&Nhz(o;Xkt?I=f(42HUNO1IguuK1I#gGea2#wuY$?v` zI-sc%7`wsTL6UQvXt7~t=(q7J7$-m+14M={#<;Vm0Z(ix=jOHrVZ&cnc=-4+NCg?b zJW)GKkEQl&L=Lb&xHnw*uR|A(q)S?8ZB_v{1@?b)`x1c1YOpffKsy$Qt4sPZHvnW@wGX+%V&3dh)*fN3JN%Asg?2we~_^_$|p_T2M%RqFus*Q zIYM2^BROShu7f&(({o?{^1u<%Ln`cZ{`-4%t*+CE)yv{V`)-2J^ZiU9J^c6ElhyZE z2h+Y`UPso3Q`G&?g}n4deoj+M-9SoweZo0rYY37;N5jEtBvq2f z;vv?HCH>N!<*7PuDZr$^i*wQhpawJiNHkT{FK6850w->9zKaKOlGtL|f(LAI_~nJZCfQS{b9PHTim* z>-`gE&n;inA8aSkd0VGg*5R`e5xbf*@{1SF->~wNL&vqZnn51|yF9_z-K)+$_XpVy z4+b=1(*)C5wlk^zf-!jE?#&9$b5z%SgzFg1zg5oJ!xJK6!QWgtkbg1!zVX$YkB<+$Iubp!vQp{vP#BX`Gk3k` zeU8B3{1=dDl>E|msYE~Mz3IdXvT z#c;7blmR4^R^;1Lb zl$tr-`1*I|Lqz+qWnFD8kcdsCqy`r&7uH?c#;wa7tL`|YksBJL$V%!_J=@eff^mx& zpJ3LV&pGy;xb!nDjoX8c6b786(%viuLyYJ%*irH)>0ea~Blf~J1O>6p3QMY$qDXW3 zUoH(y6@+(S&yXlf4Mj1e5fsx^jqr=Xq}hPnXIdIB7#X7pI()pma134&5&^2JF2GkZ zFjxgG*W>z7Bfp-GTu)I+3FI+i=hZHJrYsvz0VM*0vS|KGwySO&5s$3KREASI4_@^z zPPK9r`tShr@DAJ`A=m;;N?_6Jd21y&SAiulrF0|JTZ@zUwe1*6KbEcI&;@1Ex0bOt`b$x0&_MlIH& z7LaG!!%xJ{IojYNY35RJ9Lo zkIitj{2-%GVZx7PQ8d<2p0l4idnHZX=wn=2Q{(ri<5v%u4n{5=HixrU5;RClI>O{H zf4-jm$8o_ni1pYfFztS;XfC0k^A0eENm7}k77n-9m@tkMdt>ilOS0l4s5+sJ&66R0 zV~4<+oclgw9TSg&SMcr zbdv;@et$@X?Hrbqkl_nJx4DB3d&W%>mWwge;5 zRCE3fSTtcJTG?X5|8ChRT|8>A2)rM_)`6|E*y(u;T0)IkZ%<<*7a=;ifLj5OTR=Vl z>qbg25OuwvhzY8kPz=EqU@dT#e#s%$U>ztN=cSb;`-GuDCAn(p+|toO) zVX+93m@O?){kj<88H-QZL_D^BciWLcbkc1n0JFL*lwt4!GqNfV=vBHnCthEFO=Z$B zVTb!#$s4%0b1RNqZJo|>ih3iRbVA19dxg< znO~2YZp^TOxO1Y=Nh@xqLiIbKCD&9{5%aqk14Wxk5v0UH{^z?RM!4jK57?<)J8iwZ zi~%Hk)mjro^27IRwzhx=DwRsca%3Op3DOwkiAZIJGBU$pu~T)EqA0Az1@qY-M4G_o zBs&`G=?Q9i)fq$wGerFb^G0Ud!2aAHP_A*8%h>ZJV1MmAp8sb7o+^;WL1d}#2=ffE z>Hn?{KJ{Op|MrjyZOj6C0|ASNhQH!)JFac}&$L#TNVMC17cz0pBLEEOj=OS@D3O#v zG}SdT6Z?4U?6n#M1NYw!56w&`kiSj+Z*;mDjZo1F^y*Esc7C9Q*AZ%g?Hbb#_kEzz z2UW{u?DSSV6f-I4GvLR*!H=y4J~9YR0o+3@?8B)%l$@LlAX9o;+NyI^5ozxXz$shN z=F$>#ovY5GfLS&-H)}j35RHN>AdWaNO8ybux)W#qWCKLSv!HAz&4Dk^m0IQ8d0jud zKdNPL_K)eKlL}zL#~hXy6&1C&XFS^iBigd|7MKHeQyGPNy{e(`gAo`Dd&%1^j7&aZ zhb{}2x((aXs#f*Ov68D;sbH^8+g{`9ojq<5VH_#F!z>RbVrUT`imJg@Gej!$+s75N zBX$srxdAl<{Kn$LzZ{tvMPeL~6gR>lykU5@hE?aU-e&<1N98Z)kz(j%g1E@45w9qE zJdg1+mgelymq{>m5n+QtObwJ5Y^Magdk6|HGCU~2!EH!$WPc)W#{w}iao@?3)QA4H z>iT+uUNrye$o?`D3?u@PBvMQ{urn4AAnJ(%2=7}y5(FV>>ermX8F0mw7lS2H?-1!# z;?mT0WK>@e6DUpL7Q(BFkMQ0Gr7>|Mm1Rcae^Izrj3m+{#TaHc>GS||k1snurkWX* z73?7@U$kQ<4mMT~SvDXazA=v(#bZ3ItEk8j`3Tl(dbUE)z)61dE2kOs)&0rz!d@UU zZ!vM^1dI2bdi^JPIeX)Nuj8Cg6xESjM(d%Qd-t%a*T^Vn+=cCH=cqQ&uu~+%OUY^( z;f6q+#aRHGBG)7xKQ>Z9#NjFQ21e^*OOf!M5;JW%l3;<(Jy=f$GZpNUv3>ChcotN? z-W40NJlA^?`S$DsCFaRYKzsG$^cL*M3BNpDsg}PV`_yk*?+to>lKkD{nVu}QhKxqv z+yJk-wzda|N@0-jP3>NkP#Wd;|IR_jtS}y=-xa)ME+&vok!_|#!zuNZ3G$Itz^DGE zM_ga9<*MSxE1D(Cz$8O}p{OZ~FHNA8fTCEg{6lpWz9Ak~pW>%n^k}hB+h;N%-NGvT z#f7rrp(%Nq&m+B4R4EG4!<3@MVS^TtgJAXhd5VYU4?qlninKW`+wKgZfJ1llTZcZy zK*KVo2q;t^x_^S$2*78Wc?x#~{ESxE5fJ<`*TdL>Y`Qo%i2x(Ns?D=er2ba|yu4by zxcA5oeiw7fWMKHNIP-NeJfR)Xh-UG98t%Oq8#!!Z^hO|QEgFIg?gOcL>ZGV~YhWH< z_;!1J6~RU2<>%*zLO~Gc5S|lJeRSJSOh9%i13P|X=VF%yY=e`(akpM;ao}Hf8w4l2 zxv2@*?h@-@h1=^$(|tcp?+d_-fKUSM1*j;sfeI=js+NQjib4g4a8{WFCF7s=-2^@@ zhn;#PpEYGS+=RA^Ou=fP;V`^{CACN9s^sAWYy6G+HDD)7=`(8~jtD6xuqKjXf+cAf zKvLg*Prd?WNxOA$Q#>yE+W_!7{Qli6r=HJRHUi$`wl|iPN<>eHohLx{ytpK_?=87CfSw>kTP_1bU;!YT-LuQ zs|)kbP|Hp;X$`N%8`u+?lvw@eJ6MAoykhAO->g0>DV2%V0?p1jxyu z_Ua%UGli{xYUw4pK3A9mw3^SLWzbLMXG|aT{yTLA&jnjkN{&47|8=S&2<*sLAh$JZ z6wA_i@UY@f0wdN4YX{9S!li%zzj(JPpayL} zYDZ%dUiof^6Lfl?)d5O%6x-*K5t8Jl9rOO4*+>eUdJs2u!Rm9!HwD zlL7x8=62B!j6o*>ZSPwh{364bwZZLk@WECi7yS2!5tI}uE{=t~PVN9a+2a3|A1KQ5 zI7EjVI}E8ul~l^bE_1SKAZ z&^6P`kjx!<0Id0S1C+)p%C{zS@HRn6v$oP6iaH_FAa7p&*SA|nM&^GVU3VbWeHXv> z-dpxwac#-od+%iL5R#B&Z`qr&S9ZvFvdP{O4SN%cBC7Xz?;lTpJdNw#-}ija`K$v9 z{*Mqb0Ut`2{d+k8%uqp*<>Hkt` zS(9lTIMO84nZkVYsno18r^ZE1h-X!i_=zJ2MjkEwJ#Zz4pqo;1pBkRNH_# zcLvI%-|lp1?-vm)g$Tui*t&h!qZkKdZi`T^;I6}Y|K10F9i%!^mG6~v_{$Rvqlx!= z?Qx~e%$q?^ii;vqnOUxg>D4sw%vo2!ikmxO*%rPL%@M~&3 zU>hi%%b>2qLwA+jL~mP&9MRK5fTa68!LJH>Pt`P^xgPJ#T-X~!2 zzA>pn3q>>I1|TbZ_@ezp#bM1an;XiAbacg z&M^MVj|&`P+(-)x3zzN?<`=nL7l>C=STR`+%q})Q?O#0oP%EoJOr@N1E4Z6cM8V8Z zJ}nHhy=10x@n8bPTcYS6GlOIpZ^f#y&Cbsc_+;0OGBRitO8|KhGN+&!eRHD--=V2y|F z585gy5|-^g>?hg%W`R;Kq41!6Dw4WiJ7S2Qr-r1CcoZczaOB5&VcyjVjNKb}jJwM9 z)YCFDFa%gha-t+qK6KfYtXew0MvYK7(;FiH9Q+vznK$w2?ZfWPRL$uDQ{#o5ABl{z z!Jx&^(uMvw2R=-n&M)ex4u~gD0npCkGhg>01r3R zOM~LBVE2#7yJcl%A)i9)jZ9667-i`Ae8x8*Yy!*ziZ5?RMJ+BaJ}}TStRQ=2>(}`X zOl$y6k`+=1!*BvM)NsL*c15m=J#9oJ@N5_Q8$RnUx;e>^JT8Iyiv#OdyUQ&PKydlS znMkbHT?`BLFMJLK-YNnczW42LVwT*4!8lo=`|6utr)^Q#x8D3akLudDb0IB;iXl>b zWNON~D<}ZE$KRM@c^<&IQQh~Q5qKSzT1jEm=CuaG&x2oAdnJ2Ic>NtvHi5#|Q(Qd$ zl?3qtFii9aGY0;JTeS1{{_7lqVs(2+!SJHPr^1aK9UEJ^GB!1hUvYPF0h}CO>dbhz z*V{v9yMWM7O(Usr2k_}X(m$I!J8#_<;-n)xG)~Utm~5nYRKGlXrzoedgPNHe=kCf1(=RDk2gxBb9Pp8d*oq1xEiwkRce z3+~j90=&GuRh&5YViymn*s7i?>_WoS_8`pX*h|7)$ebzs>)VGbK#lf)&%F03@GLkx zbI4Pvnopa=PrHJ0bhzO zE2Y_y-1-0JjYWk-0@?YDrWYl!YVEW*vpv<_Uy-UN4^@%O4fg%^?Y5`7R^VoM$ATF( zr`R)_t~XjKEt$k!u3`8nb~(W8iPL}lw@QX{V`n42yO00D=wnnCeEQBD>fo0)Z}1qr zsjdd(>p8p|i?^Ql&CFto^RrJ;q=@DNAb0`ZG7Q^602~7PSJJ;Gcbwx^-v3I7; zwQR*)Ml!ZZk~{e?KI|{+UiO%nN!CL_<4W<`$h?tb?!_RP9+^hQG*5;D9RW|I+}*vBHAm8NriPzAtM_c5 zg=M3XU3?nnNCPk4)ARj>gU;hFXieadL2CibMA!L;dqqQ3Q6NnPIO!bbfd>oK3(8dY zjlh}U^BNePzY`CGA6=ZRjgD$qQlC^KWy5+`X<#5YYFR|xG;}!9p?r*AfVrQW?Gt90 zwN$C5Q1YHQJXTZNr67NiU?qC}>n5&XTguv)nxEUnG$k{&z9P%Wp6^dk9wcIMi9r;| zJO5M=Xg4-Dlk=P=zjlVX7yz%4sGj7e8?m<9QK@<&(N*UAjXOYB0(bSzfY>aZ^Go3b zLsA7$e}TJPk;J!+!3%3_+~}1zP+?>&diU-Xv?wdks)47~T`UiRf0|wNL3%D59HgT| zNM0RI*6n|~4yO?O8`qhDR)M#pK&Jxiy^HlLP*4EE5?DWse%_1A!_R;70n9bpt6~>v zWT$Nz1hvgT0?3o2<%KPuB3sH>CE8oJ;Ap&%P|o&ak{fWVs?^0$gcvM{M8J1Y2@{G1 zw)FdVpG$>EZ_su)!v)hvH-LjHehpa%10HonMYd4F8w?+ONH1}P7V2K%_%xHXe0N&! zdP=&2LqoAV>Fb5S5%#PgKy=b;)yaP|XSfrI0zB zR*Wka5f#mVS9-dOlLw4iQ#+szUJAp&gK6G-45SyEAeC4y)1*=xTm0}r2jm-osUe^8 zckC7e5(8i0%<(T%Q`7E0KR?A&-bUrc=3tRP1I@?L#p%YUX4mkEnFQjPxo6O1L0cW^ zk9~sIZplAd97)2HHz5X?DB$U2J|yHTDit#GH^?SMarFG#a*X0alRu-p_16%n63!MC z6s^$KxXs*eF)GH&^{AwSCs*ndblG433n1grk`tn?BXLT$RASpGdzhBXiP+{QeWV$d zy8HKmU2;PDKkDd;gBDFPG`kiHdcya~%!t;G#_Byl!Mxz9{- zR~FLhW_YQ}_pH=j`oH@Mc^?CwZ=avdsNM{GRP5xqHy|2lkvIuPnA6`@SGz%S35acv z;2(wo59;kk}CYdVxNUFkr1KnVZcJ4;*KAH!q$lKj+NpM-JO5@{}E9{Xt^BQ`l4RX z1CIkE>5&j=}M%#~!}SNq&Y$umZ=++?=K~6TYR3XxVGP0-&>bTV9UoUkkMy zB(y=j7*ui@VL_&8$-x?}>IqIlNNWXL{SD!lTMabbjtXm@?9w!WH4MF!y%9!RYn-es zLuR=HMv~dxki92RY_=vxWuHDz8GYAK+kJ$SlT-CSOs355$LrSel=t+>V<_VGz0a~f z8kpVjBE+W^sVprm1*-uZVi>lbG7*O;aR@giXOS7$v$L}U8hA_4P6<9Uy&dwHb-5=r zKG@Wp83)cBqMGn2ioA{&2|w7&KFsZPA@NV;-o8bM^oS(xjgIY0tFS7u)efh>SzCr#B9mP9vvV>V5S{*d6_9-O~@rZA_Z}4 zU_2jw7wJ(|S?Lerj4cz&W_H@duka_t@@`Y(12$?Z@J+{CCVOSjs9?kaoCUN{B0wtA z0?Ho%wdiDm+Q3L%%_4ZmrqK)}GM=8Ez?#8N6F7Ni$9Hdb5L)7|D7rWnh0$jZRW`Q? zmLp&YD*|usrz79@f1rK?rC*LkpcTs6p$p94@3Y=EWYFK!q~}oTr#+O77eLPJ;OtzH zAYeDC{{Ntoz^kLTDSo8E&!Jk9Q&Q%NJP0r{eg=K#amPVj>8PaZEl6Bgs50bK)^M+V z+ad*+1Q3CYa}U(qOiNT03|5}Ovs|H-(`VTMv^_MyD4HQ`BRrT~sG)LP7BGS@UAz%V7}8b#o!b`v}4spbD(JgQ1&h2$5^E zL;sQRidiwL#-b%oV{sZ;`Slr7hxGHm*HAzWlu?O|x&aFKcn-!#vsQN+DGZf~BfsON zH_`g6<(D}U-s{(wKPYZ{(;BBL+gCHIWUqM@Lt0Io)7xFl2095}#%*p6{!(x&R21ghoL?3BmncKN?xww&7Y1sp#sJsnYrck)N?ZJZveM4_M_FE_8Y%Vw!f zBQh!8VuvQ3{bG=i_Nbv6~Y&br%lF}S%#E2r9!uH z0)8u-jxgzZG<67k7wDtKj5Il#V58z zAM+AHZZ*2zY80(v=x=0@reTVweQZdgIWdt@%ik_ifKF6V#m*NdlB2*oST|$M!YaO> z!>StZU{-ZP^zDrLg98r*wXcdavk6XiAC4S>kBN0%d(V7n4Xt!5PUbz7nuoaGZ*$#Z z)oBs2Cz&6z;fYCz3&(Mc$NhvuK(XE0#Cu*-UMlu!j~NE?Tv>upn)s6)2UVxn$T* zSPiJ}{`(sao@?@5+C;#bT5gasZ~XEzI>PsWYH5}{MCOA`^c==gQcM(46hZ(1g8=8G z4)ugS3jT1Q(h+YQYI3RUzsso*MH#@S1v(FfE;N{kdtyp^g)PW5N;$I%$}f9MdkYt^ z=b|UkH})C-UAzY!Hi;1)PV_-tXC0KQ`8{CmZqFGsi{E5YpuhWfu^EuDaWdVs!b*EF>13k;J$Wry6q zgc-Mg7_wh+BX2}$5_8O)7%4tDzcs!IyENgKWsWcYce{gF<`ogC^QgeV1yBX>^C%lr zOuG(ZARd9X)zdZ$12q(qjRaLVgKuIm^e1==3h(5lNuo5U?B6JD z!S0;Mi&HhC(sa^;8lOCr6>UOayZg4UcVWt*>>JAmty#1BNJq0PMr! z;?s9l0PE!C!3AUvt@ZF@lgx~!IoA_A)-2nKs_NyBA3s(b^R5mIxiI{Pjc!=4{xLTPZeTRgkc6q#jGNC$EIh9laFcYz#1crTbKT2Zf-X$S z^+La_mn6vqZloZA0VCroe9$mp`JM=!#S%3JxJ4fBj`k-fR###v(SY zhR_o=Xc(7_dw*<_{sO~vGS)2Nalp14KHvx+#LG{i-aMg z4S{=~XtnN-e6f4?N2|UJdDlZMZ*Gr|pC1q{dCCqoHVBk@@Ma02SWT6-P@-SNHqIB% zIb>!|lAyNAr%R>bFPW-crY`YJRB@z(JZ)=h3vOnGe2%t{H+0YUt+AP;(5@~ZQUP;+ z!M9^01zA6~0Hu>|N?uGu1OE)*c%(c5Uk)A>|IJ{s#w}tn0|-Xk7MLy|RmK7mRM|+- z{4a-whG16NP>MGFbNZeU+HIu`N1)K4Ab~Yws03lzaZ|D&uVzry-ePC*r@ZWxU86gs zw?Ny9b!x(M`v&+21Ab&Pw7_OygN%!d^U8~s&j;Gui;g*p55Tz>^n-2qMh}>h_ zu{l+; zKt}Ee?3U<}vXq)>T%as5YAGt1AqJ-^nYl=_ZhekDb(bf@SVk2XpX3^9ia>GYvUYcO z_m!Z7)+CoX9uWQ?;awE-O)g=T+)wbjVRDF_xM3Us8%%;y^$Izv4%&^g6o8@e963!&>Ns0%e~a>G)cMPu1QH3cYsARgH*2Pc2Te3 zLuAkssv@b~Jv~7wq)e|nAaBl>3DPfV7)g5)P;cX6hQsvm9IWV~r?W2igo-qmWx;5^ z3pzz66i#LW>_`P*x)5wCo&r%Fy_=GRCViI?otO+H)EDJ7^>);9Br#e_MxOOkr z=atReg0<;^;EjHR$NOJl0-i))y7lOVymo`xW?T`GB)YFOuwDQM#nuetu+Pw>Fm0Ws zM=UD6#E?`8M96Hup0|HlD(@`W#8S#rMT(sg7gqi*;c9}%Q70pY%OF-%tH5SIMgQF3 zte&e{u|&n|zxBcFVp7qNL7@OG5nA2EnP-|t-X+__w3=?C)I+f_*^aLa@AT+WoDmc$R|=sNNZd5 zVbdr^#}bPcwY*c4snON+MUaFQ8C|RrfD|svc*TND3ZtKy^eQ7gi13wOO_|=iH&!f{ zdtC&NP&&I5Z6e^?kmkCt57xZTN_zpgO^oRcI7)6s(_~MWM=eujqPM>#d*|JT9fdw5O%!Xnd3`LRV2Q_4EDV z!@LR1J)~9{nxG5dvI~77U1dnlE$5?1xSx$yVpU3#Q5;gV;|C4E?#wJi(xaftxEX1{ zI~Ai+0u#&Gbu{gp(kA`l8_gHxMC z2q_Ccc*r@9xSFvsL{Z`omiS)P)YV~>n_>{}#Ld0_?w*dg3pY zB>nT_e*r%bJ>ickfvX^YMcNcKvng`+5oqn5iM;4Be9Q;EfYea(^}HdZYX#EoFF+>f z-`(gA0JPKS#Cdz0ho|_vMqDw%MI#;IIJfB5B6B`(cq6r$@tDC6xBLnsD2Vq-@J4;J zbPcWvh~E$xLNv}-L+r3$i`RG;A14GPl+kD)?I{p6qQ_&ceH`~}v0mW5?e7&12*OM1E zGd~h@sZW-X$}^L_OPaU1Otr44$irMy_w~CP2SgM9bK_}^la67=z}Jo`u1vkaG}bKl z{_Rjw?9)zrR*7t5uK2IimW~6`la+Ov(v6gOnQM}}@LOWdaTSC<3@r(5neJ1#l_l?d zz>7noH#%sPVnpk-{(ZG=t{0TwJBp{4dOcG3bcHiMG8cB47wfH(2rzWy!sV6E5GetM;9mD;f zd>8S&Qif!N)zYWj#N#3RX4)eDxSzAtZ>#BZ7(F6#^Ykotke6t$x@HanIb|Gy(gKvCK`0%HF`gdV@wT( z382oxROp)n;qFzz_f8uDJv-#WOD0^D{RsUH`l6pP(bo_tSCE&dxfgioch{(LCJQ3< zp&wM57e5QA&?&e{W6}HFYXj|}Kq-yx>xB-W#Lu!t3Gary;OZ`Q0)1X;rS*u6%~S$| zbgl<=bv3X_;rD~U>ZxCB2NrKkZ%qI%iLHad3CA5igusuMx4KxHQDrQvjf&`jSt;>S zCO;PvRHo5QM^XY#a-^TOe#``=>U-hem(Twl{~MVJmT;5H*O()0Xm<#5F6~P_IR>Jt z;!b_SDrz@)2v13Fcx3;r=l^~^H>7m-KLe_+WyE{VxhO1z=&|a!(<3CclBZ-%3&{4! z&&u0>$m54b*_{w#kBfY>k^eVasN_~BTto2FMSGIbFI}-;&_MncswO{%B+Udv_#Xp@ zx1uiRMYPS9yX;FPatZVsF}s2bvBtOv)DYKj9`%r!0gsrWQ#K>b{n!nAGFdIREJ{nw zLZ*xRv=8hk0!KbfmK};e5S#Xoh{$|t``XY`v+oJn`H?| z)6RS#pJ6Mh(XgW4(#-!9-RXVXDrb#8FWi}3xko=rQ%9OR2nN-vdA6vOY=CDphpQGd1A@U2UOA4$hS_w z;_sYPE}SQtobsYf%sNy;2Ec(+Tj^MbNj|dibrE7?=aYwQGR5NHN3pXKYR5q2o&8#Q z{ttxYv%m3)N1f7%Q`8)w>eEa5$}v#yc7pE{ufWFte$x9NfSt|Rv(XX$I#?uHSUnky z=kLIl#G|K;A7nvIOGt7Ap!+5!{f=;9ILtQT_lb@hKnkwnaVzQl3IKdCW60Og=N_K= zL!eNg*TELgeQ+n|LBl;Hw?=(~nhnk!L--tmBtZS1+_N3HYaH@7fWOR>@MeiL0QDQp zZU0~@AKCBfDfr^~OLWl^Rx8YDnN0tJ0e=hfdr3y{X;L55zZ1KAH;BsIpl~4L-yi)L=^TR0F#w@zeK1pBN_jKZc$B$p1c<1gGSniZy+e^baW(* zQ;`nmg>ZnoV3p8xgm}dMZD}S4ITF=0w!zYGiw;S^}?mgI`JyH|!vIBg^(n$Mz zI5UuyUp%qO2&78QCeTr33!Oqp4*;PDKdOm7EjIdejY2bWQ{GGiQfR5)I=Oiw4wqIl z^=!HPv2d4^x1MzT=^r!f(FJD@ldl3}NYm|^@RtmCU9Lm6899E6E^wF3a5WUkxl@hM zyByuB!Q#SED=H+{Zh{nHrVoz9&GXILwlDQMC212`V(B;Bu$Z5^@%6Zq99$7URUnZM z4EyfZMBlrwG$`qLX6h15h1cj!rksplN5;ozSGFE#+cf|2hl_7Q9V6e7qO>82N?3Pk zk)u*Jjk+o_7Hwci2vxY_u{=$g*~A(Q9YuR(uTsh zg5wEe$*sAKYmLgic$MdpcubC6lMxn9zVB05PpE5?IatLV3#QXLG@hX#dLJ6hDq?32 zT#)DX*+l5g^CzCLC4@GpabdVLRH>i#NVryF^lf7uY167Z^erb?5R05BIgtzgR81s~ z^K!T@C7!m~^=rH)xGg!f6<4bhS1Ga75{e@7Va?~8zQ%4m54u00=Cu~?=7)hb&N8NY zjuxz#MzxKbsOC7#!HaZ%@fh@5O6V~g)ilCIVV!0L!wqFzI{lai_3zv(p;iV5cS(Fj z3b@J$6=~t|qv@9`3#V88y`D)(baLB&;8~VH)Z6wG>$(bT7e+Bf+i0G;b{TiR)%wut z2BZ|^rf*&wo8DglxW8~r^XD$iFR=1}NSR!b+^rlLc;o&u`$DM zIJ>02SV_34ps)mb_820#uV0hRyaQ*K77|?qZNr{GVl!>Tr7U0 zF>5ifwO*`BLE}J+@s!Z@PvH}hhrBbc{l28NrbZu9 z=27+&^M{3P(A5%#vX#6-{NnQ^7_T@*+YQ1avC?&!9LaC9pOvT1l3$lRc`h31hB;au zGlCu7a9g8OZf)CLu}pjRo|@Tfd0DN4NT-_0AKKyy(sI0cx;Q&5@oLjc+(PYkv@eGE zT_16M!L;WtKj!dx*C5MWihDQHfV`;$pOe4ET{-qRf_+fy-f#?qrGX$_ob((Y%OH(H zmLTm(l~O2M8=A_~+vc)rw?}=%C3>=L3k~HTa-(X}_2bwD<_nR?VXZkLgKZwn7(94~ zrLPrTiv8k_npqCNAK^GrkjSL6g&>!X4DA!=&8b{Y{B=cF7W^Q)%0+uE0_p_kA$44D zwwhJVL~qu$w>B?B?vJ|5zC#s}{>vrG&36XF6r42Cn6o1Kn;>k=V{gDn)FD!Qg`(t_ALG29F-1y{Vxe`6V&%XB5kDmc8;}(=3i|o-`sC140RR2_Pq02Rv>hGzuCw^*5olVe@A$Aa zJh5&6ePdkz{AkKniTdj!Xk(2;CJHm~7N6hb*uH%jU6sQ@pUg0($40uh@OlyA8^H1J zpBl0`o~A2v38>%$$_vgc>`$=r0n8yie~s?l;}xQaA6s7Y0*+Mezglc;dNFtEfmL#} zuyMIZg~Y$Vf)1%MA+LYU>|LPfl_xasgTGQl7!3&m$0PhYpMhA#&O|LC4_2N*;UPS1 zf^PT;gww>y6%TI%FOA68;n9paUIFO?^g4}WpufFCiA%+-t1jJGT&$3`E%=5J5E>r##kqo0sZq6t_f=UwCe7$t4-jJ$+X=6jK^m6aIQZBRsHOj~=1FaX8y<9K& z&15Q#LSPwI+H{zr#YilQNko=|n+?|^`#xre_jtc~YJRS$Atkp`U8HD4So_pH&~qsg zMsau#he))tV^i(7jirw$r8QNIrY4!B48OZH2ImWMH)y{&J&enl_s4h8f0fSZ$Ra7TGP78a{__#!0SF)<=`OVz4x$Li6yNM1K^y?}V&wzQ54D`T9eulR{ z!QBIQz1n1dGN4$mrgqK)dv~L6)a}*Pt zFp&Fh3Y{|f0S>(yC9?BydKm)IA(pN%$^bf&I*z*O~=+V z${yGiK#YyguC9FRde_=u>ov6celThfKSjDcvGu!=vx1-q zItht#kij=M`<|{3;!hL0DPCxDxfRGh|C`QjXvt62W57qL$whucNr8_w@fn_n09@LH zQJ@egPLF9r16VKr=0(Tq6NtG8IaOHhC7-wT$;*Jfuda@}Z=$Ey6QKooKa|S-gM&Q* zsI8p)U2T^9B!zL-T4uH&@^KjmCcq5uXs!ukhF}@qcXi3#x?JQ!T=A&4T2s`Q0AK>l z>cd-rvg%&H)^vFb5ez@?v(x;fueOdsb+%j0_9OJWtCq{FaJ#d7Ra=U|RP-%@^n#J} zF;;07l}!t6Ps*#q`lV^2gw^cUYKd}8Y*K)>EkoA@sF*z=%bxP@~?7TKFu@W zzEr_A5ZTK*G6<$EuV&d!g&iIkJ4Ii{e@|?jQj3#p{VhFh91<-t4H!3xq`p!>59iW< z^cl;=-TgE82&~EmNAgc?DsMbGpwp%4)t7$)u_%DUGGI9NVpZ1HgOo|Y=eEK%iq!27 zc>qKj0$fa;RWoU9h2`900p`tFb(}IXRRJ!p-PtlcvmRu|pI}+F+@@!5!LS4JMNDFH zo7J09*d;)5ycVq6CR6O6ioxLlwjp#9SFdO=!a=2oxGDf8T&LgdjA%fxArBWM)GCuZ zKuSDZZpJbD=;d%@C(arx8`$tB;qt@P^-$Jl2)?k@FL#7cq=ew5|Fc;%-op*AK_-sB zE=(>m^&`_*3T#Vuj?rn zOn^}$P1ORe)GK{=IpBuX2PFo)lxDfaeH^1|-}oV2o=d*p)-Itn`x zeT+2a8`hG&cHSlbN&bj%bC^~X({8uqcokJ87!=q0F$?{domAhW?~ld36byT9CXSLl zh|jt|`l;WqJnb#pNjQBL;oW1 zW4K_dxhiL7tgp>TA@p%UDOXAk#dMMCYSRpN*v7!3PhJ#=#;X@};riUIK{; zAU{DeSB`id%_wty(hmlZeoX}dSm^qot{u!|c8W&E(jdi5W!Kz%MA+2@QFtK5vZ1GR zmx9L@0@$Ap2QzQ5(!YT$vho=F@6?U_>ET!W z5WNIWP`funIS=ILUIyokJs5y1PY}JC5N86e=-mb789qZ81nVHvE4n{)I>SX|8HnJ3 zHVn;oFcNClw#hNU?#buggJ1#AfxqW*yJhY@o-cR^{9kx(N^wQqAS+qt{EL>Pf-Za5 zhSOh2iXa{KYN!b5!v3hNM%3F^c+w{OXHR!s8bs6^CKSX{5U zMAre0;u7-$G%O(_#ej9aI~P|F#Ez}7jL`>n0&e}6>+&9cU0VS&?m-K^VkM^W=+dIG zbkc2d+@fk_%rYsO5U^<95zWv=Uol7{_pJddg{lan3Ss%y=>#DAhY1Y{W+bpsh*_C-K&R-OzdSOOdo)6_Z zy0e|*;}dAm3{W$rV>C@mkUyQRDJ|O1PeD35g>PxyGsMT{xTQt@MLtSlVJ=nu!Ud;* zjNM1L>GuT8p23sczanR;!mTd676C<#VZzOzMejZKly|g-Yot|InL3Ei9cRK>_Wx z2RwH%dSxj6)$gQ+iw}4tVA7n%s0zm}F_VU=UiyWLEV_QfqwZNq+fXSIY~`I3sg5a? zqsn>AXUwk|w}nzW5b;Ox@oqLcj&cYG9kwWU1Fei=v1J}H@fZ_MPT2j`5b((eZ{Wry z7~*~9jRS8BbaPyHLxN;J@{>HQTykSo=lXah`Y^i>s}?nah^w3LAH{?|on8KC%6R3& zdt3#M(V!#L{LnQRpE9o{V^!JdNL*gVS&|u{*9xr^#PD^&bTl%7!y%2bq1ZO=@8@)m&Az z2UCZ)WPz``+#lx2;I=hTa6N@lj-8!1Nf-J`$ocVkgy1a#2GVg{VfV2U8|jVHWK#N< zLaO ziJN_GDo~Av{_YIsv|InJ!G;qorC~cZkvlQ;=5S6?g+uls zVVsM`1g)bAx#ycD2-~|wOY0&dC@yZwFrB_g>Fns(J3C=y!tgkN47D|f>)4x;zP8H| z+x&9KcG_N0s0BzCij(;>o?cm$AENg;sktYW_4QftRtKtD_U;7b{C4S>%*9ri4um|) zi7XUTp>RC8hh_v{t?TcwRQ0J-dnN>OS|U-KLM zPKjo2TX(VP2N8l_b4bP1-7PUcWRZQ-h@0EW_u5M*YwQ%v!O86)y)sYu7GK3bb7uET zP(iFlRdaN-SEN(<&(tUT&2xE|*}`#qw`hS{n&{`?bl++YWh}%lAYe{EY2+oLQkQq- z@~JkrOy%a+X_vcI&}6r=E>+?$@ot@Y=vhGnD*A>IIMdav@^9a1zJKm=?_UWeLPSPf z{1+59SQVF2DT4Oi5I{de8{dJp_h56$DJalxhgp5C)Qswc2K81j!Bev=e-67LSD7uX2pKt#t&pvq9( zuwTAROZzdaDF9*r>uezTg&Uz0o~x=jBR)#VWIO}ypDOY55cT@%{Tp`qwnc4JUfi~1)ff~%%Qtxps3dE3A!T#;PS+I$2v|b{BM4QRNtM2dI$_Rn z*VT(ZLXZRt0|(g#n=g0Yf876nsSk%i28G;3S0EK~p);xcyR#(QW8l6XWFjW6ErH3M#JA?jKCLT#`WGanQta3-#$B|ShF-EGSEih}Ue#MfY4NC4 zxz<|3-#qhQP(8BRwch7;o>f2{IaBsGF8|7S9*X}}?@v-}l`GGH16vSBv5YS5QfxW@ z3$>*jJzjQEA~W*!OyN)tUV#WK)JMSyYUu0>|42t@L|N@i@+D>ND2M3Ul^H913E1dr z$=Ufw5|o$!|d z>gelJsQwdLG7SK?WoVB%5uwOa7w}4ZdpGv4^@t(}?*X1`meh9m`!AMB_?m;=kYrPJa*_vZmldJi4q~c5w!X$ghM4T27}5W zz+eH-$lxgezOa-mbeMLs0X*g&pW9N_MRX(R&ThZ`{2lnRuml3B#f{S-5O;$ztnW|8 zD80>6R&_P>%wVdNLVcV1>^Hs-=Aa9 zWa#24fZrbcEP%`5As~>s-LekyCTyu|&c54BA>G4`$ zq>%(KLlSsQ+Ca(vFB1>(3DO_HI@=T03x0ek^=Db|s~SzZkur6H3AiAi;$rj{#8eTL zKu+)L|G)OEVQT=ie@cHSQanKJ7-zw)AtW>pR&BdT&IxA}l!o#Kg0yvj3IOKVIBghH zlBY5%D*vDfNvcL)o!tHG#QTmxowk%dU9NoRQZ3=EQO|4;?=Eh2_Nu3L{F33*=MB$_ zSnwGg^fFoKcNmgCRmLQcy=b*yW2_)VWG{}a&o)#O&NvNMhw0xJYu{pas-99uA&H^u zbt7AOqb2k5Vc3wtu(X-!X zXcMPhZDCOt&sr5+u5o8iNkLhOz>p(@NaTnGmuxmYejO%O=*HuH4Kd^CO!y>qisgU{F2ub3D|4hM^(2sSJ5`knrkbWt9*zTTb zn80eUn=&oH_7;K2!{C1B@JOYnDjj)JQJVPW*VP|vCPj1dcezBZjtXcqn2~viQ!?J| z2syGZPhkt>*w$>2hCv^XZ_#Y)s$srPh2HcgtAR&6-NM51G82|nTb&oneN;HgFn>mRD6MGgRn`l{Extk{}e z#Q$V9Hy2j%!IogqCjr)4@*aW$xk29(Lg%6eybCDA@y0`m=i`-Fz|D2D`2o@DwE_7B z7#L0i3J@<}2Qc!$18?J}QZE^#1KG;_6g!(i)eW(xk&v_5Hn(Tl9sJD7>LtT6QF+@J zPw>p-?M_2NNF7`nCVCmiY^_4vV?Km$N*^-tP|2TQBnkksfV?Q9BPF&~8WGD48WFm={)wDEg8ZREtO zigMy`r2xImNFzqL%)rC4s@&U!ijohyshow_zVqhLlOG_7@DR#LHY9j)DEi>TfL)^v z5>`hJ+QH}!lrIJf_62Na1LNOlEXsK$VajI=iwKXZ+S;S(A~YH0YqQ`>+l*-lA%rPt z*zonQ*z4<1=5S^SSP%-+KD^gDS}xHtW{w@%g~@JIqCg~) z9U{zt=I2inN5XE22<($O%P`~JqzR@xv=`TnSuM;YA{Z-WLhy~U##A!wm)n~%t)}xx zs9W7FT1snU(fWZx^SL#R-E`hQiOgH(dvKBQzMYz?p@A~V{sDE8Ork_1GkM8V(grlv zUl}DIbJEofo~da&vkFs}IO7ja@XUym^rU`_r)IGiGO?R;Ia$1@a#Whm6F>yg(}o3p z_5Cf%zWe4t-py5Q#;pbLxm!Gi^3z=NW7uIB3g%@E@iSJyLkV=CCY5)R$k67L?&`lp zl7`I~yUEAa^e1;z{9qL=nspelNj^kR5*@ARb3(>_)o0AbI7}(Ql}r@WYP!Uo!SH2X zomPog`zqG~u~o38rh_ASYzq~)kj$+Kb`8HFkTa}=H7($El>ar;gF3yfW<~lp9r~-M zG?Mr0Lq(#A{)vf;zfpy$8~_qKL+DyC*V2P{dJ1mFf+ zQbFT2CnM+jh5=%zH~rj?c%{HO6m{+gNl3VSDHnVfbxyeo3%n-3!B5~ck&7|1L&l6k zG#3c4laCbH&ke_{8;~*tMc#h_#2t)(?r00sQbK}Wv{yy1dMlhDSkh`MT0P;08V)`xE>e-lWr`sa%QwZn9=T-CX&AV9?rZA(D!Ug5nP(JD24^ z6WIzD06>^?eGGZyU}ZUJ)G8<~Z`NhPyQvI(wcz##Qh}}S-%pgV-Ytee?hkDuw^=o= z;O9ji^HAZqRN~bfrmLIczk7P{*>}^rM;I zzaUUy`k}o06R3UbPaqcyrIWkW;+R`yfbZ-DJ$Cr$W0%z#5M|Bto)-@^1HVmid;-#O zTU33khE_;@ac@!Da)drmg9Dq{wZb(*zKS!;X<+U`pO?g?P@af`1BbKx(~-*D=T*b( zHolj#tNJ!S)A!A7+Haqm_l6Z`th)Ge%-@x_M-T@3KbI~PyHuzoBEEP8 zyoT@bzt?lIVNrqR7m_%Bzr+r*$&{qE(CQ3XmaSw%Zy8oecQ#)j3{&-e9H!gs@X*ay zd+8o(v&&=kt_~@y4lW zDocnQ9&^DWW)^Rxs^)BCAiD&%nOsf$*D=<7<^nzRS@qIsIg!va(h|~4+&d{a+pYL@ zLt2AfBKs(E82U+=mu!rkam67Ru54*~*4D28weoMC+7afm^gNk?HqYop(@=;uxDbVs z&dCO4)E}+LNoXzamOE2A{j9Q`WvwK$Jo~nykX7iP`nU#&5Ey?Y`){%50`dCIljE=7 zA0ghA!J`4Yhs9W9?|7|(0La-ZFCWr89UaRmnaBLv*4OPU8ZrLen4J)LC8mTx`vsjr zj5HuqE^E(6hwwLxCL!5E>KmH~C+r!VZI}0OB+|503K-Ynx5Rh%)6OR zRQfQFmRs^<%AP>iPtSFKNcABHybIXE=vXdyi}qk`NNIS5}g&WM+o!9e$_p@BXLv z#q+qI>%OjYKI@3p(Ne+1qQXKzK)_W~Rn$X3K!pE9M8H6Te4~-m%kK>s8}~dA}qjbp9p+t^eZk#Yyaku@W9y3v_ zVyQftBmqW*0>iEUrN!p?^>fO?WL~>Y@?czeA%oA4<;Q(Hr{4r46&U{5IUxjFTQCq{ zFcV?QFjL^Hmg@`d?uFgOC+&9(@1M`_o$0W{TORBOznUy-Wg&`HD2dTQbL{Mv&A$qa zpyoZTm({~H^~T(;MK&AI^4{h9_zH{W@6U+d6}OKIu2uS1bi3>9ovO@w>?C?s88>cf zd=w<{{fUW*)dn^2G2B~z?w`l^TYC#!#}1V0P~e~mQ$2Y8{BUccEK}W%kAlX5Vww7r z`7<-Kg897xl-#VW*ZLf7t*sK0lKgG){i_iXC{g)@m~op@w(AeajWAsAp)Xf*oU0la!eFh@HeCzWW7c=MrzK z>ffNdA5Bg>J3CX3O-uWC$tOW?8ehG7b@*$rqP)DGl%;!#$MoaDZo@bh3R0%}x_4Xf z>O1XrJ_3xs{{DsMgS0FxEQDwXtsR=I4JI|?(&wfwE;afbkE--}JO8?yn=hX2%^TL3 z*fq>4l%yz^`Tji|WP9+E1Ow%O{px6MzPa;ofZFG!K1YGj({E+TGOfXPx7p@?YV-sj zP2c_e`IFl9qt);4Rqib`lZvxxzqEO>osKugEP`*mY;4LDO86*nWUuGOUuzd7VIMfc z=gCxea&j_!Yqa7f276_~;nUuS`PZ7crIXu6^El&DfmbKI!Iv7La$jd=4E6MmhB<31 z^*J=Pk*7+=H`CS26-wOJhco9txCrKI@?`HMN&Ycv^{molwQ=G8R9aoVgCBg+JCx2w ziWND&d40Wf=PE>1T#ma8XySoOF+y&PcEvBg|^tt=ywdY?;?l(0x z!NFoppWE|>`~CLKeYq=ianU?fu2_%bS=*k7qt^*{qxHjwlYbW4;Na1dq;ZnO#>RGA z)-^SGjTcJuP40PLSL&8o;;OLY->#isQ&Usp(@N+Y8nR`ok3Bi|nlT&XmpbTmZQh@2 zK(Ef4b1sw#lz6Rd;{FuD)5~jbp^Y5>{4!v6uP&aVlbO9nj$X{`H%V*3_-5}#1(jv| znx{nP&Dnfh99F#%4Gx-%NS-FEq}#IG=~-e_mZPq`z%LQZzpDE$gv_#d1iv& zs8|Uf`?UuK2I|W$;p6{R;mGV!f5?$(UYos8dNaFz|JWw<>@q;POt(rOF9`Gf{NC(P zNw(!<=O&ZG`QOJY*O$?Qze;3%Kbcod#-@MQ$BX`z#>$;MXwxtUhihrSdCnQRU6cT$ zSOXOav3lzFq0fa)0vf5yAuZmZQZ(aW01*a?YcuuT`|uR+MjzBd&AWK3kRE@jd(pk2 zEm6l2-G=d66|fDkB9U%>>y<=~NY0M6D{`G(-asj7+fAL4j%a|tc&N%T=IE&0be%3w(H91w)NdLAJ-7nmrI`E(y9no5qIjyfxF;tEZ z3dKa3%5{K@51J+`@rM?nnpG8JV`EcOnoKqc2@@_dI7uf}6@?1T(+)yg1y*ckU##Eg zb8LPokhB{nDTe3q*+IyimHcN~Ztmqq-m|NlAbLZ;cJs1{2rnwzCr@N*RRQSpyvWSVbbN$h7qIa=qaXBnk)#YDUD6LPIoJ;nY2P z^vM7GPfcwto9KuoIs!IH0;S-_5L?mT`sigti@>R$KeFUSldO3Q}q1m-@cGKmNA zQ_QW6f7>~Qe$vp;P+pEIX&I}SmYtoQ@$c&MbvFIqWxInk8SLfN)NhR_kdvREpOUhC zetms)#h2QfLh)Iu+@)NV8L7^u-U#}YF0w!_*MI*FIS9QR+kh?;edBhtgE;Quf_Q&JMj19kZD@Q^C;$;H7+KIh2ru$Xv_=9svF3uhs2^L+@g%wxEY=`Lr z^1fG#sRII>$02$ioXHg0jZ)7Q^1<;fmd_iGPYX!UyV;K8`m zF^;oeu4YzFj?bT;EjsKG3k*@imUur*jJdL?pM4^vM$6Sa8I>XL;`t9+u{{bmQ&Rg6eYU_qSXh12Fv&Zo}ehmCQ_IqjOM~9SU0X z6%&w^(*5Jxl>wA;_c6R!7M0hwIxMf_EjNtj7I7N1q2{BY!zGP+D0OD`SGVse>X{ik z@`pOYfZ5&j*uDGOzeM>5Q%C<(jtCm%6=%?LZZq}mwC=P!_<}WLxBKLs+xPeDWfi!$ zznH>9pn}vG3=R(3bQp4E7Lz`0`f+jLW7Gv$>@A`M#U@^6mXS4Dc<9e&zH=`V7pNWq zMy%;-<$93?b#?-|bIxd6&O%fGyo|UEs`Syq5%ERy#>B*RTs$@i%k?NpNtGh`^Tue3 z$Qho%L$0Z*X?(P-k95s=wYXpG8QW*c*EcW#1yL&6AR*oxE$Du<%TBdI!!Kei-XHJN zy3ZRlP(zqxb6aUcoxGWgvSW_Nec6LBx4_|BgUhg&Qa=W(#d`@9-Y@!-q+c~l9w*K7&8<#b`s=1jwGdCqA zq#M~X`p?+dSm2+QP1ppwg==eTjg3OZNV`ZqzNm|Z`S}4$f#=*tbzM8BVcn@|X$k{E z586{*^pjjJZQ3$t?S88pBD_)Y`EtHDAMH(*P=V9_^+`k3)bIXy^6=+;stwT%J$M^D za9W{N<>n5F7ya*|o9=3JusK-bOjR97pzQt6(fof`L>0<7f3D_?MX)s8;aYYs{)e>a z2x%y`a9y6iH;hv(-P+oEI{7+sui)(%@foRKdtvy8Nbbc<8h~lkqOQ)*p3Q7~hR<0R z$A{Ouj|tBp@YtBCW$&ZM@VNr=5nT#6u8_UKlp%5dt|GJM?rU#f(QTQ-XQg0mUAnZ- zW<5>MW_dxVT_m$GJ&m>|Upr%OZ;yOouaYsoSzTKz>wm^2{vjHdItz;KWqx-83)R_R z5^Z%&4QdmoxOmH}SBiizrTrSKs?@m1u5WI8dU`&6`t;__8*Fl(kTs@Eb!=?xhYugl zZ5HQCOG~%C*jJ_=v3(Ce5)lzGJ#DbeA@>uotAK#OkNNrY{iS5-96*C`caI;>1Kh1Q zi{gc=wyf;e?p%WrqC$yIKtO=6uebs7bkCpPza@N+n0bh?A~7*B=jP_zXus)k5yb*8 zN~V=WlfjT%gMwZ*@!4B_^xVtKi%lzE?y$713~KqB`z5^#%I%kruU~Iu8k$$VeVdS! zbT3A!rwCha8W0l&0y3pxHgY%?N*Fp_0D%XeT&xcJ_r=BAK4NXtZ(Hb?)ENow{^#!z zw&<;NC-l|;oeD6mSN(%11 zLtFV)|35A0ya3NfvbcA)x9jJeVZq>sI5O3%^pVg45Btdl1qCzJ%azLj?!+R5|Mrw1 z;bma30+ztd&3$(mM6-2l?>hJle)tgL!Baw3%h5T@9F5XK`5O~TgH2rf+Lq+Ug~&Ebm^62u9UBUFeMLlWng+*TF~|U(%H$$*Z!E! zxsT6n0s{Un@9BL&|A#W1RZwx%HBRh$io@;{eGJ#;N^5It+uEq7J)S+|<)1PN=cT}D zUHAh;bkv9^JD#20)AV=Hh-)*kGA4>30mjMM8IcQKOvU8(?)LVj>mL#fG{$e20WwIo z$e{?WRU_++vyIi&*q!a>yzMiNO{>Xcjk@`@jJ`%?hYn~q?OBO*kB)ajaa%~C~=`d7`q+nk2)YYPM@$M9CsvX^?-Icwd zp5*LKLi^#B(%+-3fZGvg>?-KOd1?1raMzctH_6StScb$q6Z=~3ium-YT>9>G5;sG? zKF;EkdjT#DggNBGI-#6q9IL)wXb2Tg(J?PTi|AfAAP9WbZKH4Xk z{7E!F<{}06oSP<9qOxYS4@U9hh#ITxw-XMQA_PT93}(RiB_ycM;k${-Oe9z!B>;wY za^j>gebSJ-s3B?M%ngTO{>P64x$tf|AvV6qulp-S1D-sos_=E)ARp4t0zI8pt+nkX~s@C<}>-{1@d%VhiIIghInTe#&W^Llhc?Z5wTzmsk2)sD# zL7gV+$k-UJQ^UsES^#kNEf-(|OiaBFLSOR51(}%ce;q&8(AEa2p*ITmac7Y9Yum0f zn}LK&fIo=8)EQGl&?K$oTjO!@7e}Gm0vVMc{jjuQnruy zPF+m!ha?XV9tGuwV1d?Pt)wMW%(d;9YxNWsk0BpL#*ppM&=62BmKVz^+S=MLUWi`h z>s5Wmc;<1SdR6D2=OZ&+{z~eGrzQm&P z{wFg*DH+~5{m|3|Bpzq~D#1oQk}O~PIbq`#KeQibQT5%Xm8V(88@YMstbTwBA-#K_z9W`~{yL zid&^A&$*A(fm?SsBT+06pJtoEq#r*_B3P4)_*SONHAJ5+ezG6Gm#F;f`0@z|67Y$l zqM}ZBW@e_QtPnD?vc9|&)?iHi3p&cXckh%nd*VnrzdN1|XL5#SDcD;MrLh2rn0)=9 zLc7SjZ2>Eik%eV_fcjZNa`GVGMjP$B=`3fy`7O?X-McMBxzr;H1gN1V&8`Jgj*-48 zg@t%AiGm299ZQd438V4u1^h}B4GHb*>*F$OB|%1Dq}0!YR^$U;B@olo(IFru6%M#? ztJ06tsrp=qG94LFKQlAa9C&>O%Yu|33Y^gY%(>nC?ri=U6XU3@01F$N{gWpknOxO0 zFZKun5Czp zG54=gE}MIAHxh+Q4Gcx0gq@qa0J?$c7sKqYcXtLMh`LB2!*J|2o?2MY?L@UzS|-i! zD4p;T$dNVJY!ZnXsM3W<3AhbBU+hsXche%;{pH?bw8*~U!?-^0ED~q)P22qxJ z^Vj32O^25OpozRg5ds)r6|}4hl9h8t!{oN2RlXM6V<`f&ZGo=K3xXUuAKJiW`u6R+ zX9%W*^SO!pw*KFgM30ig!JFvLcdY-9p5%$>3h3b#w|)pwKX1FsS7Se%~hh(zZSi*&>gLWGLB~FqRHZ-s zbh-ONj+sae;ES?y)cju5k1&%mknUhZlf?rElb6CSc2rl78{a&>zJdD4nvTimps9(I z#YHc|-cWhh^;n4Nfu5coz&9hVKik`}iHV>E0yJ=J`e7>|PJ^fD4<{a=?1v8`O(uJX zKC8ce5qcKo=bKtsByU;uodaO@^7QN~)p+m#J${-Mis5|6Q&!NP{$G2U-YjbVgW*s#GA`MzZm(M~KDPRdc zpo^L*2n%ZBt7&nG&q--%Z!Uf%^ICM|uO4=1Ui>{C2jRX0$;R^b?re$n zU4AGc4lf%Xk4X6*t(BHCR!m}=Q3m{Y7JNNFNEb}=oDdjGl|C66S;y7yYK(ofcX1_0 zGo81j>`(@WzkD%ZCn*ZL`j4BcvRa4zWW?h3C=~sk+!oHn)4S&L+ad=9MbQ^x?xj*&vz+F)WOL zI-xhG^Z0}6^yc+z z@*E+9BAh%}W>CH2WtBWIFaXDL#8#kro|cZz!o*~FiS?%M{hK>t^sY=4iDhMWEQx|I z=ZpT&Kr}X}e#DXa#NM9Qtkn}LWJ}NklLn&YjiUQaU z?rjVAlG%iXy-t5mL6r+So3m%ptD+}CDmX}0xd;1_CmTD5{;E`!`RZg+d*+>4_2f1U z-r%R5+F&VZGiIV2D4n;f@w|;DHDgcww@RYLkV78T*^E8+u@8;YBgh@!oNx8ofk#6A zbz*gOHH+6m#%2Jgj+QVLLp~Z-rpBxddK2mK6~Ow*$w^R=;YgqlZypb=yT>XXZOY#H zg4*uazPRVT07x7>>5QzKzmk_(Gww0b_sl&oAX`!Ojx3df~wT_HKyO%Xn^Da zCjo||lM_&<#AtFdvK}^_M~}R&PM+@OQBzU%Xen{wD;)F|qkj9-A1|jcut!TvYi4G) zRV9#%gN!i#^x3o8%F4=`n#kj(x;ij5KCiofla#L|#X#8{FY0t#K`LDO@dJww!H36Q zy_DMOY}WY$s3pR}!YkU~NhFc~@FK{o1ISQXiewLaczzG$!0d?%URk2FO{Lq|&u$c} zM*C>K()?=gQh57`5}2eC6<>?_p*z=i{t87!QV6C0d{MV0fOKD>gusvJmL-d&;L~@% z3OO$kilU$2xE>@XFA{9@)>7ix6HB0FQw`u6#RgK17|<{1g+(#E3-JA^Xb^*N8te6x z&k19InXT@k!Y#sz((ZXD<;+0c-0g#HcYZy)UOy(R-(&O?+0ZldLv#E zyTvvkC6^04{5-fkpDo2ekfC&+7%eUoYXrCU;QHpv?T@xyOgtCGJ1ia+(P4VzhId~O z=s$p{*M%4UH^K*4%9y(4 z+4}M0$Bqt}=J|q*43rfPRirQqVsg6iH%fJ!0HPB>8r?aC*MG*`WY!t!4kp}qV$cNl zo;&uWc7_@U4R}#8>EQ3CWplZd3&88BKk)BiXrQsC7t0_9Q4LZfjR?Cmx`YNOkt=S@tgPl~Dha=)yG-#yu)n#6H*)bkti!vYvqSi5h&WBb zZw<5?X+oV(Y|3bP(9+Y4=y6I|8`OP4kP2sQl63>+8`7AV-%L5ywx5~wb%vj3rKXk` zRPTQ}{vz(NVHt)p=5w|?mx8L2nxW1@gH1A{k~7dSF4Jb+k2N|6+R2wta*KdN(auX; z5wr{pn}bgX-cwm4>oaCA76AR#fB3w91akH4q^;8mGC7Z_&tB8aDkp+U7>#_N_SNCP z;y~VcfvRj`ZOyp-3-6V)+|pxn^A4+CG!wnzi;*Cz1A5DB{3e|3buBRgRv(0h*_i z6EYvtH%5jM=kkb1flGxLAo_4fE>M1E78Q;EF|LXLsRvFJP;;JaPHE`{OTGw&5-6_V zpMsw4-hzdVji{*1`nuQD_1Uw#qY>cPSmqA^AseX;Saxb>56C@x2G%R>C{c?k-ZM)Z zn_nPbuCyLq27uxK7!jMgkZ!tr^gl-ni=`Hi&7ZEK3C;ytKxI}vB|y+vnw`an;Sv$? z2GPg0`5LrsIK6!AQhwR3$gfXDq@{15Y1J8VIX3yF++aOSo7`ru%b^V_>8}1^A;d~C zW2!Ir>;Ro|HPt4}ifDNcG?eD0Aj#7UD5_J{h8|(Ax~gD5ZOg*%-O#9=+3}R9Cq|fA zjwCm?Oicl9Lpe*Av8MV^k_3zU@rQ(WpSEA;agoJOIhOw=M+#kzWh=A{cS3)|9`Jdw z-T!G0B~DbfMG$2UJ|j+QQ%L4m@8&XQZO0XdIGLx!!ax5qd*rmA+@ct-{>^BfD1HJh zGu?rYl(QHwn|Xqr#IirM^gg_P5D^Rv3_dhA{`8!DP#F-bC;)~}VPPR4_ZS{dUS5^G z5;R6tCPr3@p5$zXKwr#nF*XDA-n(!5Q7pc%`1vl@*L`!fGE^g@yXZFBw_q`9vQ2tV zWrW|yok);NNa_BhXtdKZ<+K;g`OVV0lTPlx6awiV1}&>wO(VFZQFJ^?k$?27)&S*)_OylrCNAEXWuK9-{`}bCQ#1 z=jCC$G@P8A)Dc?co3ZBEXM2PW*@k_hw00pTBB~QOD2fu}YBBwD>IeLb|CE)5WreJB z3IA$LDf6D%BOY#U+(QJk@NQdy`fCjX`Iw^Veg~l^PoDG?I1A*KmX@YGVFk@7;2t~q z^HHacpU;e*;+VdkaCrazJ%L0*2nFzypV+>>zIf61Inv9^%Ml~w{+&V92O89sXBT-w zH3sp!42JC%x-Zr-WX-=IbZN@@@R4m*SK{q?x7CN^-14DL$uY13u>w8`*XK6`#Kg#F zh>}enjBmv`KI9wla&rf+rK;3*XfUTGB;bVT)6{9x6O88YBl{KbWLJOuXjG+-71;xa zOo3@>+cQjdP@8G6ia!I8?8<}+9jdQMOfI+Y`+G{{9z>z7Urgxlx;E!A>esmeg8Ghg55qI2kBX;P^Yr>Ag`?*V#FhXP;AC8M!%S$# z3Z7z-3f)hL@9Xl501I@xIIvo)X2!FCY7L6j#~*6letv#b)YLTwfUHSk`-6(K8B;a$ z#SQ^|pGII3&$<#k{VOc`@4^mxLo!GB{SiarNAURGyy5Usv$nPde1>pVf4b$}rd=S> zWHX3&5eUWhlkYJW7S;usqmmLTAEFNjY%Nyp!nb-=M(Eu4+JJY0*0`z}6m+{bls@Gs z9Bm5351&pZ;P1ur$`95h8}aGj51ll!UUva6hVxZg52yJjNR12DJUe z*w~g|Ns{!l1KUkd_GSG}*b}lK6_A-J9DExv%DVew_UqTWr<1H}vbd27k&%&QD#^+* z($!20KYxCD{!6)SA*tgY9i6P*a0YmJGPx(Cxq^?IUHjpEV*!zd7e6x^iHmBU-+Kq9 zNKFmtU*`Yb>xS)n_sVAYP!XW9JU4q;loA*bG*Y{qaw;2k%hHq^`ic+}&FsBxY)Go* z1*~9ySn|cf!om%J+*}uAQ{_|(6xZ7g(N3!!tHn>gE-o&w*gu>5g4TM>3xc0{?abz} z)#IOYbC-a`iMfrd^gF=UwJIlk@#p7I$2nB`uEF=)stDiTS#%ci0KJQRgG%;d8S&#S zh;nwichF^{GnId~dO5K&nLLL^eNW6SL>=Qoj07w2bjr|Kh4;Vz#67nNu<$)eWfe-k za@KxQ$R;HxFEhE|W2ZOqS^YXp6dRGY^SSo7{N*#bYve#}j#NvId=Zx)NwktYNZ~}v zQA>tB??1lc?mo{mEA&;DU2_+w*$4lo#vuKs*96a&7IC->jpuDKRU7hsjRQ)P-JpU$ z*8RhnR0FmpdR1MzEX`<3%r=Oouhi_)@#`Lv__OrI7?m(*rT0jn%yEsJFf92dQA#G> z1bmoCr6To5_%5gsYLz)?d~8;(hdaV%n<1)_ac3H*eqW0$%8HMlT4OCRe_=0*$aJ_}2Glyqwo;N8|0+*MD1g-reT{ z5CV{kr8)$Ms6?kyhaCzG0XeyS!(7Ty31Ra%);4D?_7LSM95Cr3w)(3X4>Bl;mFC8dc^XOp!EAfA@!|33BW zIBO*^k_Vd!Vq)#)wR%-3#ojC{j@h(}8|T+kj$Y6igMwswUU%ftqU$Wsho>nZ)rF;_ zhV7zczf2Q(A9p3kvngMwNM++EgMn?>T!_Bp_O~u?ga>o8+@Vd5zRqqW3;eB2buYEp zueA~bF?IVrJqj1g9dwtnb3(y$UU72^o5!@`?ypKp5M}hsCX94+Qb4`k$>Lh5bJ=$j z15XB+B`&skje*6B1uyu_P+$AGLAU|it+7$RM5j(ypTg_!$nqy*cY|5^Cl zi<8?8X&6EKFZq%$@I<0adFVs}e3bpJEQzWwZ*Dz67`;7vOhEx&D(D%-#l;ZXx<1=r4sdWa7|H3C;3s?|pUNaXC!)`Rd1KO#-l3`sp8Nfi~F^Upd_ zu^%uKm4lbIx!I74!)_0&3TXoxJajqghB>JxqZFyCo?Bl5bt~(8>Fa+#*`Ab?l*F6> z=KfObIb+w)J3B^W=WhlQbuVxfrRrQhreR+guA-MCSx$PMpPYbZnG;t~Q1F0{J%O<` zzA${0AEIU-KAas6(%HN0Bqb$*3X+T*4uCVlE_~5_gqzE67DR>ld4I4&zYf_K1&WYh zNuU4pIAH1h>l2P1{{EE`3DF(MC*-#mDr7~MpdCTNrQ>21dz!{wqF}%CPR1%juj-_J z@SaE8LQ%jGrg%RpQg;|Cc3Af^Xw+MyVDUlj2XyAq*9>`*FL=6(9bCiYpL(DM9RF=C?pX(fOI*Fx~k64$HB$n${VU#E8 zM87S=-N{QpWT{V7*87jl9)+k_p92v&ZFsS{a%#FTlH5cZ_kO>|BQjNbgn=iVj~a-&qF^$I7!ucYU_C*Rb*UP} zlT;~SHPv`ntZJX+f;FZ-T#xr&65XB|iH3RrOXK^B+gH}N4GqL6h-4CqwF;{I)HkqG zF-Qje582L`1MnctVqL$isjZ!CA0ka>fS{$xx31E|sUt%6zAHTySM6)jM@)WF*XA|| zvURZ|L3HMW&#e1JfQ*3(qy&j`N%S=8e%u7fwuk8nqK3;?PpPYFX=~$GAaP4eBbVf7 zpe8&(xKA7YiG8-a0l$Ptu^=Mmk29}=1?{VC(nb8PFmCdK8uVd|k=%oryMT#|GOKV* z;~1*u&$jP@M=E#h_W0>su|g~SCvd9ncpQfq4~L7g(9!(ZEz{0A+n%pi3_ zG>Vx6DDJD*ucH)7Dsw2*wO#_6fBqRDjhDAKlET=?2yv_;6tsUw@X;eui9tyJq9VyZ zefnR76;KYw1<)KW4u4g@dE>Bz)G7RkTpiA(u-9+>SwZXikCl}ebJxHynTW7J*E8bc zVP{VnvITXRm{n6QHy#UHm)lAbr|5mZ#l2WNl9t}Owaw~M5@s6B5B}YDu|M$ih&Xpw zDY715<`v@H^Z8@6Piju}!Q0_s_dut+fF-_u1I;Em6{aNdkNe@|V;nDaFuu?mALUpW zPjO85q9Tw;5+BKeCMK41QB^Z15FXg|$8pw&szWlFtkRNCHSYe~THOBL?_cyDpbJo< zIWCRJy?f_padXe+sjI7u`(`-@$ zg$~^qH3d~C43}C2z#!Cnov~f-YhdFLA%;xL;4d|Gbt-=A2=#J_=c|4HAC_Hk<>lb` zS7dhFV(Yq|zYgBbfu@kMP4@cWt=-+9AD@?vtcNw3nVRP3 z=8AttkCJ}*^5y?0q;;5&{G{NyGN~ldyh4bca$KmlQF;8B`iNPxKq52UG#aN4eilr? zwNpN-MEEI4gZ*f*#~0fJzzH7O?7q2OGlABAQ&}Mj-7vxM$XQ>os|G{D<@D`HP4GW&wibs$5t8cizSA5 ziFm}i#v(Gr9FI50JE4R^o!p}oPl(Inq4<3wF*LJ+oVb9mv1kgU1KsgCGb6<*@ou6sDcV`;`W-C3R6?-A+jL4=yShzMzY`w zDmIzaIi=VLWSwUNw%X@u=_)+gk87qMRT~t@fEcO) z2AjG%))xL6p-SN~zU*H&h{kxgnek*p$gbA3<>-6W!_JF-UIgw0YSBNCGf5p;4|oc3 z-qz>820)}XpKD4*faw1b-YXmv1 zE~Z|_Hz94HJN7y@85kNqe00Bpuq-mi`$TA(o+MTg678_Ep0$v~R4vC6P1ZEP@F}mPqQEoXHc7D)(Gi#*ru=|ZY(ih0nb1wXQMzib^ZJ}1>?U`RyfX7 zRMgMs0L|8aHq_Vm^!8pAd|K_{i!3LU62V=Rb{Xif-T(J>0IA~iZAWG1V64sRAmw{+ z+3bzjKdOeed_qCRxRc8b_6>px7f5`SKP&5%iwC{UD&Qj&k= zFiXLe>Rn72>BeE}^nKfNmnPWvFJU9bmeajtbWaD8WYXU&iUq_{iM34i5?VsiN=|Y% z3r`wCPam|xbji0p?}o3GogO2kSVxv!@`&x+TqPJEQH#_oCdCV2M&Z?jgAPTafrhi& zZ)d8+*VWY;9GR;OT>V8Madl$DA;{p30^A$}FCTi@h9$@@VYQe&h=>1PvZW3&7Se5AR-FQMpSQetun^Z1C0>z2{vj z{cOXbB~wyTLWE9`>;_WDkP=K)6$-dG*m>8+!@)5O$OI>@o7nhqq#*K2Eifs_QEPz6 zLSzL#tf{%PSUWbRAZSkXa%iv7(8p;pq#*|aBErb{IGBdB$PtN;W_C`&buYb_pPGv3 zH4JqNk~7i0x)AHoVI{0FkaYacu9*!d%+Ah^{$Rna7dL|rS3FZCfg&_V+l?4hDos{^ zpRSOP)T$tk?_VYl8~XP1XTtJ03tlqFWBsAa(5Kz}y;%43TFj5I4@vDz z;)wMEaM%|J>_*VJI6ZvJ&N|t;lqq-Cs0g>#$DnZt+s<-=ERx1YO zmvwhqnShUFWylU{#>SKu-P(L^s)r!^rO2Zwev(&5aO69tj2WbY*GX)0%ftSPr zluv&@zdG4_lHQPkf-J;8mv)yiQ{BVEL+yiN*T@Hj`#rV-cDA-S?gB9C0TB#v-%tl0 znV7uH%zOmaoT1^Sn;63Q=H@1`a4_Fu$jIb(x)?V9gXQk1D6~k0l~cdet^voULU9kY zEMz3=_Yh0h+Xg_;+Sc}*=wcfX%R~c1qLIFSU$V;u=$ql6W>(yUgoM6NO(C*E_RiI&Wy9 zr_r*15glK@Wu+M&+z_{|BG}bA^N|8kYN5#~d%;zubXVn`uOe%D`|%e^=;1lk0)m1N zeS!N~3c7YlVTHugLv3kcm<|Y_8jcP8(h+!V?!PCz$_y3!Y$cj5OTAnUU!2G9rG?cc znB35q0(Pcr4&0?qzTSt)1E|jCok4;A{*um<>m%9GW62SyaWB(P@_F-iG*-S)(A2uXY}NMU78oNUcUSRTH5b${4a&#;T&`s`UW{1V(4SM{8E?rYAtsy(?(lQblG;2feLjYegv|*FLq}S zFE@+AKVx7ry{c|;SWq|0OT+muF8=s303DA;4AP+8kNEr@5Dxee{hTy3Mu5r2aG{Q% zw`qR~QI@rNPI|Qcz68mByzmAlBf8v&#>e;f_CO*9YK=+}9T#HbuwN)0sKrJ$*7m10 zKJ$Vp*dNB63Z?xW%%a%&^auj8I0W3!2R8CAotsmwJyb~JoI&^&KuY~129 zFQVxI^IXd#b!&|p!~?5+IUXGW^sH#r>wSdf)>Bop1kShkcqzm0PIK4}*nVt0?5$hR zIC(r3aQxElOKNSj_2wmicYd0OqM-3f?3mT?70LwyW5qW{e1wi?nfnZw)PCABSRyZP zuQ{w0-;0eYJ{o>6juys?&S4*eE{BiOdDJ#3_iW`;Qh21!EPwE)L$Aq>!LK}yyWXCj zbtYL>p+*8ls!7VM>GhJN?7;*U`A-bq<{>IS%S;4s7{-f!@9nXaT0@xTY!Vk2*9UvA z5&E{cO3IRyb1owBinezfZB?IYS_G@%?aO%as^}G2Yh8CS8aVO@_aU~(-t3P;9Am=i z{+as5GH^x6F6|5xM@f?y#ESgY7SnfG8&4$WU>ve;6o%v^Vheasv<^CfhSzz>b+ciR zX3Ei+zuf5NUeDpDnMa$(Qxa*w%tOtl-k+bGyr1?OrY@L?$F$yBG3_&_W`lc(73!QX zFJ^3{nv{H>g_RYgj$h|sh3b+AC>ZdLIBl-;ld^y9vOjvp(5jdoSflo zAna1&pk=BHLo6ppjwlv>C16gZSDTH5ohE43p6>5xG`oga2=o4b%ruAJrcPH)RW(GD zw&FitbX1r~rN^_3v!3!n3I8l5C-g$h39V%woAVH}3vW`;*DYl{<38@7wE{auQJUH$ zb!<@&78dpz?iy!g9|9`{aAJLq5+6;CT=nu+XEzD1RbRDbOaUvsLxcrAdh{tAK4TNi z6-LEd;rB09f813>zu@^VD$i5mv#%(Hz&2?=%H%H~G_rpWdLhvFHWt?6U-n~&`0H~R zd;#W1BkqoJ_gF`#&S3%;=-}V@%xpb!jj-eQ%IJH37;Wa>JeZFqA&@;~XwmNQixe4+8yzOA*M*UV z1-gKPZU|R?W)cExKq;OKS3i+l8uFp%f99K??VEphgk2VRzC?$G26M5ez?-bqzioT| zdlv|r(Tx9`5d2Drfnqw^jroja#{+tR^QWiQ)~nD+Zh|_;o_I(Uz>$3T3qIA*1Y}>L za43;*HlwIri__sF_x7q?B`d_GETOX08*zooh2(gM(FgoX9o4Zd`TW8~+BM3;(4$wl zn10eAPqAazc}2Q&`xkf~%u0gQ?)0wZbxTXGtBRGXNQ!!llu$l~e$3;&+4^wq#o)X1 zV5X}Gn40Nog?Y}PyX&O}t#|OaAu|I(qAcbK5a(bU(|0Y+&w~S=>hW#!IPm6l1|XT| z9IWw+{l!GDsTH^7`|Qe;MF@zN^h$zHMhW;3hoK%o8md$f)J{NI-IfO*=3UFu!JdRc z-&1&k8(#`IE%kOGG61cwA=7QY{rvs!)bMe^2s(&Obz}~$GNr11vG}NCVF8vA$r*QM zX6C`HyG#ddhl}EqjDe>B}G^!EbqUw;XS(L^S6EOFjy6a`Yy{ zkD-=)Ihw3$ozu+mMRks$$cU1SH3|hG_#=dml2WwLIF+59xuV)99QrE$BZ-4463|~q z+nzK~awBQuWp&8WBOHQs%p(;?N`Mh56Q$=M40(`vu-eo0LB^8&PR5F zqHziXoIv_lNVGB5nlOkp_JnDi(5gNss-Ck;u=_=M26F~Wb}zx24vFy@kd@K{7x(XN z3$Af7{U1VwiF*}z^l3e%b!h)a2}kmlx*#u&eR2QH=(DUAk<6Gkhxs4U*MwEVF1sd^ z?jK$1FmQyPA`qr~1Cx_)IKd#|Nx*p?b{fL=kkN`a+>YT2$?@Z^TH1Db^Rj3bD~BMp z`6vUqhhF}{iu|yhyfb$6g4VUro8K_)C_=35L&DC*NG)E7+i7ECvpJfZo|hN@TkFAt zeyP3|QC8L`!x{ggdV`*fXI|O=en6$9$Mq*aYP%rn{ zPGPuD#%+Q^LiAK`TbV2mpVJo#ej|K8p&Xb349Nou!g0&d7)Yd6@xiEQ;dN%+O ztXlaFB6;HP^6ENhZ6`sf`DDuK;V-3EB_t_H{U#Q2yxis{`uY*4lwOg-Y~bSZ@MzV} z030OCLk9r`NJnwlKh|T!jiG}ldHk)}BX01&4+hn={MLOC^T&A159XEPFST^m(3AD^ zYnU%sGZ&-wW5|<>^SX*qx`$@+6aJC_AVxINH!=(ip|)|`Ls}IL)zu?U8e$b0JC|xF zw?TU=*L$j>q5`?Ao<7JQSWcuNOF3H|%x@k~O;3k?gds7#hYufdJbLlsg^CJ#>IxAt zF(kES@h?Laa&*`UFx+@qwcss8@?gXleisH_O!}*z6WQpVkfqegI`>a*&>5(yVU`ON62Sk2b!2pOMJXv?VTKvt z!$PmKNV?@?qdWSFOQQxPD`~QL)Om$sa~+t!a6*1QuyZ=R?w(>KuANm!`h8&mmzk85 zgqAjO_tTaYAI*Q#!uGDn;UQsLVk=x1h=Lh(4~gjL=*Y6rugM6Y+k7c9GR@l%qfARK{0dABDtFR&WT{A)z{bWKJFQwSs?}8 z;Ey2Ay$)Sy)3BRnGrSHC<$n@CB*Aedn_^M(3M)w+s&A%T7PIsZEt!>c};H#{RR$`KBqj+%}AVPxHkTkLm z5&Q*cRwXidg@rPZFRQm1boZ;9-;?n4#NN|cAH)^&+-D1vS%S3G zlJ6#M2p^>HiTY!P@$TFQRvcLVw%8JCe8;EH6~TbMONBe(_k z=$_AV=s&R#?g5mL+S+|c=@!Ta2Z2g;z26zUF-rNl6Ex2tn0F!YED3l(xa=DwO(PR< z0SY20;>*1-9P!Js>1u?x)3n)@_DJwwtQT&trKKfb2c>rA8YmL{VTHjJH%o+n)?vy` zZ?<(Cd-@QJpYI3X!F05rSn3AMi+OBl_PoC!!TjI@cm$SXw)q)dfU(2L$B$y_`N!cY zEgc;l5Hf3M`3?~25C-7BfA4!jC+|x4?T((p6V^u+U9pnyTO55gIj0)>g3`Ato1*(^ zFW!D($Ya=mM#@BASzK&6_2!WeJyBE-A51s@bJ}|RcBbDNKeVv815tOnIf8Q3s)Cot&Uxb+s=Nf}Yk91dQS`GVN^1p4P#%9@ElQWhh zz?W~<=^)T@wG_xQ>cQ7Qv~I9*TUD%gDCOh3|CF#I{L^8?SHvh}OZ7kadZ@M*-9vZ} zoFqmJ6H~PF#@NtT_x63=?dBF!WGc4O?n{|H9&>XVmK9m#-(;Aoqm8OE z&+OWscIEJ$iyPF-%Vyw8QK5~wA~>ldS-HhK%H{1_^s6l~Tzj&7{aAUO_~ZfU=p^ZY z+UHasr5ZoNzgMj*0xY;D^+>nPuuSW3l{7t#&%|7MtX9eCS z1VppUr=+DRxU9OKZHn-)v}BR12RxX^h9WdP|BZ%q&GioYFkZwNT)tdwDS3#URxT#C z$uOCZrPFNL2!BRGqV(xgmJ|0zJZ>c<>@g;dOGw~4;7De1Rqz09+7#Tf6oSka0nOVb zrKRC@xiAFPyoM9iBCe;acgR(tY>s&UmULtM*C3|2JclG$k|{TLb_q6UHnU^L_KS-X zDOg%rQL%@;ImblZ`{s?btSqGt=||Sah#cox-)n$mPFy}Hx&kz^Om47LsWJ9^G%(JE zg#{#7v;`mlqbOtZSGPCRNn&RAqvUUnFG1mhmrtDyKoZxB7qw4)#OJNpBD-_b=lh^U zvvh2Ml!OGa_QV(9srGtp~pZTm>+nmHk#zI%M;yPOhZpk(-T~hz0&=OxRa+(Yxj07`r~B6 z1ON@SM?_Pu!^mf|1sm2=!ydm{CQcjtoZhpPbTKt`NY>-FGwGAKV$+fXLCL-!Bqkr_Vh8KyX{ZB60WVN~e%Y&QF4S(GHmo>38o!*49*C9|Y?L zoWQQ_Xdx+4Q^4Fn?9FLw4So#MAk02xc1^pWev~q^vV!0?^Bs-=9Mu4oqjrONXM8BYUxpU_ZXwdhW4sACOzkzeV^|)C>B=AoF zy_McWH|Gbee+p-5j()rj{5-3djg|r&v$Zp+hxwHHl>NZ#3EKrF>$%JPoL@zFR)d9w zbzJU^T>e^d?72%XXTOv4NS;+qm|T_I0m@|I;5#y+9PHWoK_f3(|9Orza73&qJv7bq z-CkdYz@*Bg|I(-09F;5aJgOLAI~fj!Z2GZ{ll2bI`w7yJw4?Pw`QO(=@deIoXf7Qe z`h3Q9&Nq+MMNCo4fSEcE6`9qCOVBwv{LK4+i|d_a(8<_QoEv)P^y#Z`(o`#qjgF4u z!3V-%mqsT!RbU{Tov0j^NJ6T$kCipLsr6vY>|Rr^2RY3`28Yp)PGl^+Zg!RzpxPru z^XJKhlC|yUW@(9zCy%o$N~TG3t8|9u1!i#i)7V%;P(oH~U-*k4lYci5BSy<})EJ zr$X6Jg~wqW^V{=`lKMP*pA0n{NiwdD?kV&Ay}b($$+W5i$g0jecI{1W5m7j-H$;6@46XDlCk=Pl;_Uy(C5F#_fof zyrSY5QW+;TQLZ?-kpw1RLH^a?;zM?d!yw=GahB_awB9Sa2$>{?7wZjlJ62+h=QQ4F z?jRP*{I8)wH9n}brw6zMW(Bn10&Oy4Vq&lye-F;fz(9|}!fdUsx+hd|Fg-(Cf(WHy%N}%zZ-}=4hRZlIZ3lhD#%|pM5&V|D}@P+ zp}R`lG3o&mZ1r>`6$RkT<27ikNas1zmy#VTttsE7-$jicU9m-Ae&}T z7$)o-Q$o9&s2553`qisuxjIN_p*A!hQpusO6n7ZTb8d~uyZu@_TZ)5P_)dsO-&Q0g z8)Lsw6=Y?O8}*xK259GH=4Q;{1OTnlmLxq`o#n12)J8Z9D$;Ny>KGi z%+xe=p|Yd%+V=FUzvwu5mcbswa4Uf&zAI$?ykWsqWgR)wf7@T*i)Clo+ePs?Iqw7n zj7U>SRQVg&m?Tfc7}?M+oMkv>(rM18;^&H*i3wh(x(Pm*=8M8?%=)rPsV=G29Zu0l zOD|XD(AgdjO-qkz%dyvGv`*LZPtYqA_s{kTWa^>%=6r4PedwI+%CvnkxzS-0&O!cl z)#1ab-3qoXqX!WJx9HzCDxf0yB|>kiICTBMYobX-?kZ*pn%%TIUqgW#sn$uo+PW8p z_bAmJ?s6;)CHc;n6?9w3y5m3o{t}_D?3bc7uNJ&*9`6|7sC~0gT`_rtX*;Z1Llf;xg?(bDARxjh_7n`|z+a>t$^Q~ONryRSlx>Twqx(?`POM9qq zQE&1wx>+6Lr5{@OFRUVRM2dOjPWm}c@l2Xe_dLklhRbdT&l{_;%9rT~bIG(CuC~ex z8D9HGnHis9t3#9I!Nd^CZOtxtFe{ol+yJEe(qGb5@(hIZAXZ>}iSkHvsv?W_o!&t$ zGBRS>=CpIsBYs60r(JuIJ_lK3`A4{)^BABxE*{WyC6Z9Ks-yXE@|Fqrx_ypm%zG<< zHu9NCpZo3Q^4^q5{?T9*INh`Rwew|F_2BJBk_zfUtN)Z^^fYabR*5Ka2i&SGL=*x| zv|a&_0hS>{w`S`x|wlJm^ParBy+(%Xs1YHhOJIvbkTW5q{V@fc(! z*gATqC7gvgqF{aBT%Cu43&LS(HsDpvCa<3u{J$J}zBreHq0mw?hL!`ldT1pnjtc|a zLWDz4y*TmY_qQz$7dCTgLuk0(Fr3odU$)K{Ga6;E_HTFTA5c@%;)6(l`0N}J+kY!y z7hzmksj1prH_%orN1C;EyWBYJwAmQGuko*7e{8nZLb$kj+M`Sln+0yU%So#8iN{kU zzCQm15G-_IPyP!|MdNn++W(yEd`ULML(?L4HQHlSuWjkC&~9H?7IB=c{z~(5U#C3v zLXxohqt9mUGR`i|hZ4ri4jFU4DEoU{^*{t6^^o+wIniN)9GEPamq?+$-=- zqSRpd)8G32rQ=KbDMrp`smVxifa9uecM+t+ARu7)cd&(MnKsGro6d$oZ+3SS1`!I1kQil-^b^Gl$O7t>%OY}LxY1Ful8n7LcW13 zoO|TzPxylK(lSgQLTd4(veE|xL&^C{tL%ICR4|#?vv}0++Q|{we}7t2gDB6v%9W(y z8tC6+8HHXsE`y*z;w0mPQ1p~i>D>Skpas~QpzY^JK%7LFy1;;Qa&m_C>*(vhyLV`h z<}<2)$Bd1$@%v9i&EBqKXRd@s}a+ zoKS_|zI6+EMG>~;|7KFn&YU?j@rDEE4B&^E=3$Orm+Bn&Ey3kvMEAW!jY7Bj=g%Li zTs$a*$fuj`{*Tu~i6E)lndbXPa08od!4KHk+pGJF?6n_11G0$F<0CyzKxcxMqAgqb z@{PTGVRO1!Z{+9o=C0bzx`Z_6;V1?= zLut+0gS3YXL_M?`k%SY`ZT;JGhvc4XR#q0Q;X&{`KW)cn#0!cDAZ?~-uTxQ@4`rGF zxG@F7`*jJ!b$`D_kp=aO^N`3m7UF3EQu2q8O*QxrNZl=H?efj>wSR-)pIq?_M!kFY z;`v=3Ty`Sz`10ZpK?rBwfbi3H+0X;dok^GmLV*s-`TYL>)eQMK zA6Q5-e#zH=G2M8IX&XW$t8ggW@|`4io>2u%@vyjHI89qKw7DZvcdj=FJF#{glFvA^X8nwa@W5n zq;Ib%iotEFX#N#bZj#H3FPK&=Z+!(Mkt1?_a&qDq_Ow!KxIE)zzh(c$ejrlGFXCy^RxDLU-+g=gWG_0>G~2c(Ib(jkc|hF3miu0 ziDa3GBV+3?NF{y0^X00AI(0o44CwcqM;7^93q54WO`#9pL>7ggh`4HY;a#OJN?zf8 zIm(?Eh);w&Q%wvN7^;*x3%Xp4sPvYldh&k1;B}78qMq4S`>@Kl8!Hw3Dn3Mpv`GiM zM;e?M0~N)X{?P=fAKy?$q^z%Lbo%EPFDO-HHrd{&T1rUr>FwzbGkGXnsQZGo^C}hl zJ@s@)uJ~QlfX|*+qqeVS(=&hA6COxrcYD9)?X&EfBHx+;Y3jYR&L z>hCuPK@ukE^B(r(?i;Z7KmghXrrM|*+*4)X`Fwp>5Sq~(!A28}!GenC>vLrUaD9E3 zo130aepy{zozLPj9wz;Ge}o0q=9!2l(3bQ~HRoGE{z1(wx(!(jS+5m5jA?1?v^Q{n zi3tnyCtCRVJvAyqpyJLz?k-t~QmN%VQ2&-`OD*iGlTkxK^{Qj9PF~$(t_%6xe#Adu zG#p-nIGE-8XXCpvJcry_qTay{qQKb;2L$OXF%=xS2lSY{|>?+_hhO8dU&CuAcyl$75W$5 zAFB$9WNz(UuTP25Y7I?Fb}8da&kq?o%<6If#2G`1@mJiG+zn;{q9gCr^~4WeB`1=f zT_*V5t9@sUM#*IB7i%f`0`icmOmG`r{mDH#O;Fq(`e@U3y&Ioy7b0O8m?QX7va+^d zy2AxIXWP0L-skGvodv*CC29Tr{qVi;oY-SQN7R2xoCS;wf~-6*U20ddu5mwk{CFRR zu601ij{9WpcHKu;4*fJ@cbw6H{%U~w3DCs^P!ofq@GJg^Wk!N%Y?m{Jr{Km{;a@{J zCFg!R-%x~jOi>YfGZ&n)C^mUVec3`XO(+gTqd>VhJHP7aD0q@ol2IM7Yj#9Oun$`4 z-PL0C$pRQ_2><7tr4z~WbEsvJ{x9OU7~Q9Q`4K*)Ad>g1F@ z2a$px@}v~K#!ONy02Jvs1p@&9<{GhUF01pS)whVd07EKFm;5GqNDpnHgb1YCmo-pvhiWodnZgro2q`lA_xlO2OA zgqwtOJzI&<;iz1xtlJr$n{n-3Og$GOeObztB21B|FdP1JaZy()kKVeQ4H5DD3`pFt z{{+m^NSp=Jh$G%x#BQwIy_M!O7g+zS=jyfZIqP!ghYJ5b1DU%BKRRv~V;JLV>~D7) z(&wq6P0DJvAy~%5pLaDeiL> zkED9=PAMSGw)|VdfStmF2)*ZRlW+{!G==UE<}Mgst44^!xs&47b9}@ZqR2>^#!*`~ zK>ZfbqW0pRvTX?PK`m^t58J!!zMq8jq}o4cXGyY)d-t%PywqQQt0FO<^TUrn?lt#L z5ltAoy3(qrc9CVo`tisb`OVrLOp-qqprT-0%|=BQ9%>MEl!KV_LfuD&(x~&>dQ_ow zH-aR38mCX}vu;J1zW8yW-kZ$4z?Sg*V4DJuY6{LM#G7l7lZ710X|W zppReQ@PWvw6h@@7b8&In224gn6psTSOAO&V)r)Z(8rH|5i-D^p~6o4 z_Wsc>$h$#r>Fmvi^KcOcEcnF2LvV_r}^YkcuhyW{-A!YdN; zMYZ7pU_gWf$)pppDX(|QK23$Mul`%ko8c=^9gk$kfQ+Y}>MHV9;UL1~!;*0A_&ag2 z`6P4kg}FJ98u>Hv^Yin0!u;nrn6%&<=ytAx3qkk}LNRdDVrasj)s9Ft7l-N_wP(=% z3PiYw!V}L%z$L=-Mr;gJ_FBdQ$C^9QOm{aq*+DVOI+jfz-B*;5n=B$x;_XW+NaUY> z%QqT^Z-ftT7HxiHEz7U1$b7sFg)Eb4F%v=ziksU!oKkIi-BYe!Ubv68aJJm$Q-2AU zV?ujtpq_UAdkh7H8x-QIYLB!Yc8ca!Ob0yllx`{_!osobQ{TV4!}Wa*Va2zgl~L{O z-w*KuEIPio!u0N^r(=Nt6lr}gDIx`+o`^8cuDYgrA0Kz7NplNF5F&&GA4_)M0VRc) zrqibpF&C{1(`H9U#|axsE^|Z;<0jstu{Wg)LnH2E$OjT{ajR>n`Xb4jQBEZqsBaa#{*j%C&p2`?1CHeGBdy=k4u+AR=UKH41{Zr|IJ0vDD0>nBUSjF+uPfy`jD9~e0uh7)|cRk z#8jiZ`~g0pEX_k>#>U3}j$gkHx&3pb?NZ%McIG?>xXkM-hz8i?zJ4Wi7q?W2 z%C?t8TKAvtRf0|qGj%LAxVWetUm>Jkm!h^VEO48A_yoEZVJiTU@$hMw?31~~v;ov8 z5IQQW(nVvH_b>VF-y%umPj+hR@Auk!QRVRXgW$-$=Z5sA5-C%XH2iygz7Q7Vx#Ttl zEITH&SHY+M!e@D^9aYz3g{439@uNp)D;>#emdZo_5VF8p_sFvB+lOp+Fu8z03A+#= z?Lxi-cq6U0{b!!KeyY8cO)FIL4lEMxi|HZQgMnAMU%Irj=yg3I2-mH=?(4k||9cT6 z18ClI9wW8(<;(lM>N}CDEikm;Ga}CAeF>udQOIWI+24r9^;*Xqu(zH6 z@dKkQ_ei(5AY$6?-Q#@0B%wj*)GuHq1;PSfi1=hRyA z_Jh0yDCN?;vYmUB9yYX1mXwthcI&^f$75T=SV-II8G)w)&ct(ZW{>bHDPfSCzZubD zf91*_kOOg_+T2Sj+2bI#2ws=V`Uj%}u3dj4Y$pe1@uj}H3L%u7ymHV*M&m+r2C9$Ccgd`7q8bVal|N@JVjMoDVX~c_~3nIrD^kF zYTfidVgN{fuZ!P@VS8G&Ot-`0HasxBE?W~wJ=~ltfS@hI#K5!8xhn+;Mm`8s5dIwJ zDSWr5 zPQ40`mN%T0^mxHJH4?bRXcnqGviH?M>}YfTeufcZb?88Tqr?ebmebrq>AF4@CkF9U z-!bcUliDa}v#Rfluj(M$#j(2=$=OKg&%S@(lskTHZ4K;@Lx=A*GG>;xzuMj$uO&YT zdPFIx%jYolCpl6ThqSHH8Bw)9REa6=iW_y%6;htgCLQg!lV~F<_R702VU)ZmmmMxC z;6RX^0*S7HU0ei)xGo=cI<=du`&d|5KEr?(cF5Ssi2pwoq*My0fo0v;*ueY)$qf41 z39%P8PvCYFxMUv~_`)WiiaZjP5hKY==D3R{0t;K+puk38NQeMMFEWk-iF+Dx4d87X8 zy^9c5`iwctBd39uNI>8R-j zXgfcZbN>q>`!P4C;eY;aPkKfMRG~3DM|8x~jLyUTg6D&QINMg{ZdR|0bhOTcSI=Rh zO*-G+Y-(TtdM0@0IiBLi#+=QVQ;*2GVp0`vIBvZEhER`H;8+M3z4yQy2QQ&;;_Gv$ zn*63fm9>41UpPzt4uVE@A+IL4Bg!JiN(>lgWsxn_WI{)wL6$P`@neja4wG2EIhG;7 z&7{c)Xia;HoBqn!tgnFKK}~NM6_ICy=7b$_L;@HvPrl4M7u7M1_C%-T@xZ(Z(vb}e zP|jm)<)7S6NI00)i?XRfl|O|xq3`_2Nh+bj+S(6`{*lSJtkQRTunQ?koa+RWNH;-n za0H(}Fi@pxdYj)QIuLs)5{QN&O?&(SO|_HM{<}Q|1qEP8T3@)S^6vHBY;BDGyo_gO z`;K-|#RbfDcy+pjEq=o1u$Qqkhrn}u3EM=l$ti|9ywK9~S(bBnJ|E+$d$111A8YAK zW~a((6=tEyQ48Tb;(YdIJl#41Y4%e`D(HG<~< zY$8(`%bk$M-c=Ue@L}H~MfMA=0SKWLugG>U_}0NCL%o7N42{--1If942^UG|c=a^& z!6gMF5xUvVA80m2It6B2$0sFHEnP@jvT`-~5m0c`YPBG>iduzPPy;U}7Il#q~sBnCqw^9W7hIGpsi#~{E{y#x%xOm!LQv_hdCUZAZ% zit$}<0F!?E;~tGA0G~l;&Txuwa7yost(J+||E$*qrHkOq*apJLtvG1G&+xRiR*;M7 zKc<4zj1X>ru`H1k?QzQA91dH9|B~(zNfC98we9&dIOtcATh!T_m(3d16hwbS(f$Le zoN~Y~e)9An!g7^ZImW8Na<+?fq7SU3QDWzIL$*|HWl!C>j9wV_+TY_vFMf?I>HcvX zxH9v}D{E8v7hrQi9+nGY$p+?+UadLk));7#@}I?!iono}3COeekgd~XUktVnwn98`lGF zUkyB}GLZf^$aJlKi77u`C#~^2d@d}TJc?i1bURqHlDL-~9n5cDt~HXFJ}kA;##mBV z?6dyKPd2Oa#_h+|d$~>aI{&7!xHcTCUvs8cWl*SR$xuefCgJ%@1IBj7heBq|3C?xr zWIhyi@BQdFeM9-d-ffe?fJ67&zAnG!xZR=6ND?ZVm$}*?bEb*gI#BRR%HYAb{3oTf zJjw&P3&xi)*T5km{TXrrhMmhCQX0c80NiX$1o_r&t|ys&S`=-&cg61pYt7fsumVu7 zoJo>ZjBx)V;&Z1$LgX2b0L`}Hnmi>@O(24jftJ(ui)*}wHok(9z%c}YF(BV@`<+B9 zwcvj(Z|TAnL0CPUZXd1np457;dWf?ZmjV4mbH@hk z_;;|33Vv@w!|X z&RkH-q*jmJ@QhZ3_uzR%s-M1k_zox5R-hNO9n?KbLd&hQtPbT5dUalVo2GGfr1U}# z+O~1!+T~#llMpsJgqt8`byx=t9u#QE!l+c5KHq6$Cs_wOF!NigO^Q7AU1l4ZeexN3 z_YJioevd&@tV%a6hm&*Y-3wY``tBs^^E8i7q1g+63;j25o6@s|XgdUT}?f}?ZwY;h@Qs_a5HX0e&7 zL2xCi`$_(IW<^8NTdC6ulTp?w--xLvArx)`g;0qkBy3>9B&*jn`KWF0OImItEW+Bf zEX2eCX%K)$h;OlG2(uPoKqVoe$U<44f{#B4kzX4C1)(W}h0nsm7nhcem6h>C!qibU z9IF7f-kU=maQ5fREzeNeMcsO}JEi!YgyRs*c|@_`!Gpg6jlctspBw|JkHt%0Jk1OZ zuOZ1RnJrdVNaXHE%LT7WD=aR9o*$Fh0VN|8n>S2vEHbX@3MTiu903*qm2@S9+vx}^ zDCo01bqWU-l}t6>ZiV6#?EU!fztQ@u1be*{!$Bh}vJ0|rpbUpaw5C-~A z5u=d;N_8TulV31QqUQsW(>Cji?Sk<2J?}EjRcpks@BGxu)ARLiRQk(?Y+?pQa*D5R zg}wrvH*?2$gCyepg>+qwJ~L%5ko!|c@3Z)HS+T~0nmQ+sWgt7Lv!y4OpX3I~H-|nj zExHro=l<(^*O~_39G;&4*NGDqe)Ur!0ALvz86dIF2q?e(+GFa2H2Pyzfg#wWUY{eLHClnT~uXf0XViozn+G%(kHx%%Vxv=d?g2?F5PR<*>CG7$XoKKwA#lb zikQd2;{wmjlUvXIK+hKsw9HY~WPBx+rb-%({6QqcUUMd~u?#{f1L45P{Q5_GDij1y zD;auOO0sU|Vu#GUUo+*%XD^4Mx#j(E`u{H+;+iTiHme8(!R+x=T-K^$0(dQ$&2SBk z#}R<+|o$IK!2SqjKg_PMTy8oMsI&L?z;^m9gk#86vlSXg!caT@84s$4GUbG6imZ_ zNK}K?%Cz$5e*D0q33Bf)eP5{U$B@%%++{B}Gds)ZAm^Ka{0rznPoF&5LW2*C=0&$W zk^6hIMeqUI-Cr^4gm{s#^Wm&7arUFH#3d>l{gFOFQ}I)-0VMbpV1VY$@f!S4LED$(7r!2|qOeoq7YTR;+C9ZXhXmFKJQQ!UABZs6Sm4cj`0y*{bGh@!L{E5n%G{vq zN!mjZ31KW+-@!WM9JnoAHQXaQ`quMVF3g35e_8?GjW1_5+Rn5ihz%cdPafns!FrVl zdq(WjhK~|uKFC~o5Hr4n<&Fx>LM|klCFX&|Q*ntrVZlV(Gtx8bKqT%#+h=Ybt>qxc zZorcAdkju@tbAZH^!1hJ%SuqK*MG=%Vzya)Z?x8rkLA~q&iU({>st&2;EI~K4!Y2m z^4jFvlF|&|Lk$h#ZUm-%U3-`(c>v8E=0N<=yWZX_^!}Qoqz+<%2bxbm9RCv%|I0Z0DQ9jX2bU z0H0-|LZRt!kSy7 zSs4)gRS4h`F9i}3foy}9b+P!RdV~-ofVu9w-wT5C1Z#-$x>0z(T>F-TZzQs&kR$z!g~i2$8VkVb`uffi%7eXMFYZEc=HBGK76*O& z0?9};wYA%zAkdX_wXoI+|)$6{LT2YXQdTAuPPP0 zB|ifTE9h4WiQDLAW7+@w^B7dF4t^+^W zX7Y+d5|@7gJVye1{-fWplIWXXwm*EK>`<53+~t+QD&rG2HlLAIx(k#Cf!mb^Q77lh zukT1rRcU_yR;I;$TmJg%2QSH|i~sxYI{~%F3LC7dV(iBnU_?R#0K~k3v>`&=u_~4s zDP}FAcwggZfQv9qvq`xn()qoR&6z_&knT{a%27+zw()-djER+N|3=v$u z_Vl>9xZlqOYiFW}*`rWUe{j&3o2#q706&U*^@xs^13!DVg3kkUaaC}#UoxVO2+(>pv4z_h+9#d{v(BVVgJ|M@=og#^b^a6*Gpv zdGeVLJcZO`XX^6Q`=~_SgdenV*x&6O)4aQ}6Dpt`Sr9t%>67tatkSc{c4jmiPJ`5b zUCJSi+lweBq}_8>`N#y7`Yl;=!*nlO7) zu(pOw{f+kMhYz(d*y3N9%LP1mFa-b#9Ll=-zinu_x5XrS6cR;dX!FX zGV=F@jSQ0Ca(Wk+?H}pgqviig8L64ju62|+nXU9gg*E=UN%>ayUq__LD8i@|oYIZjD}_cP(8S)q z&)aSH&Zq9TNQkTQS0aB#3zf@xo2dJ_|F*xOKOlqwMr;DELfp4k=x?qfi#m@H`HK5i zZ8!eA)wc8c8qz31WB3)&h$KNY{Y~iWD&+bX=z~R%9NEIJBg7;E!2gZ= zieuzGT%Lr&@&<(0-st6{^$vHH?v28sk6l^+vCy@Y>aEIUn&`y|5@bZ?`Ic3QZw=Xu zbW^%!&*aEB*U*qhXvm4D_VmqS$7AfxK{JIjkDphk_HRd>^qYBOt|Lwn!hXbwE|O%2 z-&lAEu&ubXH1;;hoq?%RLYtS4C-j5y_6PSGq?OLR#%>L^gm%&DYoGvtTYEkO(wC+*c|55c#UpBCYdQ&LRUnYB;kTd{z8 z(4I)@^b^8oz+>aZ{g0ucL|j2rFv83sRm_&L^CUs58D{(A8tcW@l( z<$||eYQ$sa78WG0v;>pER-~B7U*KcM<^@Yj==6B#^`UiFQv`YZ{5kv7D~Q+-JtFwQ zVfBGBV4rTiTTwJwSb=I{(A z_u|yW{;BXU$r%~3*P^5XnjzT7?l@HoN=UA{PC8@9P9pcvHA8P_tDu}O zrUawJ^}xyKbM;qKg|H?~750$p@YUf2hICHWXr7?iJw(sZJ9pv`; zv=FOh=m8GVcu&JcvGQ1KS#!mk?l&gJW+>QE64C8^k-q@l zV%J<)KCUZNFzZPy`p=1mP>IlUB!%vko5LV2LFXAD!*I&VC?0~OM%yJs{4m_%q=^Q_ zm9N)nhdpm$?|!hZ{$K`%oG~{CL4faY-b~rU#IjF|@au@~e1X&ajRQ8`F8!vC(o+0z z$2Rdb`ep9o@OR$;vbnpth4b;c?}N({Zr*FMj191i5y>yQ+(S z;cjB3A=gP1U+U2)qcqw~cpkteN-RQq3^Nq;TwxvBLWiWJ9_8hQsY}MbHSB)``8(u@ z!NI>_=}CJpE+!VkdNl9Uz`P%x+3kImX{k#^jPQ06HfKd$oe21v36jbD^5x$^#teGq zQOl{Na-MiF0v*0G+atgtlG=p? z4FOF5NKMBNS>Q0=fQnLmXZ1u1>x0uVtR7g;_y1dKS!l`z<{`dB$i*shAJ|s|vrUYR zK?K?~1|DiYk!j*0$L`tq=hPcE176NaRweJufqAsADknuJ@|w11W#u+z+cH!L)z&J2ZvjCvCs|D}7JG&tg7)DhmVrb4gpWHYYmQvh zF}(lS7W2t@W@PE1=AfN0JXYsD*@`wEO+66%KEqhW;=;nbaLA(4Bu*spq`Tj@St8&s zhi38N5N3`=82Gf1U;1ujR63zBUjqt5pdwCHyH`=<&qF=HsE-Wte&J7#WB#=r^$8M`E2;8Xl9(JXkaWU4*(6N%?o$IfwNHE zIhuL#`AysKaUzv@){F;MvCWQ^@4fu>bzP2ft;YSi^&l#lb?<*S8+X3?f{t6GE=yx- z;mK~aB%9cCEV{~}?;psoT8|N5z^%B=!Zju?e5cgPcB~x!vAn)jM*&GLL_okDus7=3 ze9>K`X2Mnh-1e()9f~oa-Q-O!QxSPvEWd^Z0-Xh#ph3=F3>jj_?x94V#DN+lO@wx}-ss{T$q`SozyVGqR=?WjYh z*0xr4M00%8;jZH?a(5|xGYy9ud?eH3jflF%^@+Xs4~x0To+*_emNRy2Y4Ux|ug5Jm zOYOpZM?5sZn(Q9kMFEC0`d#*}F<5i2>*yoLkXKl{@Js_KzaJPg8w@qLh@@`0kcBX| zTFVU)A8J*sEG>0|H5d)l8FbQC(gY_soMr?*7d+M@)x=Bh%WWrz(RU}9xAK+A)qNA?J5?;`zPe zHz4Eo&7|$7PjkPwd;YM1!qR)~)={^}xPkSS!^q~~Bw{heWGmj~ZFN`ayA~G1KRuH> zF3T3DVLwB3DK-NYS2CT3IwJ2M6-7E2n>cRv2S!CCR1CBfSh+66Y&g_%HIHUf*OZQe zbW_AlUy_BUpoK_JsnY^xsh7tepQ?72c*zuKHM4I}fz9SYguuZRD=du<#yZW!v}ln? z>Nk_#9>Wy^l9KMAAQ0YPUP{QOY{Jw^@YqAHh|NsAFmkz)A#@DD3cPMoLPFCB@+PDj zlay8zPmvzVewBf(vuYgDo>Vt1wj6W%=Ka#_;1lbn(D3h=Gx= z(xSMd6 zmk%C9c(cY!f=SCzaO)?)eYg4en3>z?9|J;zhhLq6il8|l&=uHb0GQUL_g;Dm-b(+} zpFlnY62X3(q)O>R6Iy9)t_l??o=rkQn>4B(kxuRUi7^niN>y-U9F-rb>a}0mDv0d4 zUZ79Ur?w4aC6OxvPlqchr@V6BtFu9)e3z$OxAu zRMN_^Vz!qQvvLEbs9)(yuwb{Xog&{_P)Fg%ALLmqiO}jms3XUYfxB7nz||Y)$Qo?R z+a~i8m3z6lT|X@S)qb^ymlR8Iyis$s&i76)qa%W$sCvYkMBhEa!nP9uI7cLi9{K38A|2rQL(BWcx7+Na{WSj5;< z&SABDuM2BhVksW7l)-(K-G|i8d4a3Y2_Ebi^bLcTqz4T%G zrSohJ6=Tt9LH}lfRNc3AT&Hf|X`1(NcaL!5F7t~j_RfZ<00%Kb(vyB1P*xssmOpnR zFEnaYn3S*{6A~GMY#nGb1dWitfR?5t%6gDVIiTxGd7BH7^6(NMkdF7~?+ttTIGqRZ z-O?Wz$cwn~rTntZ1Uu2NFLFw<6*5f_AraYkY=XJSyb_HD$e0qEM7`AX^ypuoQ5|mj z|8L7L%bnLpIx9kQ-%-9|CY6OX&or*06<%Ov&F1buv}#*Dv*)fEX1dJrXDq-#5Y`Z{ zSHJ=I7s44GIgX z8yqD2(9Ieuo6c5u5?|z_7Y(34S{+8d?Oj#2x&zS~U!elS8$51s|6~{{tTS1%`}*R~dXaM`gCWZ+8szjTqjND=T6t z={>x>@gmw|y~tcV%0X!E^tV@IuU)G947G-=zbMdqw(RS58K5HiBWOVLe*c!n8D+}y z=PlNsMD{I?MBbhJdazn~Mc83uIhcH>f-zT^QpePYe(2_x%aJE|YPT65aLUXFQHN+N z8bmmX>->EbXFsn}B zboLBON^ec~@f*_z_bK}{a<>W=zuC6F5n@Ufxwg_~wZRi1JM_wqveoq5_S=rzNm_x+ z=zJdy#h;V*H{i}x6SlzuR$^Kj%7ZaKUz_c;`lo6BSNQAVM)$1dNQdClCG0L$#diy5 z1{fizvyqkc1m&=Y`E+WRvtB{+%}}}Pl&PDE4_WWriRm*iBdxmG$dLnNR#->(cq}*H z9x2JUp0jRAki_ zSYEz-u;0~@2}^dx3%^pxrd&1*D%E^w-LyahB^vl>2umaL&S8B6|J5hl53CT;gXalc zg}5FLG=uoSk#@ufBOjdV!wg*4V3&{~31u^EFK=$RRM$O!jt1j*(6u~JD*4I&C`C;i1aL5UHZfrWSg`U%OImFMdL z=IWge#g>xC*>5$j^SxmSIoTRq-Ypg;sg+)CE^Z>qtncE@&p`2abkX120a_1WyKvj5 z4ODhvcx>gs#U0IaE$uuY{ro^vNG97fUTiKYl!Xt-tWcw|D9Y@&R=f#{vX@0hWn{vBoW^KVuIVp%NeVxv09-Os1RzN@mTg4f(_?e-8Q;1V zd#k3<5X@UZCoeX(gh`zk=tpQDB>%=DxJ{rS68fq1zO6;*s`{GGh__@6M_}o5#hpJ; z>!DjiK3J6C0A?15Uf+JRuQDqbt;eK;99D{{I2@5nV?p?Pu=-z8U~d)$V|0CUAeSV( z8EUzbk3agD^=|Wmcgx3iHlWrv4^KTJC@z?3yGi~M*80JD%TL&VfbIc1Eio(X+kXaq zLK+P@#U*5dH#~`&*$t!PR7?p>>QP_{VBCdxGe+%|9=lVZy+o9i1w!9C7G2D4YAU*-&2QlI|PV{q^mBRRyw>AxWmg~#s_*_mOx0|_sOZnA1S|&v+ zg|c@EKXqq?YWF;ny14dBz#~w)xUaNw!T(65V}5dMD$CiMyL5l${?L8MH`R@{FKF}B zw9=Dc-jMuYVZ^_}hB`!izEvlk=#>xUVmfAXF> zm2!;3IL$47(521irL18S>5uO;!*=J+&biYGvju)t&5RAP{5B(&PgAHT94fD08aB`+ z-XeGM(7on&gX(Uz7R2vgw%EV#_}sHb_f`V}xR%|6&rh#vpHyD$GU$(G3AuWFg`}f^ zSV#K60Y+6{#4kL3Y40cKE&)D@>rwXk4XWyk&yDD9%P zB#Tm-<69EkcgmHN_*p+`T7RKwA)4xV#grHyk0%EB29%6AQ7S|`8uXSsuEE!N21Xf4@)OH#Z=CVg8lnGE&97|f{ud$c<>t!4-eHO@DqrXa19Iy z2Ejam)ULGTH(+qT0TR=074J<2y)uIkv%^;ogkD@QGFoSzSGBX+hTDYWk{`L>0Np zoQXG<)M>KJ@4X2LCVF79en~E)Q1+jM>pyiRqW5tEO4K*8;8#~)KhnH66b}eAUi@=d zd5i=%hNUyV+GYyV#6Mx3bWu@d;qg1)V`8cD&FB2<^Syu(@vmr;D0gN?tVK`LE>k3b z(YZTnvON`Y+L7#g`PpZ;EM>i(PZCUIZHe8d%yGH^+cWFbM@|C@DFNUDujmCVMq8Wo zzauz&aRlNxBk$s{u_?8LE07Q(hSh8AjAsFo!ZM_L$4#+_NI#ashQD~AF7VHHwr<^^?+`dL?-%nwBZx|?H+%Wko{-3OpE9)gs&t0Gt2rNwA z9|9Z;drs);ho{EDKjIYI!(3}6{kF5y>Z$8-X!kDWwe7vUGSfw{$S|~VK~1H-_b6F1 ziX~EV37a0EsKSwqLkl!j=*G${64!>XliaoTTtmPz<}F~v*e550WqfxGs;aAy;(5gJ z6_w6hffIjiTw6|l)Z@Z&?OPXABCoeqJ#6A5LerglefQdxD>Zp`(E)JCN(*qAY=VPKd*7p z39ch5DiC5u88`;d1HEJLH&J?MAWR6hfkuyUl0V(3r@I>?rSA8&LRv8v8VET*mfM+q zn8&2lGCVzqr&S4+5_^ws@WxTSLxc|Q2>Mz&BYEIhgoIm&36fL@p_MCP{C=saBnihP z68Q4kys2C={M&q^T3?X~xko83kX5bxHio(_QJ=UK|8xV^c@gaoJ4?JA_G71z7gMyQNjq|`mSMq-b}JJCzkkmO6T z&2r5-N!$+~OBicy4YU6@V8}P{(m^+vl381xU9%I0{fgiZw#9VfH-n~Tk~ld&PqVA0 zJ>M6}+axis93nPZ6*W5`Wkte#F*oTQ#6KjzS{k`)NH`yf?|&5C>G@FT(=7+7WFfKc z{;x_S+%giR4@4-4iNK6m*M>*za0tMYf~4?6Ky5FI}@gY*1WLAxazT zSTdx@PWx;|SxQC^>1Y}b6JKuimN>zg^@`q-p?c)zfoAHr&R_O1Zcd$_E28t;BX+iH zqPo7tzzNGU?A3W`a3snG`1NGRf?;6z+bV zr!loP+L}$eVKeV_ME1(b+!P(qpz+TBnn&xo{dDg_SGfCnkegefSH4eXT3S>ZC5f$( z{jah25?Ex06WO9d!=5&hGSGwj2zF&);fLeT@Uw{NS#Eg{t|f?cA|&Te5P`}7!z-E^ zx2hr}{F2AOcvmu|>&g3+jb&N^PAA2z$JkWXZA}u+5133%?LJ>QeA8!7oY4NF@n{G~ zRDL9r7f1R?-U0X|NN<^CU6hat&qInbohjg zhOw-c$?x=!zjasD;eA2JhZcjF#|9miXzD!774`=voCLL7JycDh?9kTMhI(LCD=$Kv zEdd~<4}9l!HeiyWp2G_f%r+RG{W>sD17LEf7Vr_g?cfO;LMTiVM1wOxat}BmTSoSt zfFMb70L((LSz|dk_5!P`Hz6BGm`PKIE-nSXu2Sgs=i8dqfUZA3b;E9LJuh(?nYW0M zGw}M_;Zlra0NSdsi_l>|G|jmOGmt-pKnGf9xs1cuDh4VP8CaQczkJGqY31(S)9228 z864DcutPaP{eq9f>(Zs@FqzNtAc$rv3c{G{zYr*V0ckQ$U%i55NPzKgI^N>Vz0EkF zEzCsNhk&d9y1LI5?Yv^=Ep>)K2W|DEynFLDA(%J$28*{rFNv6xyekAOlh*}UY*ABb zDWFiNJ+j?nQ5Gvt$WcQQ;_(Hl^scG0_1W$6oOl8ih}Bux_DVL`PclGknIX44ipr=h9AQ6BNeE z&!UdXiSFfA%KP{u9DA%q2@;w2y$!zoXPrg4U87xjZg)gpSqHW}J3H&G3Z5Lhfm(iE z{ljOlq7nXo9G!PK)@>ig?Y&n*LN-}RLUyvUAKmsyLJ~q!$;_6Stn5-*KLwvxg%TAL^s+vqR_nZZJT8(-`o<;-fZX5$D7? zLO$NTQ%FZSwt~z{B!0s}E3g@6C7BPd3^C&^MN!CI=l313yAgr$7b)0+dO-JZaB)pe zOdLFPh?td=VJ~&?Ag7zMnsB0`kVG)+6Gkx?Cf8#T#Vm%J|LJh==be3;EnNPZAWkAy zyXcxgrAC^eyw<+xEGnU(#FjDVSUC%JlS;nHqll(Af4%i1nfnDd_k^A_=`_fg>7Bjr z5O5%#pLS|nbGEnQ9ksw)icbUDlnn=N3y(G@vY+p>K6u1>ZtuH5^TSkRbswC*HahED z`YMGV9k1>b9X#5`O3BViW4`yLaY?yA*OlHdx26x?ds)6cJDc~GbZ}wU^ng-Zb)f)KADk-#;6z@6^j-A2oCt7Y-OCb@^@H$9lOv@AXo(#_yDW z5X4d3I%hJxT+@7va6?e+qVL<7m@0OzUGK$oUG36CB+qib4q2EGudK`Px>|L zJ+QRQR?6Q*cK1qca#2>@d|K3j@*kd&{de^YC{9rux4;64{npol$ZyPFXkA}vzK=`k z%l%|wSf+sY5A&&M=s6+jWD-T7V{CAl-sus~M!15-ZM|Ux` zO1u7A`WAUv5mRoMwDkKSt)V~rbR{!ymzGX{3X;BV_2NWoD&3?tN^1pLRI1p?*3Tep zMQcD}BOlIn90z_15)yGbV}%O(Wuu54*5{5miR?UVnZC6nHx`kMS8CxH0sAy;;mh}} z)bk2zycuJES6EgiB{M(576%Ur+(U~q=&j3)vV>xGv2|&#BwXk8fA3jjb~7bK4!tV= z-q?0Tk92o;YevE-jrepZrqIEHxy0+!EC_p9?jk;swZXogq2=nao&I<0@P47~KG~ff zz<-5DgXI=wY^UA^5n};-4pGE+*n3)zKm#cm#?V0RF~yMc{N%W`SFkHhQvg`3Rs?wo zJVir?g|4`Ff$6bRmQH>ghMfvW{_GR<&uez9c7p2_jK_6WSXW1RtA}MfK5k%!v*)4p z-784)isYxXLiavx)&K(8hRqLqj2@jX1RBY!vDnz`JK}UwPKTS*c!uE)F);!!+qFa4lbj&R!t@c>E%IuOvW@bJ3V`yRuwt9s#Dt!KS4q0EVL(KJ z(&3tIKGeGyx3JREbrVsfcoeZQt*We~2;)W$A2NZ^pClV#EsSCeJJtBAdV84yITvj( zXJC)RZAE(cV*0ZN?BrpGL{(Y%Mo9h>K35Jp%GzpAt~vZ3@#k3Yr(|Uv0^z2}tQqGu zMqR>c2(cguuDf62!}ya~|)iss&LMB{Hys&gY9qJmA#C8K+o zdS5o4>0s>sh83{$?OtK6-QIFfUK_h@Yp(qy{AKk^yQw-e`;D&qr_N@EV$}aL4nN@L zM`0wlv?OtuOmofO+`22aY|tZlkD;7>E95sjRvrxheOUB2n54Y8HVq0 zmq_R^&>64HV(g)Jg2<7>fXBU_^hM9N)`e{u4!Uh9z{QTkyu1Q^ToyS^oqsgpwOKag z^0I9kY)6K?m3H8ILB=i--JB1pney)%G;|T66ZwE4VtjWMH(S>=#u6zgIiXO1r(E;& zt&oorHJmfHZA;g9Vd}hy4O?-skP2-dclQged#9N6-ly8~?O43$T??UQD5clTrlpER zk5z7AtOv)6u7ks#E#q;)9^d-v!hyfr)M96tPVl6jqfV#n{8i7S7n@PCU^?opp=zjOUFW!$Q z_LOPugYOTXD{e)dqR?ZBY?d6;sIYm;+OC^Wzv(MYe{+m(h1~lwL$Vy#lLl|slcC&i ze#w<<3eeC}bU3FZRhO;Ypt<~vhN8V@GtK41L`~zMYnCE&?S_uhMJbKl;({KJLvIM> za&TTfbq=uaj=kzB=>oSFK25V(Jh_v8IiNu2&Ku|1ymQOcdg;kx6C`aB+loqKOU&_8 zFJ#~DyEP}ZPib{=${ zJUrKKZt9%>cI{(F_9^+vuHs<6&;os7?KJD(%YxBlp4~urqXyFLP0jC8Hs+1h3z-G{LVFGqOU# zE^XJSbIjD5PK*@yYpG$oh1$n!&|8VTh4KX5XGynVv%6Nf&zh$^6gzpq{lHy^L99^G z@bWL-Bt7pu%`5a2WWH)r$Xh5EZ|5#t5YQw0FUgU{kbd?_m;ZQIP-FPd*L-n==f*Ey zoNT|}4tDg! z>kDi<|KwgLoUK_kCm>SJjXboS1zR)-R=8CH$_>l3uW`G5;@1dOQ;0*LGg9zM)84C@ zA(Ie#Pl#E+{zcp3`w&I0pkz4BmGZ$?NDlc;fbXNA;QE?zMqoBSF|MYNktyaR4my&v z0IRwv@*Lzz8geIx*QXSNWf87~qnO=f8n#z^-#LAnVsd{{_u@d7Z12mmu@5+g@KPf; z1l{No*Wj~ftO-3x@g#1Rxlc-FdcmqCluEPp26G6^uCfLWBJ2u|e#lGQ_y9lwVVTm6Uj$eQKQXzG)Vw=C8c(FUOdMO~CWT1vUmMwDDnDC-W&ze~|2# zqLttWHjSt*!9%f!05Q>)f`!%`kn6TaqUopH-IKZHJKgI)&9*+MXyyHtGxVMqi305` zIP1-akWK?vDnzJgicssX&oJZIj z0`Wk=NmVBd@-IZjFY*p|d1*52QE9*u2#Oh)%me)Kf}xj#IL1X*=QEp9fzsm*q*+V5 zshxh#cZ1hR14?(;r#c@;csvq_41Yi!BEhS(-m_c)wY|B*L9-LkVq;B!n&Y?_C*4uN z0ty%7U50%?=pEDTMiD#B-`Wi1CQ+?4Bqx`s4-5bFWNGg_ z6##ZdJ^O)#9M&ey@X1%{mlVA2PN%GGKV}>EB0SA-&xmR-lq2QvQcHg>tE z2}=PRjd0>a?#aGV(>D$0q6W&(6s=3l)BeYFzw`^RWUg@(JthCbI(;`eRd}y{k#VsJ z&v4!|aDgB07#ts~G}uXgOFap;qRh!8)v^u3ftuiJ{u%J1BX zc`i_-*2&}H>3$$BW}d2cMOn<|B7`sR#XQ)}C;e0PyVKkehAwY6tajWXs(wK`u-=3D z*7eKBWv=g-W*pmL!TbVtswDjwS9xI@Vyg(Qy0OaYQMF{pj3|qimhtZ~|mzTD< z*a};0$6@Fg6CH$=Ae1NOTfd?m_tdyMqyCHPJ4V1EMUCqR6mIvz_5cW zI!D6}{#M^t%tpikaO@iQg0c5eXkNZOL0LppB}Y z8Ox9EJQ;lTi9n413S~D86JkN2%q_*yBK9&!>OUerC}A8H#eQfm?3s0 z#yA#{tV(~ZDu!9G< zq+&ucRbTT4S#?gNk*zeBup#CzeJ!b(nQ*(~If>!iE&T~NxnMV$H(^xPi0jc)>(uGk zw>LJDWs+5A;luwmtt75^SOHH!RvWw#xJuglcO~@Tv%{SaF@VlLrqVSNBO^Rdsv#TV zt`SJ?1yTtusF@s>4&-F8nPS1Ll#7fTR~sE^h2@pcpI@~EN;Ti7i!QFIk%KX2Qt~v( z*DWxBuRwheoPotMp4uZv+OTPEYT}Y0*vp&<2?>EH3u|N?fh8jzj~u^XiH)moE$9-y z0QU2hk5N&ggw$-=b)?ez491QZEjq+3gwVwDOLT*;y)YO%+9>LJPf<6 ztEY6fl114P6?0hSce4nS@d6|O&?Im{@30Za6o!K?Gb2OVu1OjTzFX2Sr|OI+`hNH{ z+M?n6jrFw71&PClFAn7##KRBrkAfi@Jz!+4ZEZskVFMG>4jVCOSqxSMB)Q~bRSq&0 z^n+Ig`4_RMM}+P2i3H?C1gx+qPPi##J62zX>tSqkGz=_E$7*~x4;mUCZGNm+Yyeac z!w6LwndJ2K9Cr~qfo}eQoqyYKS&=1sLlOMw+fxiW3h*T`Y}|qfTvrG|*)8a*LbqlY ze13ndFxA*Hu9(=o{22JX)5yPopcUWqIvQqqQleHE0Xh?_KML9Wtx1}62Hvg1R7Q*j zzzUryMyJRO`8X6>Z7COSZ+lEz7wWsFB;$4xV42330)=#O9PT!3xR9qV?ziX%t2`K* zmq3DHxi7$#kR@$HCMNo0?gv_t= zE*Kbin||V(S}A0Ui#of|*Fo+rtsvR`H)#4*#I;2HaFssY@`7+tquabD`w?~~Vqci_8 z1Z1^!R{bU`Ta#-TaCxbpJX=qdF!3q)NWdlPKv9~Wc@n&$d9kQVJfch$}T0#L#Lfj z-an-(&uhr5l=9 zIUA{c@q@5r(Nz80#@P|$**K%Hka>JMxmNMFPMHPcZUr}tD`JN*1;V?ArR>ES|My*1s1TF&^jWQX1j?p!p^nGnIN*%-7 zZxR7+TvLQOGS%{l2+aN(<9DugsMq&0F}9-=@Lhd%)j|Y1fSR( zeoD~RjxZKIgXibmImFW9?j+VT1_nM4W)anSos}UL(O=LmBdG3!8h}VZ8JUSoR@~d< zW9oC(Ovy<9z$kKzO50dlAsdqto%cLXY1J*~uhIm2wj-83w!k}$w5i(pr*<;C^yH!g zZg86_>-fJI43MgjdVr)ZRDYV{+P*DLn4S(u2`b;b&X6Ta{^wKBCW*txuOCzNXBJP!V%p5HLi48K+K?z)Lih&waXT}o3*KeT zy!i&UHfb1JLJx$|A#Jam<$RrIwCDt{GDqT++bp@R-kHp+RW{{t!GRF%s8q_*bOJus zj}sNJ4>>2}gWg%sdQy4+ekg1cg;WPH=LOIK(c~w$H-8Y@W~wJOHD7Po`N;DmK~M(G zTCT<20A)Trdp31$&sx7cvh-UAz8>HvTw5qS@l(T6y9NC$X%$o+b68E{lm7Dfy9r=@ z1ZhMo5h1a_L1%pyW5MqWQoVE2HKY;oC}W6f2prr$&?$CO{U^?Ws>#02jn>_;?w^am z734nf9`b>}3qg9Lr3SdrPGy%8EMO;t_yG`b>>Fg#pIJ#xU@U`olE~0wlZ04F%DPhD z&~S6;TxeuAB&9^)25Mv4*6H;J?0gsaqyN?h@9_>$a!uwOJ(+)NSmGk$aq;2E)j;UC z1VfqY>(W4kjK2BS{>{q9#(*$+Jpi1*lxuv0o00OWe+1*thqs^@zWKG)yOW{_0Iv0&v5PN`{7MnbNKdcFr3;Nn##=Y9 z_oz0*#=8wl?ICS}<(t)pPU{To>8bk#x$eWL-^U6uY0a_w0N`6xacx}7z};z85Il%6>LMRJ#e z4$Z#dm%;AhU@IQ?;yXN^@N{iQlQ-k6q{&7IVMprB`8O5LaqBwIXda#J6$xjPRtaAa zCO33(z3JiU)tku4K-Dw2Tiv6}B%a3UEsaJhd2DC?bVzrm_cyA#|IEmx!&z@jhO&r6 z*_khK?r1vW&FyW$ z$-%7c;^h9Hib8K4EsS3Pz`n-t^qdxm3;UySZq7)h$qrch|J38c{UJr*-hDRS_jU_BN0a=Y-zOOp#PrjHCY zJ72dOP&_cKM&)6b+Em6$V;-c)J@Y`5#%Ijob4lQ%HQzvs^(ck4luITd&f)fd&}c_kqAr6 zB;IWD#mGuKE5{9*8NAaUOaF#y2tC2tvwBL3HR00Ve;7m+g&ah#7Jj~!Ucl`jOfxL= zM+go#x%g3L!ucwTyRI<|21u>liX6T(?{jAsxobExgyXcC4(>&#TV?lPcwa9jbwzgL zr%t-MB2V@{Op_G9NQ5xIn}TJ{zbX~m(`X?u+Pt!a_U(J;e|R=?=FiOo5^F~qPGvoI z?EX4k8H@Z|Oq<1vJ$6T&nF%>BG(A#(zEQ|6adhY7VZk(=QJ*!19=a_-qrwpnV$+`u z*G|MiJ_g~nR*UTCZ$6WB25j3O|LTxK5*A)_8;U%X`WBv?dnm~IHp@uA3vx-B=mavI z2{laEldH6Kcq{mXRN{0MFzmu1#xx#7_r%H3@i7px zKomt=%)I%IdSBM*@n4X^&uhwsCA!Id40*=%<1mTa8kO^KPc_T&MJ6Gwr=KVYX)3vW zivs7DJP{_1T`y5pOb`!hy#k*gFvQGPf0Jti*i(ljg5M+OB`n%0=Y%_MDE|;@mEcN% zAj^h8h~-y8xeY99O1wgD38!N*269-#0sdUN)aKj~f%EUYnoado>(!lqxDnDXO%)$a zAkOsg+s<+fiL-D<&{}7fQ)qTpR?M>(&T__BN8!uUMMv~$|JIS_ss^#|bx#6!1N6!- z5Zpr;IIYCKKu>a1EGsMPDlSsYZo%KsE0GrxdJQLw+#lDhIi&XJq$VeQx%APHk6Gb)zv-@QwmLo)FxhfR-qHL7o| z)nhkpP!%c>>`Lj|I`77)ov|9)zt9mtv6p5i{irf;x!)bqDjlA1X#=@jp)>#_1sBKyKFo5s|WCG%DB7M&v+~Hq_#yo2qQ>Chlrnts+hj` z13j%$r%>fT4cWYVa=9-*{d8Vcy+pe>2_de}1K`f;U^Wc!;NsvkCq&a7l$IzeDk3o3 z#8X7o0`}Ckg!wj?0KP9=FK6m~X2NIX*zn^T3iy}Zw(1442M(m?-;8XO{&rU3zyXP?<4AG#_sdIp zg@GN`E2bg3DU!7G^pQ4&FP3$V`vwFAAUP;(0O6et4T@}>w;!@R0pr^$--KG7!7zq; z>G+G{%fwrq;f?b$`gS1>5t{NDF7FbN4e_OG1q1H@M8vCTYGbq8cL~6;mr`|E!yf|h zf<1;!j71|RCWd;Wljd(CH>&VfG)i|1_WDw4zC>r_T3d3MVLT2$hZ}Fr-K4lU&=-L7 z9zFmSIatoohrcaKhesy0qZpiP38c58X`P3`b3)8kGcXO$c*GjVWD3&VDGu0w069b^{-#?UcMR~_5bnlze;Ab2M9PtnlOd})iy)g zc;f#Kas|U+9;mFVW0*wdiQ%RxibBf2l&&vo=U+h*K4%&?Ar0OrbiA zxa}Mi#y{LJNo)C@=W>K2a$vBfYbjnDFt}wiI6>Jl93f`B`DP)sW z)>^cFjX8O@8P-ayMzE74?IFy}WN{`I&6Cj@x!!~hCrOXIO9+a#WXc?C*=>qbd03$A z{C%;5PGJcjb0HIMP^@MNkAiDEoCUs0I>)ie<$=2SlA_pv^%vVRiSbfwERD4YMbWN~ zRpB&)?^{!xF2Apqw{CL_6<{?3@I(qvuv}8$U&}LRRsn&twXun_|9^Jl-o3I>TJZnC zsFH@=Ef+8vFVwy#!m?SVU2yOIoa@V(8C(b#u=COk1R9uxMe+u(bfzI(-o(IT<*~5p zx_}R(o@2KurNbDM$;dy(oxhV$9Lt82#c_iTJQAG7ojY`aJE)< z2i*d2b`VSa$I{c|>Ua6_Uvg$O&2<{9n$cw*ZB9qb!oWDiON=YXGtXW=Eotd9QW$b;%MUkR&l)zw$+mz8Da6^z!F$Ni6*F5rx-FNBcS zu3dwwo;YMZdKKmjyn&Ea?$aRIMdMB9olDk9c|m}}RYaZK*3&m|sFI4QLWSEmthSGB zVDsboa%zrOY}SHQTlbAcOAO;2y_l$5AVfHvETi{mAhhLoY((($y1*jouzftSNqe2e z8Au}TZC*80|#*)od?%@3Q9fz9Awt`AKP zE5AH2l3n@DWM`MuBa;^PNt+ne-PYEm`T39ElR6KF_0t4jyx>k* zc(*)DM*Ap##t1rDsF8guHFp~>#$-Ix*1_SnC%#+oq9}cdTT^pXaK>7oL~$%ON%8R( z6%#U9^zBSi;nz5AWU^wSqkDRLb^IG(`S|a-GGhtT81IR3?a;2u4IZx3olSnUH))ZW zg(;+v5g|*3&P3TjZU#jTFmORq>!s@aWh+0cw z?YlcE?S*FM_ABY}B)v!Kl_&H+@xlhj)3%B`oNp@o3Av3fdQiL)uYGo@TA1y9A}3uB zjOCz=e&ju%pwMhj_Y#^v;CHX{9cF901NOq~!6goEW2rFJ^dm`4*N8|^5DM|16fIM# zRMiykD|B;3Z-J^0-gOxPB}~FgMz9YU{19Lv%4={+jc4Ap!1wUO84)qD5&?EWv*7># zb5)3*Ec&4y9FC|~&Utx}t+T>CZnnR}=fP>9^@N231r5WOWsxPPo-;(7Lg<*g=jt#8dW@9q|tWi4R30=u6+HL<+kF^V#*%P7k2E0np`XGQy(SsV4Xw%07VD=z;RR`rQoG(~p>gl5S zx2|?P+h`l7YS80^GXs{sr>=B|-vOoaW$xj3)Cj@RNGq_zA@5jyOkOot68yMDBnfFw z0I~!JA2gVoSoVZq=2|WJxaT$K^Z3iRSD*ZoqH40Odfwm9b0lN~`-nbM-8*AMwi*Vy zL<1g_>)!+*Hhl8CN0vCpIlpW&{&^*!1^WfWCRD?Nf=!C>YCc5&Y?f)<9puQVTAO?b82hMzSx>+Ak-6dcs+oX8D1uW5dwNpe3S zHEzb+=ggT)?=nxYT*rBYPG%0`AUFaBTEP3+a~L3n)O{YO!4|^&Bis1rQp{e&)1-QF z-U3sgeum*xOBqO} zPrk}f-?C<=c@>+f8B23vUxcT387#KatG3O)f&wl9tQM;pK$zN{YZGbtq>EnwMsBUx zPvFQk&vC_viHfvv_XY*%wg?`8jtHVhtt+pAD1!(ImVnvT3$hM}_8>xgy5epIUtNnv4%BvUzOn6oevB<3zTT}CSge>OB5 z$k@<@I3IE}URNOg4Y~Hb zv>{ECz|;B(uQHmL zva!W>H$!m{XDp~A)1;+YG^wFpdvB!D9sZ1S5N|}kJY0uny5H!nD>7?*eHPH3c#v=> z=~-!8%GlG0*M~!vJG%M<(jE7Dum&jHBK;LYt-g(!nkDSN@bv4oy}PMM;^Drl+ihIO zsjFI2g_&rND{17wmtS43+8%fJ^EO1E+@U6ss#T0N*PY#Y|K8nA+?+1)6+<~)mq1~) zR8ActC&OrzyKBnkHe>|X_O&?tOGuN~J-AXg+|ao*N*88MoSA=~fd6}Vm_*$juqs}$ zfR=gnr_`-b61BDM33Uu$hR>^6#;%B7BL4aF=U8j!&5Vyny|$2ZDKCQagxvNklK8Zn zPjqv*)wk@^oFFj!s!4o&$-~1l%$l2%gY0$->EZ?Yt<3lHCzusS(;YJ6SKl|Z+`Ols&m_a5lf)UH2i^IDoQrC7rR+m z!*i}X(#;SQP68zDSlwHS6jA3x$1D9pve-q939Ui<~jK?AvX?AF=RFPfsrh-So5vm<9q?8_{$~fMNON2O_;)+1S9K=tt5!US>c?5S-XUJ!eH6{m{`g^(?BXg)&I)vUcP#Df5J;p zEc#g}4&aozx1Wc-m0n~Ypf);WO;R#aZYIu;89sRS(h@vLRBkSxU3yYfB*n#V;^k{P zH9QB3J#?}Zuia5zw>9;?k&u#V4c%4=G1VATS%m(uA3c6_RFty>nwMAuQIk9xDUzEi zXdsT@do$J8R^+`;nSM)mWOrP?<{{aepKDV>+LzvKOP7@$p&hpKX#H2-_qKHMX-8oj z$N4w7$8{PhIH)Wi+;;w*efri6^3vi&gwnqv@{OU5W@z;4-aV=M#5L0irz?$+t0}g^ zUXOG9?0n0?#j&E~-6h&(7L*hb&9VD#iazw#bh$qE;W8-Aa*_fYuL?Ldq3E9X!I}Bs z$u=>CQrf0UW2%Q~;uFyOZCg+Vf{|9Sk;EL4c#YV#NoswF(a>>I;A&1iPHzxcNy zKtrMZ8UKMxJbd8dvc|ZRjjcbzldy?6H`g4V?GH5rH0aR2p?rSq{|@{R*tJ42{N4!J zhNv*E+*BwP1_~L67R>K3`j5eO9of)KU}f4?VX1>4?V^__ZrDI^Hk5l|ZCbE$co!vW zjkD?3V72leB@HKQz5zn;2;+Yw!NopR$M2{_FNy6ILO_YqG@{d2`-OY`5dg<=hP?S! zL*Lh~U5L*ZM=RC<9c|nVQ(-G46e9tFfyA2#=d!n_C(3wKiKr&v7sE0KNbYNW@6$w# zAkP3 zw9X*HJ62y z$sKmpBH)D*rIHJ}94YPECnv2bjBs?jNeDF97Ctv5NA5#vhm<~}Yc2PL;B?9&6+l2^ zAG#M(C~p57V$^p@*^_d8{F3O+ir~-J9h`E<3@RzS#jmJ4VTFZw&a;@a;n&vEqEbns znIhkU*&3Qu=Vu=SRHE4w{+(-)W=@{(e!iVyR8>hH>4CIQtK1|}1WT&0Mq2F`ByyaU z{Lu+P`n=jJ<^#88jKb+plNnK{-{9H)@43EAZ-jAst?xUnz=IwAYq~mXRB|eDk^7(0 z1=+P{>%U(cJWlPIv*`ASpg+F+y_S(Xg-b?QiQ1~E^nsow+ti`Zaq=*S5opwL0omKz z!{?~ffg|0kar2p2LFK)Bo%|%73KV=JvPcF+!wv~|$`Z&tnJ<2^k&P&V&HGD$5#DI| z(7(ZM&BQeuAdUGZQ!ihtK+FAPq2Sgnywm+S0Wc*R`=FP|%{tsJ<=m~XGA%kiy_k$a zjIN#@-t7Ra4#oc&;`U@@#EKCb9T3q->2~q*R#;b@sIrCJYvdDDyQvDfpbit?pxoSj zLPCS?--$?h&W){Yb!X1-rl8L9$iQ7(KO}9RdpZv5WDmWE$q)TjXEK+X_|ogW{QQpr zsQ_fa!wV1>In_A+^{eMd24U-l4z+Tr6oS@zcgBM(2Ex8UPOne`yw z!{$22IjmC`twvaXi%BECvY!^9Ka6`ZcHpV$rB9$(DVE-ob40xD6MKirV27ru&*Nja zcjumSi-va!SYS)jBO-q{K|d>JO>x=Iy836Ai_&?>+O72C0tkq_3)1x-qC3No{N^{; zq`{nVJGG#tAHyTqI?HD}f8}j7JagNec@g?FKBoS^dBq`BzGNK2hX7=BDDWhSZBFak zyBs6A5UvSH6H!>hnTs;jCK@M!PQC73D*)@~U29o-l3tbpFS7Y#^~8O9tp{khu#y>G z{SmxZT5tos5|DIS*|9yY1|}x_3T}P);jj@v=s84j+S=L(Q(E{UD=jUJ-;SapmIrYB zI5#j^J5ZN}K$P5N>7mFYTndptXy>#tStfUkpb#=PuEdz*df(~~T#`_aleRB$-N%dx zyJB!VN<@Pp&Bw<~M&=>M4OtxAvf`?urI%oJ#Ri;d{2EckB`SJ5>z=$V!Me)!AigZ- z6=ZLclalh4&gN(riAzeVVEe(Or>h%Q6^!KsAg;);fE)`eVF}CIzCXtFH@UdDZe(T( zmN_eAi_ZUybqo{H5flfb|MJxxZb8I@2L&6~3Mp(PcEMTIt!@^|8Bn^bOTpG6UTUo z_IvTX4-q5tl5%zbGi}A+LgLvNZ?(4yEC{g0!3<_@WkuoN`eNCx*|(_2l_y`Fh4rXS)D7vuA!j6mRCrhJzbpkNZGu3U@P#kh^lw~=_Cnmb>x}1mPg*G&Y_#asl zM%L?znK_!os7A^+-(}ctaXbP1y(`l9{>M2-V@BqTV;*=1R2eB#$|A z3UG%bp5*lx#9X82KN;T^^ofVbu{DTsW*GZ@t%hU5Y%t%L@yX7e7-qW8G2aK7%m^Nd zV6|Jg-J$Ylbz$-<-9I$sb~S09eju^fNklSJS?r){j z{{BHIJWSVDQg*-3qn^&E8W@$ps~|33cnLLg)r8j(#2MpWEw{OCTENf6MfJ|sRTD&r z8zsBMIpXm&MnsVLb7C-QY+`cljfhASt35(zA*3Q@q&DMap#*6`TtWifZ{mtl32(b% z7In(s-@iyUtmvY(ujJk{`E&8d*?!>~dg}u*^W3Ta;w|~U1)45GahWBFnGKq;v$405 zH2Qk$OkeNnknm=~Ha{%Co~FgN>fgP(n1@$eWLzY?37smuedlzJaI{dQ8+D$TzRVTw zEiK*Ue~IQ+gaGrlR9}-XHK+V#cQyLHs?pi^+5G~0flxf4RaGY3W;SBm6+q4>x$P$uk^Vw7l}|M!jK)|;smkhn2Kj1o+~!GGXi_JIkMEDIxf{Y`QxrBCP-(}- z*`wo_8^TP*#yJx4Pj@NGH}fR3|03-&&x_-Mh3D(?BEsIka{JdasV8`}kG5T)!cbY3 zwt7sDtN-$vY3;rIJO@T8hmXv+I9=?*O0<3JnAVKMwohO0eY5)c^S7;3T6m(A^~K5J zzEB0l@5o;#9fZbjQ%vKJ0h%Ysf}5$(eqaDL_2kd-Q>P%L(!D4yeq-+XP2<}Tjs=rn zQsW1btd?LV+@62G}7RM%m1t5Tr--68~k6`kTiGE~<98mB1CU+)1a&mJ75`+U{q2GfpC@wY@ zTxoE{kncfuxAG^gt%9Or0OUj%tA%MD!_EBl7H(9G0I0?Wu=mOx(u-ascaJO%F%o8j z@O68M`D)l1WY@6ZWV;hq^ugEzV9SeJ z;ybz)WJHvU0xmX-nOeb>pEim@6i#c!ZMxMIF%c}h8yYgoojtzZ|{1(?7ZP{ z*9wadtboa)N8sGTzr@YC{T(Ryj_3KG@5`j>bL`&z5KwGRs7%YW99Y@UbDY;@2Ah7|KX?nU@W}~i6?M1cM91< z(=dKizOt$4PzKs*;<;MKk)XV>eZ{)mJ+JfFXc@!s#c=r}Uo-vqufkBeG96TxE}U7)!w?j~GiJ%Xev0LH7&cyWZ&tjbCTY1-?0JM82mT z7014gqY-eaG7E`R{X)v9UDtbTOGa2UNrSZ{(|0pabtafS?=9X7NUx6+&1=7-_h_kZ zg)x77rFmPRnS3gGKUaYY#TG-Sz}Y+&(SBCVoLd#lIQH%kEIqix`!=)^B#Q*#+(BoZ;Bh&1d?`WiFeK?^(wlZ39hf`4fG= zUVA)TTkovmUh~}WSUZqbz!M|8bk0z>@b0DXDuQ(RPJUG6%Vu51S!Hk4s}qcPx$g?R zYTD;Ap%D3B`7B(pz3nvpE#UsbrrS(scKT^?#8hw@TD?hqF%Ymt>?maJ29slah} z47e`=z(FF4U@O#DtAQ=x7ZmahE`QH976q_0C8Z|hhgV&J)xa=Pm)4&vk}XhVy@?; zxk=Y%k`oS#2nsF%YT7qAs2d%Mqn?n>(py>$7$sh49XzqY#pt!68GUGve3f-9z6;Yy z8vxv+OmdXWWZJDDEG(>BWJav)hHqp%bAwjO{H~R_4b>NML?kl2BoK;C17C%eipnf& zcJW&_k9Z6Y4Mi)*THNJ>YtgZ~K>kXLUKQ2qE3d}R9b`bON=niNmp^mU{@qUho{@&q z%g5&x2Dq59ljd$5ljKY|clz`ldzpm>Pj zC_!hLc(nHE%Qy}l33%* zC%p94%(jyzycj*ozMcJ@q|dHFNB=_x^xs=66ZGfH^SN$~>xe7b`2^ zYVhXK`a%JQ413xW98{zOb+%RFw@FvEN=7v6-)^1n7aAXU^G(x)J(A*|F^6 zL`ffbhpp<(5P|+Pa@;lbt|uI3!is%kZzLhNh!DJOGfu+V|IWWTabqDN?c^tbWp!&a zc9Xgv3+elM5dvqL{=0v|R!b&%FEHKUE*q2IMqg4rH9I+}*Dn5k9mhY|ISibmMyD8E znqYZmQ>rK07{MG`f9M1GNyQko2eax|k8!gf&xz)wSlN_Mg+<(T@tUVRX}9Nt*@y8oFJ(r zk@t*Zu-Zdod2(kt&iUwm=jjL=V9>nG`~Y<<__uKfYh2g35n0?1Yv3Q?NcM07vK~l- zP$+X>sBcx;7qKMhVpt0^2$C17;S3?}%}R2Kyn~s#Q#j~n8$8Ew1J*baQ#Ns7K{t#$ z;`xggP-O$)jtr3)38RAd7Z>kW;STA8SI?%=YXaK{W`4Ebqeikt?6Z}VjSnB9$_W1~ zZwCAW0CvTa1cy_TV^<-uZcM=V!lE_2s|cdi5_<+O~!C z?7L2Uk2H3^YslI+L&5vV@f>Wd7^;qZSkc%|3`U8J{ z=evm`|9w^XyH4M_C<;5)cb^(3szH2|e}JI94EdJH9pW{4`!;cn7d913KYr&q-PRM_ z*E<7F|NIx*Nrms7Rhq@Cgc7Lm?Nl|Xz}t9AsrhDd$5ihozSTlw(ethI5dR7%lLwpM z6qw0xnf)Y#S{<=Wfm+qQmJSZbtqur#fM*5$0qt7F-MiUn!qF!wx^NMViGXtQ=;HS|p9@%WrY^;ojb7R%?Yz%ZvV+hJ>mJrqQ^@H2? z!^53^mqujvvOI`s{K%g|x2Hl+Uyy=7QQuv<+2l^gMOsVm@|nTxU|1DM3d1D6&Ewhc z{O^OY__Dx+h${srxk{)%yQHU08kd855FdemhtysfNjRuhkjtrd^2QO`O0OYK46%jp?< zUfLipL2=92V1H^GTW;!>+F?nlX6>mml{eMZm+TC4T7}%5xM)NzC6@NoeYbz{>5IR+ zoOTrJ1tSaXh7y5Jn%kY#A-v~lUtX(i)|b6?q{CH^E0D9eZ6l(*wO-ZsBlq}Og0l*@ z$4stT%ZNv*WZf6FrVj6_snxBG^Y0cf236d#u7I4<-qMo%Je_i54f&p`+Y??;*lv#% z{NvC}pYZzc|6m8=n%M*@e5_ws5_%vxAZGcDIAA6L^#NUpGGPKH0!%WToAgLIUV9Et z+H|^Wvii>o&0oz3d@1n|7vWA`- z-q(x|!D{?gp(e1shU^TtrwzPgirFV7b{ZXprYyv=nyq^GZS&X}7k}RwU%DnH{C7-a zn>~7q(1@)LTx#-PaCiXmzwtrRpxPNDeqe;_>)EGeGIgoO;ubqk7PV}mwoZyg9DCYZ zIj0&P7OEbK6e#0ya#?%+Yjr&o`#1F5g+oCpP!3_?LmQPHC0A~ykfuWdp%0=g)=4n( zJ~qTV%UbK=;o%Xc<^?aLb;WT*Lw}Wg0D!@(JdCZ>AtfcubX;yjiw4jP;LOleV)CU~ zHRK*y>W)zp5S>9!vB%y%WPxC3+5P)_`1S6aO;=i%j7-0L8UMNsOU7tzR(^gTh3u%j zCXCuJKlZ&2U&oO9d1XG6JM}PuTx8BrKdPsPDFa-e^daXUt-@D>1P!x!yFo~Cu*_7R zW1vYmsex`vWa_>P9e~Y!m%vbk3nVoy2ntQt9>=B{#YQ4otf4`30`Zp^Knncod}l>) z-~aa;q?Vv2G-rT%h*Z*DyQsMi*&P?87oC-YRu`KZ3`$hHZk()|kK*%tD>AG}7>Ax` z-se!ZF*I|erzxAthRd4Oy_9lMMaz~_*Ef)joRuY0SP^BQ%iYhwpZEZ)419r zG4S8w-l|#Uy@DtC-`zyu*Yu7LvM+$2hUl0@ zBuifyzX*KE6BQG{n@*wya*)Z=F49LtD(+!`X`A;y_7(m7!cKrY09P&%3SCNEu(lUg za!|4if*(m{=7t1? zQzbsxW;=-i6#uRqv@B@s@#YwKPFJeQ`c-kV zhDY~&6T>bRx1&dUFv=ix)hC9=i59##VQ+8zTQuGWFCcy7^B4U>+Vn>D=HeXK`cX(| z&oHw^yUoVV>w_u?b($7sKWaJ+gfE7>j?llGR$U2l3jHHuu8ZM=ZIz6)wA(;;&23Cc zqLrKUqat0~E8PHLinW_!ca_Oi2~B5+gNndRc$tT=mrI#}(&u%(NG8F!n!;81v){?G=1f`S8%8 z)bm1Kx<~#O!2&-0?9+CQF3Nrviwyk&Y3L%EN@uc8(_($dbJJ$vcm#SWJXGt-nQQz9ZF zjvYI83L(9nQyMMp=^m@xxHsbCQZeZ94{wVd&+tyU{g)Ir-`i3GY&UauF4YHZ@IR%=R1N^EQ_>|*M zT@8y24*ITs@J1U@g#c?f{T;rhZ%CK1M#n#39}-G{Q2$`LAHDaLJ0n=c8hRsD!gXaX zTglG8_ybEEJu>$dt|ElpR&u@`tlM8d#oyUd8;)kfEizp4ctEvKgXr!AG1J`7=9}5l zP_7Y#-GkK;V`Zbhe&ioky?#JO^Bfw(l7jC1prxpW+q`n33K&=asU7T07*qoM6N<$f-!Kdvj6}9 diff --git a/assets/community-wecom-survey.png b/packages/llm/llm-pi-ai/tests/fixtures/qr-code.png similarity index 100% rename from assets/community-wecom-survey.png rename to packages/llm/llm-pi-ai/tests/fixtures/qr-code.png diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index e4c15bb34e..1fe529336f 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -210,7 +210,7 @@ for (const profile of providerCases) { if (profile.provider === 'anthropic') { it('sends a real image through the authenticated Anthropic visual path', async () => { const data = new Uint8Array(await readFile( - new URL('../../../../assets/community-wecom-survey.png', import.meta.url), + new URL('./fixtures/qr-code.png', import.meta.url), )) const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), From 0ca0f3d0b865397906d229e73cce7ad83d9194df Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 16:33:22 +0800 Subject: [PATCH 78/95] refactor(llm): use one five-retry default --- ...26-07-24-provider-retry-policies.i18n.yaml | 4 +- .../2026-07-24-provider-retry-policies.md | 14 ++--- .../2026-07-24-provider-retry-policies.zh.md | 14 ++--- apps/web/tests/shipped-composition.e2e.ts | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 52 ++----------------- docs/config-catalog.zh.md | 52 ++----------------- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 16 +++--- docs/subsystems/llm-streaming.zh.md | 16 +++--- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 8 --- .../extensions/tool-cordis/src/api-catalog.ts | 6 +-- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 8 +-- packages/llm/llm-deepseek/README.zh.md | 8 +-- packages/llm/llm-deepseek/src/adapter.ts | 6 +-- packages/llm/llm-deepseek/src/index.ts | 6 +-- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 6 +-- packages/llm/llm-pi-ai/README.zh.md | 6 +-- packages/llm/llm-pi-ai/src/config.ts | 14 +++-- packages/llm/llm-pi-ai/src/index.ts | 4 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +- packages/llm/llm-retry/README.i18n.yaml | 4 +- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/README.zh.md | 2 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 8 +-- packages/llm/llm/README.zh.md | 8 +-- packages/llm/llm/src/index.ts | 31 +++-------- packages/llm/llm/src/retry-policy.ts | 14 +++-- packages/llm/llm/tests/retry-policy.spec.ts | 18 ++++++- packages/llm/llm/tests/service.spec.ts | 27 +--------- 39 files changed, 135 insertions(+), 259 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index 03486a909a..c25841435c 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.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-24-provider-retry-policies.md -2026-07-24-provider-retry-policies.md: aa72e05f8ca74621d2ab37f9c2f3bea86cbcf7d9 -2026-07-24-provider-retry-policies.zh.md: a3b998196133ce5880bfacb6985d6c95e64bb650 +2026-07-24-provider-retry-policies.md: 96979b219aebece96a1bcc09aa3dd572d2b9222d +2026-07-24-provider-retry-policies.zh.md: 02fe13e0ddead035ec750c027e889da08e2557ae diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md index aa72e05f8c..96979b219a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -12,7 +12,7 @@ Provider policy must follow the request that actually failed, including a route ## Decision -Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration, validates an explicit value, and exposes that resolved route override through `providerRetryPolicy()`. `LlmRuntime` accepts one deployment-wide `defaultRetryPolicy`, resolves it at service construction, and applies it only when the adapter supplies no route override; omitting both preserves the normal defaults of two retries. The resulting effective policy remains route-owned registration state rather than a retry-executor setting. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. The shipped Web bundle configures `LlmRuntime.defaultRetryPolicy` for five normal-mode retries; every current or future Web adapter route inherits this larger bounded budget unless its provider configuration overrides it. Other profiles retain the core default unless their composition chooses another value. +Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration, validates and resolves it, and exposes that resolved route policy through `providerRetryPolicy()`. Omission selects the shared core normal default of five retries for every composition, including Web, headless, and custom profiles. The effective policy remains route-owned registration state rather than a retry-executor setting. Layered settings may retain normal-only `maxRetries` or `retryableCodes` after changing `mode` to `always`; the resolver ignores those inactive fields while still rejecting unknown keys, and the registered always policy omits them. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. ```yaml providers: @@ -44,17 +44,17 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Alternatives considered -**One retry-executor-level `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. The LLM deployment default is different: provider route overrides remain authoritative, and the effective policy is captured only after routing selects a registration. +**One retry-executor-level `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. Provider route policies remain authoritative, and the effective policy is captured only after routing selects a registration. **A separate exact-provider list on `dsh-llm-retry`** — rejected because it duplicates provider route names outside their owning adapter configuration and lets provider registration drift from recovery policy. **A very large finite retry count** — rejected because it eventually violates the requested keep-retrying contract and serializes an arbitrary operational limit as if it were meaningful. -**Adapter-level shared defaults** — rejected because a deployment-wide budget would have to be repeated by every adapter family and every future adapter. Putting a discriminated policy object into an adapter's settings base also lets recursive settings layering retain normal-only keys when a user switches the policy to always mode. +**Adapter-specific omission defaults** — rejected because a shared budget would have to be repeated by every adapter family and every future adapter, making equivalent model routes behave differently depending on their implementation. -**Raise the provider omission default to five** — rejected because it would also increase request cost and latency for headless and custom profiles. The Web bundle's provider configuration expresses the interactive deployment's larger bounded budget without changing other profiles. +**An LLM deployment-level default** — rejected because it introduces another configuration layer only to make Web differ from other compositions. The product default is uniform, while provider settings retain the existing per-route override. -**Stamp five retries into profiles when the Web UI writes them** — rejected because existing profiles and settings written outside that UI would retain two retries. The deployment default belongs in the Web composition and is inherited uniformly. +**Stamp five retries into profiles when the Web UI writes them** — rejected because existing profiles, settings written outside that UI, and non-Web compositions would retain the old value. **Provider-SDK retries** — rejected because hidden attempts multiply agent-level budgets, cannot use the closed-step durability boundary, and may splice or discard streamed output without a reconstructable retry record. @@ -62,10 +62,10 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Verification -Adapter tests validate nested policies at provider load, prove explicit profile policies reach registration, and retain the serving policy across in-flight route replacement. LLM service tests prove adapter overrides win over the deployment default, omission retains the core two-retry behavior, and an invalid deployment policy fails before any route registers. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. The shipped Web composition snapshot pins omitted DeepSeek and pi-ai policies at five retries, then proves settings can replace either with always mode without inheriting normal-only fields. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. +Adapter tests validate nested policies at provider load, prove explicit profile policies reach registration, prove omission resolves to five retries, and retain the serving policy across in-flight route replacement. LLM service tests prove adapter policies are captured and omission uses the shared five-retry behavior. Resolver tests prove always mode ignores retained normal-only fields but returns a pure always policy. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. The shipped Web composition snapshot pins omitted DeepSeek and pi-ai policies at five retries, then proves settings can write `{ mode: 'always', maxRetries: 5 }` and obtain a pure always policy. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. ## Consequences -Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. A Web model route using the deployment default may spend up to three more requests and their backoff time than a route using core omission defaults, in exchange for recovering from longer transient outages. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract. +Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Any model route using omission defaults may spend up to three more requests and their backoff time than under the former two-retry default, in exchange for recovering from longer transient outages. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract. This decision extends the closed-step recovery, single visible adapter attempt, structured failure, and durable status design in [bounded recovery for transient LLM request failures](../architecture/2026-06-21-bounded-llm-request-recovery.md). diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index a3b9981961..02fe13e0dd 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`,校验显式值,并通过 `providerRetryPolicy()` 公开解析后的路由覆盖。`LlmRuntime` 接受一个部署级 `defaultRetryPolicy`,在服务构造时解析它,并且只在适配器未提供路由覆盖时应用;两者都省略时保留两次重试的 normal 默认值。最终得到的有效策略仍然是路由拥有的注册状态,而不是重试执行器设置。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。随附的 Web bundle 为 `LlmRuntime.defaultRetryPolicy` 配置五次 normal 模式重试;当前及未来的每条 Web 适配器路由都会继承这项更大的有界预算,除非其提供方配置覆盖它。其他 profile 保留核心默认值,除非自身组合选择其他值。 +每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`,对它进行校验与解析,并通过 `providerRetryPolicy()` 公开解析后的路由策略。省略配置时,Web、headless 与自定义 profile 等所有组合都使用核心共享的 normal 模式五次重试默认值。有效策略仍然是路由拥有的注册状态,而不是重试执行器设置。分层 settings 在把 `mode` 改为 `always` 后可能保留仅属于 normal 的 `maxRetries` 或 `retryableCodes`;解析器会忽略这些未启用字段,同时仍拒绝未知键,注册后的 always 策略也不包含它们。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。 ```yaml providers: @@ -44,17 +44,17 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 曾考虑的替代方案 -**重试执行器级的单一 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。LLM 部署默认值不同:提供方路由覆盖仍然权威,而且只有在路由选定注册后才捕获有效策略。 +**重试执行器级的单一 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。提供方路由策略仍然权威,而且只有在路由选定注册后才捕获有效策略。 **在 `dsh-llm-retry` 上维护单独的指定提供方列表**:不予采纳,因为它会在所属适配器配置之外重复提供方路由名称,并让提供方注册与恢复策略发生偏差。 **设置很大的有限重试次数**:不予采纳,因为它最终仍会违反持续重试的约定,并把任意选取的运维上限序列化成看似有意义的数值。 -**适配器级共享默认值**:不予采纳,因为部署级预算必须在每种适配器族以及未来的每个适配器中重复配置。把可辨识策略对象放入适配器的 settings base,还会让 settings 递归分层在用户把策略切换为 always 模式时保留仅属于 normal 的字段。 +**按适配器设置不同的省略默认值**:不予采纳,因为共享预算必须在每种适配器族以及未来的每个适配器中重复配置,同等模型路由也会因实现不同而表现不同。 -**将提供方省略配置时的默认值提高到五次**:不予采纳,因为这也会增加 headless 与自定义 profile 的请求成本和延迟。Web bundle 的提供方配置能表达互动式部署所需的更大有界预算,而不改变其他 profile。 +**LLM 部署级默认值**:不予采纳,因为这只为区分 Web 与其他组合增加了一层配置。产品默认值保持统一,提供方 settings 则保留既有的逐路由覆盖能力。 -**在 Web UI 写入 profile 时把五次重试写死进去**:不予采纳,因为现有 profile 与从该 UI 之外写入的 settings 仍会保留两次重试。部署默认值应位于 Web 组合中,并被统一继承。 +**在 Web UI 写入 profile 时把五次重试写死进去**:不予采纳,因为现有 profile、从该 UI 之外写入的 settings 以及非 Web 组合仍会保留旧值。 **使用提供方 SDK 重试**:不予采纳,因为隐藏尝试会叠加 agent 层预算,无法利用已关闭步骤的持久性边界,还可能在没有可重建重试记录的情况下拼接或丢弃流式输出。 @@ -62,10 +62,10 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 验证 -适配器测试会在提供方加载时校验嵌套策略,证明显式 profile 策略抵达注册流程,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。LLM 服务测试会证明适配器覆盖优先于部署默认值、省略配置时保留核心的两次重试行为,以及无效部署策略会在任何路由注册前失败。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。随附的 Web 组合快照会把省略配置的 DeepSeek 与 pi-ai 策略固定为五次重试,再证明 settings 可以把任一策略替换为 always 模式,且不会继承仅属于 normal 的字段。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 +适配器测试会在提供方加载时校验嵌套策略,证明显式 profile 策略抵达注册流程,证明省略配置会解析为五次重试,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。LLM 服务测试会证明适配器策略被捕获,且省略配置使用共享的五次重试行为。解析器测试会证明 always 模式忽略残留的 normal 专属字段,但返回纯 always 策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。随附的 Web 组合快照会把省略配置的 DeepSeek 与 pi-ai 策略固定为五次重试,再证明 settings 可以写入 `{ mode: 'always', maxRetries: 5 }` 并得到纯 always 策略。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 ## 后果 -normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。使用该部署默认值的 Web 模型路由比使用核心省略默认值的路由最多多花费三次请求及其退避时间,以此换取从更长短暂故障中恢复的能力。重试状态保持可观察且会持久化,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复约定。 +normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。任何使用省略默认值的模型路由相比原先的两次重试默认值,最多会多花费三次请求及其退避时间,以此换取从更长短暂故障中恢复的能力。重试状态保持可观察且会持久化,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复约定。 本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭步骤恢复、单次可见适配器尝试、结构化失败与持久化状态设计。 diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 92618c75d8..295e861b95 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -94,7 +94,7 @@ it('assembles the shipped Web catalog, file-reference guidance, retry policy, an } `) await ctx.settings.update(settingsNamespace('llm-deepseek'), { - retryPolicy: { mode: 'always' }, + retryPolicy: { mode: 'always', maxRetries: 5 }, }) expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(` { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 5f2420eb7c..fd3906763f 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: 60f7f5afefb59cb4bd0d65500886bebc988c5b6b -config-catalog.zh.md: 0b9055f14b7a2bd5192b0006db7acf45cf4bcb9a +config-catalog.md: ae72c866c66d0bfc529cbd95961a3fdb95e5bcc3 +config-catalog.zh.md: 028f6d15e1db9762a546e365fac92379a556a2b8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 60f7f5afef..ae72c866c6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -838,53 +838,6 @@ export interface Config { Source: [`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) - - -## `@deepseek-ai/dsh-llm` - -```ts config-catalog -/** Deployment-wide defaults applied to provider routes without adapter-owned overrides. */ -export interface Config { - /** Model-request retry policy inherited by routes whose adapter omits one; omission uses normal defaults. */ - defaultRetryPolicy?: RetryPolicyConfig -} - -/** Model-request retry policy configuration for a provider route or deployment default. */ -export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig - -/** Current bounded transient retry behavior for one provider route. */ -export interface NormalRetryPolicyConfig { - /** Retry only configured transient failure codes. */ - mode: 'normal' - /** Maximum eligible retries after the first request (default 2). */ - maxRetries?: number - /** Stable failure codes eligible for this policy. */ - retryableCodes?: string[] - /** Local exponential-backoff and jitter configuration. */ - backoff?: BackoffConfig -} - -/** Unbounded retry behavior for every model-request failure on one provider route. */ -export interface AlwaysRetryPolicyConfig { - /** Retry every model-request failure until success, cancellation, or disposal. */ - mode: 'always' - /** Local exponential-backoff and jitter configuration. */ - backoff?: BackoffConfig -} - -/** Bounded exponential backoff with symmetric jitter around each local delay. */ -export interface BackoffConfig { - /** Initial local exponential-backoff delay in milliseconds (default 500). */ - initialDelayMs?: number - /** Maximum locally scheduled or accepted provider delay in milliseconds (default 10000). */ - maxDelayMs?: number - /** Symmetric random multiplier range around one (default 0.1). */ - jitterRatio?: number -} -``` - -Source: [`packages/llm/llm/src/index.ts:176`](../packages/llm/llm/src/index.ts) - ## `@deepseek-ai/dsh-llm-deepseek` @@ -917,7 +870,7 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ + /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -1032,7 +985,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ + /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -3122,6 +3075,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `webServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-host-plugin-inventory` — requires `loader` ([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) +- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0b9055f14b..028f6d15e1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -840,53 +840,6 @@ export interface Config { 来源:[`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) - - -## `@deepseek-ai/dsh-llm` - -```ts config-catalog -/** Deployment-wide defaults applied to provider routes without adapter-owned overrides. */ -export interface Config { - /** Model-request retry policy inherited by routes whose adapter omits one; omission uses normal defaults. */ - defaultRetryPolicy?: RetryPolicyConfig -} - -/** Model-request retry policy configuration for a provider route or deployment default. */ -export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig - -/** Current bounded transient retry behavior for one provider route. */ -export interface NormalRetryPolicyConfig { - /** Retry only configured transient failure codes. */ - mode: 'normal' - /** Maximum eligible retries after the first request (default 2). */ - maxRetries?: number - /** Stable failure codes eligible for this policy. */ - retryableCodes?: string[] - /** Local exponential-backoff and jitter configuration. */ - backoff?: BackoffConfig -} - -/** Unbounded retry behavior for every model-request failure on one provider route. */ -export interface AlwaysRetryPolicyConfig { - /** Retry every model-request failure until success, cancellation, or disposal. */ - mode: 'always' - /** Local exponential-backoff and jitter configuration. */ - backoff?: BackoffConfig -} - -/** Bounded exponential backoff with symmetric jitter around each local delay. */ -export interface BackoffConfig { - /** Initial local exponential-backoff delay in milliseconds (default 500). */ - initialDelayMs?: number - /** Maximum locally scheduled or accepted provider delay in milliseconds (default 10000). */ - maxDelayMs?: number - /** Symmetric random multiplier range around one (default 0.1). */ - jitterRatio?: number -} -``` - -来源:[`packages/llm/llm/src/index.ts:176`](../packages/llm/llm/src/index.ts) - ## `@deepseek-ai/dsh-llm-deepseek` @@ -919,7 +872,7 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ + /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -1034,7 +987,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ + /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -3124,6 +3077,7 @@ export interface Config { - `@deepseek-ai/dsh-host-directory-picker-auto` — 需要 `webServer` · `loader`([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native`([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-host-plugin-inventory` — 需要 `loader`([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) +- `@deepseek-ai/dsh-llm`([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp`([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 78670a641b..7e67006a40 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: c17a6163ef829f0176163024a4632b712a7195aa -event-producer-consumer.zh.md: 5e6073ad3dbe1e240b5bc6f5fbc55b499c9373c2 +event-producer-consumer.md: 6a79e6f7ce5addc64b10efa8da7a886dcfb36dc2 +event-producer-consumer.zh.md: f7576a8e28e4f1db2c65c324595c05c98b8fe488 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c17a6163ef..6a79e6f7ce 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-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`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 5e6073ad3d..f7576a8e28 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -38,7 +38,7 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-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`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 35e513dcd7..c945560cc3 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/llm-streaming.md -llm-streaming.md: 4627f3cd9e224ef2cced3253b82d2b8461d73986 -llm-streaming.zh.md: a161267162aa5773279ca4a417caec57cd721115 +llm-streaming.md: c1d40bf3111bb534a5401506016256264b3767bf +llm-streaming.zh.md: 04e753d486f667eb4a63ef33d41e7103c03f4914 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4627f3cd9e..c1d40bf311 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -240,7 +240,7 @@ Every adapter MUST obey these, and every consumer may rely on them: ## `ResolvedRetryPolicy` -Retry configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. An explicit adapter route policy overrides `LlmRuntime.defaultRetryPolicy`; omitting both uses the normal default of two retries. `LlmRuntime.providerRetryPolicy(provider)` returns that effective registered value, and `llmRetryPolicyOf(stream)` returns the value captured from the serving registration after the call selects it, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) lists the optional input fields. +Retry configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. Omitting a provider policy uses the normal default of five retries. Layered settings may retain normal-only `maxRetries` or `retryableCodes` after switching to always mode; the resolver ignores those inactive fields and captures the pure always policy. `LlmRuntime.providerRetryPolicy(provider)` returns the registered value, and `llmRetryPolicyOf(stream)` returns the value captured from the serving registration after the call selects it, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) lists the optional input fields. ## `AppIdentity` — app attribution @@ -655,7 +655,7 @@ interface LlmCallConfigAdapterDefaults { ## Service and provider contracts -`LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` supplies an explicit route override; omission inherits the LLM deployment default. `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn. +`LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` supplies a resolved provider policy; omission uses the shared normal defaults. `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn. ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -694,9 +694,9 @@ declare abstract class LlmAdapter { */ providerInfo(provider: string): LlmProviderInfo; /** - * Return an explicit provider-owned retry policy override for this route. + * Return the provider-owned retry policy captured with this route. * @param _provider - a route passed to `registerAdapter()` for this instance. - * @returns a resolved override, or `undefined` to inherit the LLM deployment default. + * @returns a resolved policy, or `undefined` to use the normal defaults. */ providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined; /** @@ -803,9 +803,9 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise /** - * Read the effective retry policy captured when one provider route was registered. + * Read the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. - * @returns the adapter override or deployment default, fully resolved. + * @returns the provider-owned policy, with normal defaults already resolved. */ providerRetryPolicy(provider: string): ResolvedRetryPolicy @@ -864,7 +864,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:291`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts) @@ -913,5 +913,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t 'llm/stream'(this: LlmRuntime, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` -Source: [`packages/llm/llm/src/index.ts:65`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:64`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index a161267162..04e753d486 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -242,7 +242,7 @@ interface LlmFailure { ## `ResolvedRetryPolicy` -重试配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。显式适配器路由策略会覆盖 `LlmRuntime.defaultRetryPolicy`;两者都省略时使用两次重试的 normal 默认值。`LlmRuntime.providerRetryPolicy(provider)` 返回注册的该有效值;调用选定实际提供服务的注册后,`llmRetryPolicyOf(stream)` 返回从中捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选配置输入字段由[生成的配置目录](../config-catalog.md)列出。 +重试配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。省略提供方策略时使用重试五次的 normal 默认值。分层 settings 在切换到 always 模式后可能保留仅属于 normal 的 `maxRetries` 或 `retryableCodes`;解析器会忽略这些未启用字段,并捕获纯 always 策略。`LlmRuntime.providerRetryPolicy(provider)` 返回注册值;调用选定实际提供服务的注册后,`llmRetryPolicyOf(stream)` 返回从中捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选配置输入字段由[生成的配置目录](../config-catalog.md)列出。 ## `AppIdentity`:应用归属 @@ -661,7 +661,7 @@ interface LlmCallConfigAdapterDefaults { ## 服务与提供方约定 -`LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 提供显式路由覆盖;省略时继承 LLM 部署默认值。`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.md#turn-flow)。 +`LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 提供解析后的提供方策略;省略时使用共享的 normal 默认值。`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.md#turn-flow)。 ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -700,9 +700,9 @@ declare abstract class LlmAdapter { */ providerInfo(provider: string): LlmProviderInfo; /** - * Return an explicit provider-owned retry policy override for this route. + * Return the provider-owned retry policy captured with this route. * @param _provider - a route passed to `registerAdapter()` for this instance. - * @returns a resolved override, or `undefined` to inherit the LLM deployment default. + * @returns a resolved policy, or `undefined` to use the normal defaults. */ providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined; /** @@ -809,9 +809,9 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise /** - * Read the effective retry policy captured when one provider route was registered. + * Read the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. - * @returns the adapter override or deployment default, fully resolved. + * @returns the provider-owned policy, with normal defaults already resolved. */ providerRetryPolicy(provider: string): ResolvedRetryPolicy @@ -870,7 +870,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:291`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts) @@ -919,5 +919,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t 'llm/stream'(this: LlmRuntime, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` -Source: [`packages/llm/llm/src/index.ts:65`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:64`](../../packages/llm/llm/src/index.ts) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index db9ae65f97..2a1c5b01db 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/web-app/README.md -README.md: 30ad018ef60ea2c1c8926f482dd839dec2ee61d8 -README.zh.md: ccc7acff3027ce7cd973f3d9e9a48d831ade00e5 +README.md: 28fb5b3dcfc7fbb912493a6b97495e2ed5a3eece +README.zh.md: 92157f05497e53c48506666a3a53d638d98a7c9e diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 30ad018ef6..28fb5b3dcf 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -6,7 +6,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove ## Model retry defaults -The Web layer configures `LlmRuntime.defaultRetryPolicy` with normal mode and `maxRetries: 5`, allowing at most five eligible retries after the initial request. Every adapter route without an explicit provider policy inherits it, including `deepseek-official`, settings-added pi-ai routes, and future adapters. A `llm-deepseek` or pi-ai provider `retryPolicy` overrides the deployment default; non-Web profiles retain the core two-retry default unless their own composition chooses another value. +Web uses the shared bounded normal default of five eligible retries after the initial request. The `deepseek-official` route and settings-added pi-ai routes use that default when they omit `retryPolicy`; explicit provider policies still win. Web adds no retry-specific composition override, so the same omission behavior applies to non-Web profiles. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index ccc7acff30..92157f0549 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -6,7 +6,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 ## 模型重试默认值 -Web 层会为 `LlmRuntime.defaultRetryPolicy` 配置 normal 模式与 `maxRetries: 5`,使其在初次请求后最多再重试 5 次符合条件的失败。每条没有显式提供方策略的适配器路由都会继承它,包括 `deepseek-official`、由 settings 新增的 pi-ai 路由及未来适配器。`llm-deepseek` 或 pi-ai 提供方的 `retryPolicy` 会覆盖部署默认值;非 Web profile 保留核心的两次重试默认值,除非自身组合选择其他值。 +Web 使用共享的有界 normal 默认值,在首次请求后最多再重试五次符合条件的失败。`deepseek-official` 与由 settings 新增的 pi-ai 路由在省略 `retryPolicy` 时使用该默认值;显式提供方策略仍然优先。Web 不再增加重试专用的组合覆盖,因此非 Web profile 的省略行为与之相同。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 149e87e30d..203f8ff017 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -40,14 +40,6 @@ # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE -# The Web deployment gives every model route without an explicit provider -# policy a larger bounded transient-failure budget than the core default. -- id: llm - config: - defaultRetryPolicy: - mode: normal - maxRetries: 5 - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dsh.client` rows are the browser roster the modules node half scans into diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index bd698d5e94..4ba44e6277 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -832,9 +832,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', - description: 'Read the effective retry policy captured when one provider route was registered.', + description: 'Read the retry policy captured when one provider route was registered.', parameters: [{ name: 'provider', description: 'registered provider route to inspect.' }], - returns: 'the adapter override or deployment default, fully resolved.', + returns: 'the provider-owned policy, with normal defaults already resolved.', }, { signature: 'async listModels(provider: string): Promise', @@ -3321,7 +3321,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmRuntime', - declaration: 'export class LlmRuntime extends Service {\n static Config: z;\n constructor(ctx: Context, config: Config = {});\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export class LlmRuntime extends Service {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'LspHover', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index d0caf1d4ff..67f9eb435f 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 9087c57844a3502f8e1ba650979722638eb231f0 -README.zh.md: aea543563d1294d953b36210327cd1bb1d92b961 +README.md: 65c17f504e74408b37518149007c006d3836c4c6 +README.zh.md: 217d00c66ff332b67546e3efca2025347c1d28e0 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 9087c57844..65c17f504e 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -20,7 +20,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default - retryPolicy: # optional; omission inherits the LLM deployment default + retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: initialDelayMs: 500 @@ -35,7 +35,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire contextWindow: 512000 ``` -The plugin registers the single provider route `deepseek-official` together with its explicit `retryPolicy` override when configured; `LlmRuntime` supplies the deployment default otherwise. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -45,7 +45,7 @@ The same exact-model result exposes ordered `off`, `low`, `high`, and `max` effo `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `low`, `high`, or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers only an explicit provider policy override, `LlmRuntime` captures the effective route policy, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the resolved provider policy, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. ## Dynamic configuration (settings + credentials) @@ -54,7 +54,7 @@ Connection facts are not frozen at load. `resolveAdapterOptions` is the one expl - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. - **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. -The one adapter-owned registration fact is the explicit retry policy override: when its resolved value changes or is removed, the plugin re-registers the route in place (same adapter instance, one synchronous section). `ctx.llm.providerRetryPolicy('deepseek-official')` therefore reports either the current override or `LlmRuntime`'s deployment default. +The adapter-owned registration fact is the resolved retry policy: when its value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section). `ctx.llm.providerRetryPolicy('deepseek-official')` therefore reports the current provider policy, including the shared five-retry normal default when configuration omits it. The plugin also declares its route in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`): provider `deepseek-official`, settings namespace `llm-deepseek`, empty settings path — the whole section is the profile. Configuration surfaces use that entry to offer this adapter alongside dormant pi-ai providers. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index aea543563d..217d00c66f 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -20,7 +20,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default - retryPolicy: # optional; omission inherits the LLM deployment default + retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: initialDelayMs: 500 @@ -35,7 +35,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: contextWindow: 512000 ``` -该插件注册唯一提供方路由 `deepseek-official`,并在配置存在时一同注册显式 `retryPolicy` 覆盖;否则由 `LlmRuntime` 提供部署默认值。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 +该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 @@ -45,7 +45,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `low`、`high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 -`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它只注册显式提供方策略覆盖,`LlmRuntime` 捕获有效路由策略,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它会注册解析后的提供方策略,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 ## 动态配置(settings + credentials) @@ -54,7 +54,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 - **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 -适配器拥有的唯一注册事实是显式重试策略覆盖:其解析值变化或被移除时,插件原地重新注册该路由(同一适配器实例、一个同步区段)。因此,`ctx.llm.providerRetryPolicy('deepseek-official')` 会报告当前覆盖或 `LlmRuntime` 的部署默认值。 +适配器拥有的注册事实是解析后的重试策略:其值变化时,插件会原地重新注册该路由(同一适配器实例、一个同步区段)。因此,`ctx.llm.providerRetryPolicy('deepseek-official')` 会报告当前提供方策略;省略配置时,其中包含共享的 normal 模式五次重试默认值。 该插件还会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`)中声明自己的路由:提供方为 `deepseek-official`,settings namespace 为 `llm-deepseek`,settings path 为空——整个分节就是 profile。配置界面借助该条目,把本适配器与休眠的 pi-ai 提供方一并呈现。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index ca76130d5a..bc30d46c3b 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -66,8 +66,8 @@ export interface DeepSeekConnectionOptions { models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs: number - /** Explicit provider-owned model-request retry policy, already resolved. */ - retryPolicy?: ResolvedRetryPolicy + /** Provider-owned model-request retry policy, already resolved. */ + retryPolicy: ResolvedRetryPolicy } /** Constructor options for {@link DeepSeekAdapter}: the operation-local resolution hooks the plugin owns. */ @@ -166,7 +166,7 @@ export class DeepSeekAdapter extends LlmAdapter { return { id: provider, name: 'DeepSeek' } } - override providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined { + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { return this.config.options().retryPolicy } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 88a45ce165..cbaa0c13b6 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -76,7 +76,7 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ + /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -193,9 +193,7 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, models: resolveModels(config.models), streamIdleTimeoutMs, - ...config.retryPolicy === undefined ? {} : { - retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), - }, + retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), } } diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 7657430d3c..d79bad503b 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: f7e9421d6c782e0419b0a1ba4db659b8b2adeaf2 -README.zh.md: 4a502a504fef2c02f4647ec1b9526e2054d2d53f +README.md: 208615408b7ed450961ce5c47bafe8127b273eb8 +README.zh.md: 101fd563ea391c56fac9d6c33ef01c48671f791f diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index f7e9421d6c..208615408b 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `support ## Config -Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Each profile may set an explicit `retryPolicy`; omission inherits `LlmRuntime`'s deployment default. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. +Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Each profile may set a `retryPolicy`; omission uses normal mode with five retries. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. ```yaml - id: llm @@ -113,7 +113,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. An explicit profile retry policy is captured with that provider route; omission inherits the LLM deployment default, which itself falls back to bounded normal behavior. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -201,4 +201,4 @@ Recorded response content appends to the next request and does not invalidate it - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. -- **Retry policy is provider-owned, not an SDK retry** — each provider profile may supply nested `retryPolicy`; omission inherits `LlmRuntime`'s deployment default, and the effective route policy is what `dsh-llm-retry` executes at the agent failed-step extension point. pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. +- **Retry policy is provider-owned, not an SDK retry** — each provider profile may supply nested `retryPolicy`; omission resolves to normal mode with five retries, and the effective route policy is what `dsh-llm-retry` executes at the agent failed-step extension point. pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 4a502a504f..101fd563ea 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -8,7 +8,7 @@ ## 配置 -按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。每个 profile 都可以设置显式 `retryPolicy`;省略时继承 `LlmRuntime` 的部署默认值。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 +按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。每个 profile 都可以设置 `retryPolicy`;省略时使用 normal 模式并重试五次。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 ```yaml - id: llm @@ -114,7 +114,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。显式 profile 重试策略会随该提供方路由一同捕获;省略时继承 LLM 部署默认值,而该默认值自身会回退到有界 normal 行为。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -202,4 +202,4 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 - **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 -- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以提供嵌套的 `retryPolicy`;省略时继承 `LlmRuntime` 的部署默认值,`dsh-llm-retry` 会在 agent 的失败步骤扩展点上执行有效路由策略。pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。 +- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以提供嵌套的 `retryPolicy`;省略时解析为 normal 模式并重试五次,`dsh-llm-retry` 会在 agent 的失败步骤扩展点上执行有效路由策略。pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 0641649c08..6d3b21069b 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -136,7 +136,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission inherits the LLM deployment default. */ + /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -151,8 +151,8 @@ export interface ResolvedPiAiProviderProfile apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number - /** Explicit immutable retry policy captured with this provider route. */ - retryPolicy?: ResolvedRetryPolicy + /** Immutable retry policy captured with this provider route. */ + retryPolicy: ResolvedRetryPolicy /** * The pi-ai provider this route registers, built from the resolved models. * Construction happens here so an unserviceable protocol or an underspecified @@ -294,8 +294,8 @@ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): voi * Validate profiles and return a detached route-keyed map suitable for * per-request reads. This is the one explicit resolve step, so an omitted dict * resolves to the empty (dormant) route set here rather than through a hidden - * fallback, and each route's models, explicit retry policy, and pi-ai provider - * are materialized once. + * fallback, and each route's models, retry policy, and pi-ai provider are + * materialized once. * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ @@ -355,9 +355,7 @@ export function resolveProfiles( displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, - ...retryPolicy === undefined ? {} : { - retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), - }, + retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, configuredMaxTokens: catalog.configuredMaxTokens, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 23133ee44b..1bbeec79db 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -5,7 +5,7 @@ * pi-ai does not ship is declared outright. Profile facts resolve per request * over the optional `llm-pi-ai` user-settings section and the optional * credential seam, so a changed key, endpoint, model, or knob reaches the next - * request without a restart; a changed *route set* (or a route's explicit, + * request without a restart; a changed *route set* (or a route's * registration-captured retry policy) re-registers the same adapter instance * in place. * @@ -99,7 +99,7 @@ function registrationFacts(profiles: ReadonlyMap ({ provider, displayName: profile.displayName, - ...profile.retryPolicy === undefined ? {} : { retryPolicy: profile.retryPolicy }, + retryPolicy: profile.retryPolicy, })) .sort((left, right) => left.provider.localeCompare(right.provider)) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 8a99887b56..82537fd2b3 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -392,7 +392,7 @@ describe('provider profile lifecycle', () => { }) expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({ mode: 'normal', - maxRetries: 2, + maxRetries: 5, }) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index fd2395c33f..2c468f9bc1 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/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/llm/llm-retry/README.md -README.md: 0a907b5505650f7c2cd5e9933750be3701f8e34b -README.zh.md: 024545e1ed52bb8d4aeac8114ffafc26a04a2ddb +README.md: 1dd530051e387230510414e35701db10c7c7c063 +README.zh.md: 52badf6a6e7d2c2571fa951865f6b4b3f08ae4ad diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 0a907b5505..1dd530051e 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Function plugin that applies exact-provider retry policy through the agent loop's closed-step `agent/request-error` waterfall. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered turn. -Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion that produced no durable content, so repeating it is safe. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it after active delegated recovery reaches quiescence. +Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: five retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion that produced no durable content, so repeating it is safe. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it after active delegated recovery reaches quiescence. Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index 024545e1ed..52badf6a6e 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -4,7 +4,7 @@ 一个函数插件,通过 agent loop(智能体循环)在已关闭步骤上触发的 `agent/request-error` waterfall(瀑布式事件)应用确切提供方重试策略。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号轮次。 -每个提供方适配器都拥有可选的嵌套 `retryPolicy`;路由在 `ctx.llm` 上注册时会捕获该策略,任何到达该注册最终适配器边界的调用都会携带它。如果之后释放或替换路由,进行中的失败仍会保留当时为其提供服务的策略;在选中任何最终适配器前发生的失败没有提供方策略,会继续委托。省略策略时使用 normal mode:为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试两次,并采用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对未产生任何持久内容的退化提供方完成所作的分类,因此可安全重复。normal 策略可以更改其有限预算、符合条件的 code 和退避配置。always mode 会先请求下游恢复,再无次数上限地重试每个模型请求失败;成功、取消或插件 dispose(资源释放)会在活跃的委托恢复完全停稳后终止它。 +每个提供方适配器都拥有可选的嵌套 `retryPolicy`;路由在 `ctx.llm` 上注册时会捕获该策略,任何到达该注册最终适配器边界的调用都会携带它。如果之后释放或替换路由,进行中的失败仍会保留当时为其提供服务的策略;在选中任何最终适配器前发生的失败没有提供方策略,会继续委托。省略策略时使用 normal mode:为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试五次,并采用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对未产生任何持久内容的退化提供方完成所作的分类,因此可安全重复。normal 策略可以更改其有限预算、符合条件的 code 和退避配置。always mode 会先请求下游恢复,再无次数上限地重试每个模型请求失败;成功、取消或插件 dispose(资源释放)会在活跃的委托恢复完全停稳后终止它。 两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。 diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 1df7d050e4..31e098678a 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: 91d4b745c2e179c1d943628ea7c4337cb48291f1 -README.zh.md: f9492e6cd3edb5b6b969cfde0fb8a759f95ae77c +README.md: 1ff98b9103f434a0d3669b13eec291ec68ba55f7 +README.zh.md: 3e3683c3465d83125f455780c6a0104e3998fba5 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 91d4b745c2..1ff98b9103 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -8,9 +8,9 @@ Provider-neutral LLM vocabulary and abstract service. This package defines the c An adapter registry plus a single streaming call API, interceptable via a waterfall event. -### Configuration +### Retry policy -`defaultRetryPolicy` is the deployment policy inherited by every provider route whose adapter supplies no explicit override. Omitting it preserves the bounded normal default of two retries. An adapter override wins, and the effective resolved policy is captured with the route registration; this service stores that policy but does not execute retries. +Each provider adapter supplies its resolved route policy. Omitting provider configuration uses bounded normal mode with five retries after the first request. Layered configuration may retain `maxRetries` or `retryableCodes` after changing `mode` to `always`; resolution ignores those inactive normal-mode fields and captures a pure always policy. This service stores the effective policy but does not execute retries. ### Public API @@ -21,7 +21,7 @@ An adapter registry plus a single streaming call API, interceptable via a waterf - `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` Offer to interrogate provider endpoints for the settings namespace this plugin owns. One offer per namespace (`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`), disposed with the calling fiber. - `ctx.llm.listModelDiscoveryNamespaces(): string[]` List the namespaces that can interrogate an endpoint, so a surface offers the action only where it works. - `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise` Ask one endpoint which models it advertises. -- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the effective retry policy captured during registration: the adapter override when present, otherwise the deployment default. +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults already resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize adapter-configured call defaults without clamping. @@ -48,7 +48,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` only for an explicit provider-owned recovery policy; omission inherits `LlmRuntime`'s deployment default. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, an output default, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use the route and model ids as names, advertise no models, and return no capacity, output default, or reasoning metadata. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, an output default, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use bounded normal retry policy, use the route and model ids as names, advertise no models, and return no capacity, output default, or reasoning metadata. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Messages (`message.ts`) and content blocks (`types.ts`) diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index f9492e6cd3..3e3683c346 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -8,9 +8,9 @@ 一个适配器注册表加单一流式调用接口,可通过 waterfall(瀑布式事件)拦截。 -### 配置 +### 重试策略 -`defaultRetryPolicy` 是每条未由适配器提供显式覆盖的提供方路由所继承的部署策略。省略它会保留两次重试的有界 normal 默认值。适配器覆盖优先,解析后的有效策略会随路由注册一同捕获;本服务存储该策略,但不执行重试。 +每个提供方适配器都会提供解析后的路由策略。省略提供方配置时使用有界 normal mode,在首次请求后最多重试五次。分层配置把 `mode` 改为 `always` 后可能残留 `maxRetries` 或 `retryableCodes`;解析过程会忽略这些不再生效的 normal-mode 字段,并捕获纯 always 策略。本服务存储有效策略,但不执行重试。 ### 公开 API @@ -21,7 +21,7 @@ - `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` 为本插件拥有的 settings namespace 提供查询提供方端点的能力。每个 namespace 只能有一个(`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`),并随调用 fiber dispose。 - `ctx.llm.listModelDiscoveryNamespaces(): string[]` 列出可以询问端点的 namespace,让界面只在可用之处提供该动作。 - `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise` 询问某个端点它公布了哪些模型。 -- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的有效重试策略:有适配器覆盖时使用该覆盖,否则使用部署默认值。 +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,其中 normal 默认值已经解析。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有该精确路由的适配器中,解析并校验确切模型身份,以及可用上下文、输出默认值和推理(reasoning)元数据;异步适配器可选地支持取消。 - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的调用默认值,但不自动调整。 @@ -48,7 +48,7 @@ ### 扩展点 -- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。仅在提供方拥有显式恢复策略时覆盖 `providerRetryPolicy()`;省略时继承 `LlmRuntime` 的部署默认值。覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量、输出默认值或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现将路由和模型 id 用作名称,不公布模型,也不返回容量、输出默认值或推理元数据。 +- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 可提供由提供方持有的恢复配置;覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量、输出默认值或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量、输出默认值或推理元数据。 - 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。包装层如果在已经发出分片后重试,就没有可持久记录的尝试边界;因此,随产品交付的 agent 重试策略改用 `agent/request-error`。 ### 消息(`message.ts`)与内容块(`types.ts`) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 9e4ce473d0..38d7554314 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,7 +7,6 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' import type { GenerateOptions, LlmConfigurableProvider, @@ -22,8 +21,8 @@ import type { StreamChunk, } from './types.ts' import { freezeMessage, type Message } from './message.ts' -import { resolveRetryPolicy, RetryPolicySchema } from './retry-policy.ts' -import type { ResolvedRetryPolicy, RetryPolicyConfig } from './retry-policy.ts' +import { resolveRetryPolicy } from './retry-policy.ts' +import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' @@ -172,12 +171,6 @@ export interface PreparedLlmCall { stream(options: GenerateOptions): AsyncIterable } -/** Deployment-wide defaults applied to provider routes without adapter-owned overrides. */ -export interface Config { - /** Model-request retry policy inherited by routes whose adapter omits one; omission uses normal defaults. */ - defaultRetryPolicy?: RetryPolicyConfig -} - /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include @@ -195,9 +188,9 @@ export abstract class LlmAdapter { } /** - * Return an explicit provider-owned retry policy override for this route. + * Return the provider-owned retry policy captured with this route. * @param _provider - a route passed to `registerAdapter()` for this instance. - * @returns a resolved override, or `undefined` to inherit the LLM deployment default. + * @returns a resolved policy, or `undefined` to use the normal defaults. */ providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined { return undefined @@ -289,22 +282,14 @@ export interface DirectoryRegistrationHandle { * API, interceptable via the `llm/stream` waterfall. */ export class LlmRuntime extends Service { - static Config: z = z.object({ - defaultRetryPolicy: RetryPolicySchema, - }) - private adapters = new Map() private directory = new Map() private discoveries = new Map< string, (request: LlmModelDiscoveryRequest) => Promise >() - /** Resolved once because service configuration is fixed for this runtime instance. */ - private readonly defaultRetryPolicy: ResolvedRetryPolicy - - constructor(ctx: Context, config: Config = {}) { + constructor(ctx: Context) { super(ctx, 'llm') - this.defaultRetryPolicy = resolveRetryPolicy(config.defaultRetryPolicy, 'llm: defaultRetryPolicy') } /** Notify topology observers without letting one broken listener veto the commit. */ @@ -399,7 +384,7 @@ export class LlmRuntime extends Service { } unique.add(provider) const retryPolicy = adapter.providerRetryPolicy(provider) - ?? this.defaultRetryPolicy + ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) registrations.push({ adapter, provider: { id: info.id, name: info.name }, @@ -573,9 +558,9 @@ export class LlmRuntime extends Service { } /** - * Read the effective retry policy captured when one provider route was registered. + * Read the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. - * @returns the adapter override or deployment default, fully resolved. + * @returns the provider-owned policy, with normal defaults already resolved. */ providerRetryPolicy(provider: string): ResolvedRetryPolicy { return this.registration(provider).retryPolicy diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts index 99f38e84b3..85b85db5bf 100644 --- a/packages/llm/llm/src/retry-policy.ts +++ b/packages/llm/llm/src/retry-policy.ts @@ -11,7 +11,7 @@ import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { EMPTY_RESPONSE_CODE } from './error.ts' -const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_MAX_RETRIES = 5 const DEFAULT_INITIAL_DELAY_MS = 500 const DEFAULT_MAX_DELAY_MS = 10_000 const DEFAULT_JITTER_RATIO = 0.1 @@ -37,7 +37,7 @@ export interface BackoffConfig { export interface NormalRetryPolicyConfig { /** Retry only configured transient failure codes. */ mode: 'normal' - /** Maximum eligible retries after the first request (default 2). */ + /** Maximum eligible retries after the first request (default 5). */ maxRetries?: number /** Stable failure codes eligible for this policy. */ retryableCodes?: string[] @@ -53,7 +53,7 @@ export interface AlwaysRetryPolicyConfig { backoff?: BackoffConfig } -/** Model-request retry policy configuration for a provider route or deployment default. */ +/** Model-request retry policy configuration for one provider route. */ export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig /** Fully resolved backoff shared by both retry modes. */ @@ -105,7 +105,11 @@ export const RetryPolicySchema: z = z.union([ const NORMAL_POLICY_KEYS: ReadonlySet = new Set([ 'mode', 'maxRetries', 'retryableCodes', 'backoff', ]) -const ALWAYS_POLICY_KEYS: ReadonlySet = new Set(['mode', 'backoff']) +// Layered configuration can retain normal-only fields after switching modes; +// always mode ignores those inactive values while still rejecting unknown keys. +const ALWAYS_POLICY_KEYS: ReadonlySet = new Set([ + 'mode', 'maxRetries', 'retryableCodes', 'backoff', +]) const BACKOFF_KEYS: ReadonlySet = new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio']) function validateKeys(value: object, allowed: ReadonlySet, path: string): void { @@ -138,7 +142,7 @@ function resolveBackoff(config: BackoffConfig | undefined, path: string): Resolv /** * Validate, default, and detach one retry policy. - * @param config - optional route or deployment configuration; omission selects normal defaults. + * @param config - optional provider configuration; omission selects normal defaults. * @param path - diagnostic path naming the configuration that owns the value. * @returns an immutable policy safe to capture in provider registration state. */ diff --git a/packages/llm/llm/tests/retry-policy.spec.ts b/packages/llm/llm/tests/retry-policy.spec.ts index 1860625147..cc7ebb8fa7 100644 --- a/packages/llm/llm/tests/retry-policy.spec.ts +++ b/packages/llm/llm/tests/retry-policy.spec.ts @@ -12,7 +12,7 @@ describe('provider retry policy', () => { expect(policy).toEqual({ mode: 'normal', - maxRetries: 2, + maxRetries: 5, retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'], initialDelayMs: 500, maxDelayMs: 10_000, @@ -59,6 +59,21 @@ describe('provider retry policy', () => { expect(RetryPolicySchema).toBeDefined() }) + it('ignores normal-only fields retained after switching to always mode', () => { + const layered = { + mode: 'always', + maxRetries: 5, + retryableCodes: ['SERVER'], + } as unknown as RetryPolicyConfig + + expect(resolveRetryPolicy(layered, 'provider.retryPolicy')).toEqual({ + mode: 'always', + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0.1, + }) + }) + it.each([ [{ mode: 'normal', maxRetries: -1 }, /maxRetries/], [{ mode: 'normal', maxRetries: 1.5 }, /maxRetries/], @@ -74,7 +89,6 @@ describe('provider retry policy', () => { [{ mode: 'normal', retryableCodes: [''] }, /non-empty strings/], [{ mode: 'normal', retryableCodes: [429] }, /non-empty strings/], [{ mode: 'normal', maxRetires: 1 }, /unknown key "maxRetires"/], - [{ mode: 'always', maxRetries: 1 }, /unknown key "maxRetries"/], [{ mode: 'always', backoff: { initialDelay: 1 } }, /unknown key "initialDelay"/], [{ mode: 'sometimes' }, /mode must be "normal" or "always"/], ] as const)('rejects invalid policy %#', (config, message) => { diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 2b8827aff5..000c2a6d88 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -203,7 +203,7 @@ describe('LlmRuntime', () => { expect(adapter.lastOptions?.messages[0]).toBe(message) }) - it('captures an adapter retry override ahead of the deployment default', async () => { + it('captures provider-owned retry policies and defaults omitted policies', async () => { const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy') const adapter = new class extends ScriptedAdapter { override providerRetryPolicy(provider: string) { @@ -211,9 +211,7 @@ describe('LlmRuntime', () => { } }(SCRIPT) const ctx = new Context() - await ctx.plugin(LlmRuntime, { - defaultRetryPolicy: { mode: 'normal', maxRetries: 5 }, - }) + await ctx.plugin(LlmRuntime) ctx.llm.registerAdapter(['configured', 'defaulted'], adapter) expect(ctx.llm.providerRetryPolicy('configured')).toBe(configured) @@ -226,27 +224,6 @@ describe('LlmRuntime', () => { ) }) - it('uses bounded normal defaults when the deployment omits a retry policy', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - ctx.llm.registerAdapter(['defaulted'], new ScriptedAdapter(SCRIPT)) - - expect(ctx.llm.providerRetryPolicy('defaulted')).toMatchObject({ - mode: 'normal', - maxRetries: 2, - }) - }) - - it('rejects an invalid deployment retry policy before any route registers', async () => { - const ctx = new Context() - await expect(ctx.plugin(LlmRuntime, { - defaultRetryPolicy: { - mode: 'normal', - backoff: { initialDelayMs: 10, maxDelayMs: 5 }, - }, - })).rejects.toThrow(/llm: defaultRetryPolicy/) - }) - it('keeps a prepared registration and retry policy after route replacement', async () => { const oldPolicy = resolveRetryPolicy({ mode: 'always' }, 'old retryPolicy') const newPolicy = resolveRetryPolicy({ mode: 'normal', maxRetries: 0 }, 'new retryPolicy') From 5372fc384e043b932141618ae5795f8643c3e585 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 16:36:41 +0800 Subject: [PATCH 79/95] chore(llm): trim retry default refactor --- docs/subsystems/llm-streaming.i18n.yaml | 4 ++-- docs/subsystems/llm-streaming.md | 4 ++-- docs/subsystems/llm-streaming.zh.md | 4 ++-- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 4 ++-- packages/llm/llm-deepseek/README.zh.md | 4 ++-- packages/llm/llm-pi-ai/src/config.ts | 3 +-- packages/llm/llm/README.i18n.yaml | 4 ++-- packages/llm/llm/README.md | 4 ++-- packages/llm/llm/README.zh.md | 6 +++--- packages/llm/llm/src/index.ts | 3 ++- packages/llm/llm/src/retry-policy.ts | 8 ++++---- packages/llm/llm/tests/service.spec.ts | 2 +- 14 files changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index c945560cc3..21290b63f6 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/llm-streaming.md -llm-streaming.md: c1d40bf3111bb534a5401506016256264b3767bf -llm-streaming.zh.md: 04e753d486f667eb4a63ef33d41e7103c03f4914 +llm-streaming.md: c1b2ab5f1e0926864f25409c021691d078df9e0f +llm-streaming.zh.md: 7bf04bf7a4a7de63d20eb67461842ff49f3b189d diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index c1d40bf311..c1b2ab5f1e 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -655,7 +655,7 @@ interface LlmCallConfigAdapterDefaults { ## Service and provider contracts -`LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` supplies a resolved provider policy; omission uses the shared normal defaults. `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn. +`LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn. ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -803,7 +803,7 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise /** - * Read the retry policy captured when one provider route was registered. + * Resolve the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. * @returns the provider-owned policy, with normal defaults already resolved. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 04e753d486..7bf04bf7a4 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -661,7 +661,7 @@ interface LlmCallConfigAdapterDefaults { ## 服务与提供方约定 -`LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 提供解析后的提供方策略;省略时使用共享的 normal 默认值。`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.md#turn-flow)。 +`LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.md#turn-flow)。 ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -809,7 +809,7 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise /** - * Read the retry policy captured when one provider route was registered. + * Resolve the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. * @returns the provider-owned policy, with normal defaults already resolved. */ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9cfd3dbdbb..78e948d3f9 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -832,7 +832,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', - description: 'Read the retry policy captured when one provider route was registered.', + description: 'Resolve the retry policy captured when one provider route was registered.', parameters: [{ name: 'provider', description: 'registered provider route to inspect.' }], returns: 'the provider-owned policy, with normal defaults already resolved.', }, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 67f9eb435f..cf54a96ec7 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 65c17f504e74408b37518149007c006d3836c4c6 -README.zh.md: 217d00c66ff332b67546e3efca2025347c1d28e0 +README.md: 9bb28e6876b82c521341769123a8b2d0e5d98e09 +README.zh.md: 21c55cfa32bf68e0cac4c0bd72c94c86d955fc00 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 65c17f504e..9bb28e6876 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -45,7 +45,7 @@ The same exact-model result exposes ordered `off`, `low`, `high`, and `max` effo `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `low`, `high`, or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the resolved provider policy, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. ## Dynamic configuration (settings + credentials) @@ -54,7 +54,7 @@ Connection facts are not frozen at load. `resolveAdapterOptions` is the one expl - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. - **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. -The adapter-owned registration fact is the resolved retry policy: when its value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section). `ctx.llm.providerRetryPolicy('deepseek-official')` therefore reports the current provider policy, including the shared five-retry normal default when configuration omits it. +The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. The plugin also declares its route in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`): provider `deepseek-official`, settings namespace `llm-deepseek`, empty settings path — the whole section is the profile. Configuration surfaces use that entry to offer this adapter alongside dormant pi-ai providers. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 217d00c66f..21c55cfa32 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -45,7 +45,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `low`、`high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 -`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它会注册解析后的提供方策略,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 ## 动态配置(settings + credentials) @@ -54,7 +54,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 - **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 -适配器拥有的注册事实是解析后的重试策略:其值变化时,插件会原地重新注册该路由(同一适配器实例、一个同步区段)。因此,`ctx.llm.providerRetryPolicy('deepseek-official')` 会报告当前提供方策略;省略配置时,其中包含共享的 normal 模式五次重试默认值。 +唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 该插件还会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`)中声明自己的路由:提供方为 `deepseek-official`,settings namespace 为 `llm-deepseek`,settings path 为空——整个分节就是 profile。配置界面借助该条目,把本适配器与休眠的 pi-ai 提供方一并呈现。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 6d3b21069b..cd143ea135 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -294,8 +294,7 @@ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): voi * Validate profiles and return a detached route-keyed map suitable for * per-request reads. This is the one explicit resolve step, so an omitted dict * resolves to the empty (dormant) route set here rather than through a hidden - * fallback, and each route's models, retry policy, and pi-ai provider are - * materialized once. + * fallback, and each route's models and pi-ai provider are materialized once. * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 31e098678a..0700b14362 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: 1ff98b9103f434a0d3669b13eec291ec68ba55f7 -README.zh.md: 3e3683c3465d83125f455780c6a0104e3998fba5 +README.md: e6b3c4924ad4e7cf115abdbfb38d22d0f524e377 +README.zh.md: 51f034af632c19ca91c1d2f91a7c9c22714870e0 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 1ff98b9103..e6b3c4924a 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -21,7 +21,7 @@ Each provider adapter supplies its resolved route policy. Omitting provider conf - `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` Offer to interrogate provider endpoints for the settings namespace this plugin owns. One offer per namespace (`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`), disposed with the calling fiber. - `ctx.llm.listModelDiscoveryNamespaces(): string[]` List the namespaces that can interrogate an endpoint, so a surface offers the action only where it works. - `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise` Ask one endpoint which models it advertises. -- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults already resolved. +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize adapter-configured call defaults without clamping. @@ -32,7 +32,7 @@ Each provider adapter supplies its resolved route policy. Omitting provider conf Interrogating an endpoint is configuration-time work over a *draft*, keyed by settings namespace rather than by provider route — the provider a surface is adding does not exist yet, so there is no route to name. The request may still *name* a route it is editing, and an adapter that already describes that route answers from its own knowledge without a network call; `baseURL` is optional and one of the two is required. The request otherwise carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and a request naming neither a route nor an endpoint fails with `INVALID_DISCOVERY`. -Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures each route's effective retry policy, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. +Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. Every topology commit point — adapter routes registering or disposing, directory entries appearing or withdrawing — emits the payload-free `llm/adapters-updated` event after the mutation, so consumers re-read `listProviders()`/`listModels()`/`listConfigurableProviders()` instead of polling. Observer failures are contained (logged, non-vetoing); only `INVARIANT`-coded failures rethrow after the fan-out. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 3e3683c346..51f034af63 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -21,7 +21,7 @@ - `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` 为本插件拥有的 settings namespace 提供查询提供方端点的能力。每个 namespace 只能有一个(`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`),并随调用 fiber dispose。 - `ctx.llm.listModelDiscoveryNamespaces(): string[]` 列出可以询问端点的 namespace,让界面只在可用之处提供该动作。 - `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise` 询问某个端点它公布了哪些模型。 -- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,其中 normal 默认值已经解析。 +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方自身的重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有该精确路由的适配器中,解析并校验确切模型身份,以及可用上下文、输出默认值和推理(reasoning)元数据;异步适配器可选地支持取消。 - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的调用默认值,但不自动调整。 @@ -32,7 +32,7 @@ 询问端点属于配置期针对**草稿**的操作,以 settings namespace 而非提供方路由为键——界面正在新增的提供方还不存在,也就没有路由可点名。但请求仍可**点名**它正在编辑的路由,而已经描述该路由的适配器会用自己的知识作答,无需联网;路由名称和 `baseURL` 至少需要提供一项。除此之外,请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据。这里既不读取也不写入 settings 或 credentials;返回内容是界面可以提供给用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,既不点名路由也不给端点的请求以 `INVALID_DISCOVERY` 失败。 -提供方和模型元数据用于发现,不构成路由白名单。`registerAdapter()` 仍拥有提供方路由的排他性,并为每条路由捕获有效重试策略;适配器可以接受未出现在 `listModels()` 中的模型 id,消费方不得仅因模型未列出而拒绝请求。返回的 selector 元数据已分离;无效或重复的适配器条目会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 +提供方和模型元数据用于发现,不构成路由白名单。`registerAdapter()` 仍拥有提供方路由的排他性,并为每条路由捕获适配器的重试策略;适配器可以接受未出现在 `listModels()` 中的模型 id,消费方不得仅因模型未列出而拒绝请求。返回的 selector 元数据已分离;无效或重复的适配器条目会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 每个拓扑提交点——适配器路由注册或 dispose、目录条目出现或撤回——都会在变更之后发出无载荷的 `llm/adapters-updated` 事件,消费方因此会重新读取 `listProviders()`/`listModels()`/`listConfigurableProviders()`,而不是轮询。观察者故障会被记录并隔离,不能否决变更;只有带 `INVARIANT` 码的故障会在通知完所有观察者后重新抛出。 @@ -48,7 +48,7 @@ ### 扩展点 -- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 可提供由提供方持有的恢复配置;覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量、输出默认值或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量、输出默认值或推理元数据。 +- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 以提供由提供方定义的恢复配置,覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量、输出默认值或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界的 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量、输出默认值或推理元数据。 - 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。包装层如果在已经发出分片后重试,就没有可持久记录的尝试边界;因此,随产品交付的 agent 重试策略改用 `agent/request-error`。 ### 消息(`message.ts`)与内容块(`types.ts`) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 38d7554314..e87c428d06 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -288,6 +288,7 @@ export class LlmRuntime extends Service { string, (request: LlmModelDiscoveryRequest) => Promise >() + constructor(ctx: Context) { super(ctx, 'llm') } @@ -558,7 +559,7 @@ export class LlmRuntime extends Service { } /** - * Read the retry policy captured when one provider route was registered. + * Resolve the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. * @returns the provider-owned policy, with normal defaults already resolved. */ diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts index 85b85db5bf..f6e6175cb9 100644 --- a/packages/llm/llm/src/retry-policy.ts +++ b/packages/llm/llm/src/retry-policy.ts @@ -1,5 +1,5 @@ /** - * Provider-route request-retry policy configuration and resolution. + * Provider-owned request-retry policy configuration and resolution. * * Adapters expose one resolved policy per registered provider route; the * optional dsh-llm-retry plugin executes it on the agent's failed-step extension point. @@ -53,7 +53,7 @@ export interface AlwaysRetryPolicyConfig { backoff?: BackoffConfig } -/** Model-request retry policy configuration for one provider route. */ +/** Provider-owned model-request retry policy configuration. */ export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig /** Fully resolved backoff shared by both retry modes. */ @@ -141,9 +141,9 @@ function resolveBackoff(config: BackoffConfig | undefined, path: string): Resolv } /** - * Validate, default, and detach one retry policy. + * Validate, default, and detach one provider-owned retry policy. * @param config - optional provider configuration; omission selects normal defaults. - * @param path - diagnostic path naming the configuration that owns the value. + * @param path - diagnostic path naming the provider config that owns the value. * @returns an immutable policy safe to capture in provider registration state. */ export function resolveRetryPolicy( diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 000c2a6d88..45f523c129 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -203,7 +203,7 @@ describe('LlmRuntime', () => { expect(adapter.lastOptions?.messages[0]).toBe(message) }) - it('captures provider-owned retry policies and defaults omitted policies', async () => { + it('captures provider-owned retry policy at registration and defaults omission', async () => { const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy') const adapter = new class extends ScriptedAdapter { override providerRetryPolicy(provider: string) { From 75c9f05c6ba8e060790beadd677d1ed6523307dd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 16:40:54 +0800 Subject: [PATCH 80/95] test(llm): expect five replay retries --- packages/test-support/llm-replay/tests/llm-replay.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts index 837f5d9af0..27356a8132 100644 --- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts @@ -644,7 +644,7 @@ describe('installLlmReplay (through the real LlmRuntime)', () => { }) expect(ctx.llm.providerRetryPolicy('empty')).toMatchObject({ mode: 'normal', - maxRetries: 2, + maxRetries: 5, initialDelayMs: 500, maxDelayMs: 10_000, jitterRatio: 0.1, From d415b63c1972ab846f2093c71edee91728b4a16f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 16:54:16 +0800 Subject: [PATCH 81/95] test(client): cover settings lifecycle guards --- .../tests/settings-store.client.spec.ts | 24 +++++++++ .../tests/stores.client.spec.ts | 42 ++++++++++++++- .../tests/settings-scope.client.spec.ts | 54 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index 4e37cbb65b..8ddc1033ea 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -187,6 +187,23 @@ describe('permission settings store', () => { }).controller await thrown.load() expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) + + const wire = { + settings: { + describe: () => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [view('read-only')], + })), + mutate, + }, + } as never + const mirror = new SettingsDescribeMirror(wire) + const malformed = new PermissionPresetSettingsController(mirror, wire, { + rehydrate: () => { throw 'schema disconnected' }, + } as never) + await malformed.load() + expect(malformed.store.getSnapshot()).toMatchObject({ + status: 'error', error: 'schema disconnected', + }) }) it('hides the row in a remote browser instead of loading forever', async () => { @@ -216,6 +233,13 @@ describe('permission settings store', () => { }) it('disposal stops deriving and suppresses in-flight writes', async () => { + const neverRead = vi.fn() + const { controller: neverLoaded } = permissionController({ describe: neverRead, mutate: vi.fn() }) + neverLoaded.dispose() + await neverLoaded.load() + expect(neverLoaded.store.getSnapshot().status).toBe('idle') + expect(neverRead).not.toHaveBeenCalled() + const read = Promise.withResolvers { expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) }) + it('ignores a mirror notification already queued when disposal starts', () => { + let notify = (): void => {} + let snapshot: SettingsMirrorSnapshot = { + status: 'ready' as const, + view: { writable: true, hasDocument: true, namespaces: [] }, + error: null, + } + const describeFace = { + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + notify = listener + return () => {} + }, + ensure: () => Promise.resolve(), + acceptView: vi.fn(), + } as never + const controller = new ConfigurablePluginsTabController(describeFace, () => ledger('bash')) + expect(controller.inject().hooks.configurablePlugins.getSnapshot()) + .toEqual({ loaded: true, namespaces: [] }) + + controller.dispose() + snapshot = { + status: 'ready', + view: { + writable: true, + hasDocument: true, + namespaces: [{ + ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1, + }], + }, + error: null, + } + notify() + + expect(controller.inject().hooks.configurablePlugins.getSnapshot()) + .toEqual({ loaded: true, namespaces: [] }) + }) + it('reports the Host answered even when it serves nothing this tab shows', async () => { const settings = settingsApi(['ui-theme']) const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 0437f602b5..ddbb6784d1 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -259,6 +259,27 @@ describe('SettingsScopeController', () => { expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) }) + it('keeps the write queue usable when a write publication listener throws', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1)) + const mutate = vi.fn() + .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2))) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + let shouldThrow = true + mirror.subscribe(() => { + if (!shouldThrow) return + shouldThrow = false + throw new Error('write subscriber failed') + }) + + await expect(scope.set('preference', 'dark')).rejects.toThrow('write subscriber failed') + await expect(scope.set('preference', 'light')).resolves.toBeUndefined() + + expect(mutate).toHaveBeenCalledTimes(2) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 }) + }) + it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { const first = deferred>() const mutate = vi.fn().mockReturnValue(first.promise) @@ -292,6 +313,38 @@ describe('SettingsScopeController', () => { expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) }) + it('ignores a mirror notification already queued when disposal starts', async () => { + let notify = (): void => {} + let snapshot = { + status: 'ready' as const, + view: { + writable: true, hasDocument: true, + namespaces: [view({ preference: 'dark' }, 1)], + }, + error: null, + } + const mirror = { + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + notify = listener + return () => {} + }, + } as never + const wire = { settings: {} } as never + const scope = new SettingsScopeController( + wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) + + await scope.dispose() + snapshot = { + ...snapshot, + view: { ...snapshot.view, namespaces: [view({ preference: 'light' }, 2)] }, + } + notify() + + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) + }) + it('keeps a remote browser in memory mode without Host calls', async () => { const describeCall = vi.fn() const mutate = vi.fn() @@ -380,6 +433,7 @@ describe('SettingsScopeBinder.bind', () => { let locale!: SettingsScope new TestRemote(ctx) await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await() + expect(ctx.settingsScope.describe()).toBe(mirror) const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { From 03fd7c003b57277952e68a1ae5d179686e337c2e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 16:57:31 +0800 Subject: [PATCH 82/95] test(web): update retry status golden --- apps/web/tests/snapshots/live-interactions/retry.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 504c21099f..7f4275344b 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -17,7 +17,7 @@ - img - text: Context injection @deepseek-ai/dsh-system-prompt - group: - - status: Retried model request (1/2) · {{duration}} + - status: Retried model request (1/5) · {{duration}} - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img From 2b3a8d1845f424693f50d65f2c77af85db69ce20 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 17:18:39 +0800 Subject: [PATCH 83/95] ci: refresh pull request checks From 0fe31f11a33661fd52fdca2d1d7b80cf8e06da79 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 17:39:06 +0800 Subject: [PATCH 84/95] fix(locale): correct two stale product-default-Chinese comments Chinese is no longer the product default since FALLBACK_LOCALE moved to en in this branch. connectFreshWorkspaceZh and the access-confirmation scenario both reach the Chinese surface by advertising ZH_BROWSER_LOCALE, not by inheriting a default, so their comments must say so. --- apps/web/tests/access-confirmation.e2e.ts | 5 +++-- apps/web/tests/support.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index aea33f14f1..a173f8399e 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -30,8 +30,9 @@ describe('web e2e: Full access confirmation', () => { // is temporarily unavailable. const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH browser = await chromium.launch(executablePath === undefined ? {} : { executablePath }) - // Keep the product default Chinese locale: the golden pins the actual - // registered dictionary rather than a test-local translation callback. + // Keep the Chinese surface via {@link ZH_BROWSER_LOCALE}: the golden pins + // the actual registered dictionary rather than a test-local translation + // callback. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 38d0849784..3a1cd94782 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -88,9 +88,10 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo } /** - * {@link connectFreshWorkspace} over the product default Chinese locale: the - * English helper's anchors assume the locale every other scenario boots, so a - * scenario that deliberately keeps zh needs the localized picker copy. + * {@link connectFreshWorkspace} over a page that advertises + * {@link ZH_BROWSER_LOCALE}: the English helper's anchors assume the locale + * most other scenarios boot, so a scenario that deliberately keeps zh needs + * the localized picker copy. * @param page - the browser page under test. * @param root - workspace parent directory. * @param name - directory created under `root` and connected. From 9301def7ebd2ba0b457cfce95299053f38b61d91 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 17:55:10 +0800 Subject: [PATCH 85/95] fix(locale): tighten parity gate and correct copy-source wording Address the three open review threads on the dictionary parity gate. Regex/TEXT: localeOf now requires an uppercase ASCII [A-Z] flat-letter at the third position of a name-prefix shape, so zh2Foo/zh_probe are no longer treated as dictionaries in localeOf while the admission pre-filter skips them. The two now agree exactly. register detection now also admits a bare register identifier callee in addition to a property access, covering a future destructured register(NS, 'zh'|'en', dict) call instead of silently dropping it. A 3-arg register whose dictionary argument is a local variable is resolved through module-scope const initializers; one that cannot be resolved to an object literal makes the gate refuse with a named error rather than skipping the registration and narrowing the sweep. Also restate the FALLBACK_LOCALE rationale: the residual case points at English because a browser naming neither shipped language is the reader least likely to read Chinese, not because English is the copy's source language (Chinese is; packages/client/AGENTS.md). Sync the identical claim in the bilingual Agent Note and re-record its .i18n.yaml pairing. --- ...1-browser-derived-initial-locale.i18n.yaml | 4 +- ...26-07-31-browser-derived-initial-locale.md | 2 +- ...07-31-browser-derived-initial-locale.zh.md | 2 +- packages/client/locale/src/client/index.ts | 4 +- scripts/locale-dictionary-parity.spec.ts | 65 +++++++++++++++---- 5 files changed, 59 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index f1168d973c..9aa0372a26 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.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-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 6fcd799b9c3e6ec898725e0ef72106b63f613bee -2026-07-31-browser-derived-initial-locale.zh.md: 73f2c825e11bb0380fe172b4b4522295d419e9a5 +2026-07-31-browser-derived-initial-locale.md: 66fd56327aeb4463bfb8f6426ce7f7962d339782 +2026-07-31-browser-derived-initial-locale.zh.md: 721a785aa476951e7254c50230ddc092b9f8b211 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 6fcd799b9c..66fd56327a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -14,7 +14,7 @@ Reading the browser fixed the readers whose browser names a language this app sh **The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. -**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`, the source language of the copy. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. +**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`. The residual case points at English rather than zh because a browser naming neither shipped language is the reader least likely to read Chinese. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index 73f2c825e1..721a785aa4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -14,7 +14,7 @@ Status: implemented **暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 -**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`,也就是文案的源语言。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 +**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`。残余情形指向英文而非 `zh`,是因为一个声明了本应用都不支持的语言的浏览器,其读者最不可能读中文。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 3f14acf216..5b1d6c72b4 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -91,7 +91,9 @@ declare module '@deepseek-ai/cordis' { * language (and for non-browser runs), and the dictionary consulted after the * active locale misses a key. One constant serves both because the shipped * `zh`/`en` dictionaries carry identical key sets, so neither direction can - * leave a key unresolved; English is the source language of the copy. + * leave a key unresolved; the residual case points at English rather than + * zh because a browser naming neither shipped language is the reader least + * likely to read Chinese. */ export const FALLBACK_LOCALE: LocaleId = 'en' diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index b240c564ae..b51630f105 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -103,6 +103,20 @@ function dictionariesIn(file: string): Dictionary[] { const found: Dictionary[] = [] const rel = relative(file) + // Module-scope variable declarations, keyed by name. A 3-arg + // `register(NS, 'zh'|'en', dict)` whose third argument is an identifier — + // e.g. a local dictionary variable rather than an inline literal — resolves + // through here so the gate still verifies its symmetry. + const moduleConsts = new Map() + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const decl of statement.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.initializer !== undefined) { + moduleConsts.set(decl.name.text, decl.initializer) + } + } + } + for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue @@ -115,6 +129,14 @@ function dictionariesIn(file: string): Dictionary[] { } } + // A 3-arg `register(ns, 'zh'|'en', dict)` call whose dictionary argument we + // cannot turn into an object literal. We refuse instead of skipping: a + // registration we cannot measure is exactly the silent narrowing this gate + // exists to catch. + const refuse = (ns: string, tag: string, why: string): never => { + throw new Error(`cannot verify register('${ns}', '${tag}', ...) in ${rel}: ${why}`) + } + // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair // handed to a registration loop keys off the enclosing array; separate // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the @@ -122,20 +144,34 @@ function dictionariesIn(file: string): Dictionary[] { const visit = (node: ts.Node): void => { if (ts.isCallExpression(node)) { const callee = node.expression - const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined + const name = ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ts.isIdentifier(callee) && callee.text === 'register' ? 'register' : undefined if (name === 'register' && node.arguments.length >= 3) { const [ns, tag, dict] = node.arguments - const literal = unwrap(dict) - if ( - ns !== undefined && tag !== undefined && ts.isStringLiteral(tag) - && (tag.text === 'zh' || tag.text === 'en') - && literal !== undefined && ts.isObjectLiteralExpression(literal) - ) { - // The namespace expression's source text identifies the pair, so the - // zh and en calls for one namespace meet and calls for different - // namespaces stay apart. - found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(literal) }) + if (ns === undefined || tag === undefined || !ts.isStringLiteral(tag)) return + if (tag.text !== 'zh' && tag.text !== 'en') return + const raw = unwrap(dict) + const literal = raw !== undefined && ts.isIdentifier(raw) + ? (() => { + const resolved = moduleConsts.get(raw.text) + return resolved === undefined ? undefined : unwrap(resolved) + })() + : raw + const why = raw !== undefined && ts.isIdentifier(raw) + ? `third argument ${raw.text} does not resolve to an inline or module-scope object literal` + : 'third argument is neither an object literal nor a resolvable dictionary variable' + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) { + // The dictionary argument must resolve to an object literal; the + // gate refuses rather than skips, so the symmetry it verifies never + // silently narrows. + refuse(ns.getText(source), tag.text, why) } + const dictionary: ts.ObjectLiteralExpression = literal as ts.ObjectLiteralExpression + // The namespace expression's source text identifies the pair, so the + // zh and en calls for one namespace meet and calls for different + // namespaces stay apart. + found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary) }) } } if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { @@ -181,7 +217,10 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { /** * The locale a dictionary name declares, and the namespace-ish remainder that * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`, - * and `settingsZh`/`settingsEn` are the shapes this repo uses. + * and `settingsZh`/`settingsEn` are the shapes this repo uses. A name-prefix + * shape requires an uppercase ASCII letter at the third position (`[A-Z]`), + * matching the admission of the cheap pre-filter, so `zh2Foo`/`zh_probe` + * cannot be treated as dictionaries in one place and skipped in another. * @param name - export name or synthetic inline name. * @returns locale plus pair key, or undefined when the name names no locale. */ @@ -192,7 +231,7 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin // Synthetic names for inline shapes carry their own pair key after the // first ':' (the enclosing array's line, or the namespace expression). if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) } - if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { + if (name.startsWith(locale) && name.length > 2 && /[A-Z]/.test(name[2] ?? '')) { return { locale, pair: name.slice(2) } } if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) } From 3967df95f78061de815f9e62630a85bdafb8436b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 18:19:30 +0800 Subject: [PATCH 86/95] docs(locale): clarify full-rollout note on the en fallback default The Consequences bullet named the zh copy surface the 'zh default', which could be read as the product's opening locale. It covers component-copy coverage only; the opening/fallback locale (browser naming no shipped language, or a non-browser run) is en since FALLBACK_LOCALE moved. State that explicitly and cross-link the browser-derived-initial-locale note, in both languages, and re-record the .i18n.yaml pairing. --- .../2026-07-30-client-locale-full-rollout.i18n.yaml | 4 ++-- .../architecture/2026-07-30-client-locale-full-rollout.md | 2 +- .../architecture/2026-07-30-client-locale-full-rollout.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index ecca8ab2b0..007b9c0d73 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: c6c5a8f2faffd3e03462eaad159ae94c53c735ce -2026-07-30-client-locale-full-rollout.zh.md: 8d6220784104944f5d07b533e4f107ceffdfcfea +2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab +2026-07-30-client-locale-full-rollout.zh.md: 0c05ec9699700d88d786bc661f7013f2ed09ebb2 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index c6c5a8f2fa..6701aefa45 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -42,4 +42,4 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue. - Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically. - ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo. -- Pinning e2e to English means the zh default is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. +- Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 8d62207841..0c05ec9699 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -42,4 +42,4 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。 - 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。 - ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。 -- e2e 英文钉死意味着 zh 默认态主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。 +- e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)。 From 4be7e7680e157381919f6bf961bc4bba5869e896 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 19:04:18 +0800 Subject: [PATCH 87/95] test(acp): refresh product subagent skill snapshot --- examples/acp-agent/tests/snapshots/skill-load/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 89aea22173..2ab00a9939 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -19,7 +19,7 @@ {"type":"assistant/chunk","seq":17,"time":1785730426828,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":18,"time":1785730426828,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fd7a47e-84c9-4d31-aa95-9939671ba0a5"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[10,11,12,13,14,15,16,17],"surfaceOp":"append"} {"type":"tool/call","seq":19,"time":1785730426828,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} -{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nThe Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nThe Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start.\n\nCodex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nThe two rows are independent. Leaving both disabled preserves the copied preset; enabling one exposes only that available product tool. The Claude Code row requires its Bundle, while the Codex row requires an explicit Host composition and a host `codex` on `PATH`. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base Host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Neither installing the Claude Code Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"e710fcbb-f128-463c-8db2-f90cdc0ad9b8"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nThe Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nThe Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start.\n\nCodex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nFor additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings.\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"a423b3fb-a703-4494-b186-5861ab00cc03"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","seq":21,"time":1785730426838,"data":{"turn":1,"step":1}} {"type":"step/start","seq":22,"time":1785730426848,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} From 72ae04abde84856bf653989648923531cd7fa632 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 19:16:22 +0800 Subject: [PATCH 88/95] test(subagent): cover bounded Codex stderr tail --- packages/subagent/subagent-codex/tests/subagent-codex.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 60ba787099..6ba6f65e87 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1677,7 +1677,7 @@ describe('run lifecycle and quiescence', () => { it('surfaces only the wrapper missing-payload diagnostic during startup', async () => { const child = fakeChild() child.setStderr([ - 'credential-like unrelated stderr', + `credential-like unrelated stderr ${'x'.repeat(16 * 1024)}`, 'Error: Missing optional dependency @openai/codex-linux-x64. ' + 'Reinstall Codex: pnpm add -g @openai/codex@latest', ].join('\n')) From 0f734991e15e32743914e483300017d1f165e509 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 19:19:58 +0800 Subject: [PATCH 89/95] test(web): refresh product subagent skill rendering --- apps/web/tests/skill-tool-row.e2e.ts | 2 +- apps/web/tests/snapshots/skill-tool-row/ui.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 3e18ff9548..5c23f99935 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -63,7 +63,7 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { const output = call.locator('pre') await output.waitFor() expect(await output.textContent()).toContain('') - expect(await output.textContent()).toContain('The Claude Code Bundle installs and exclusively uses the matching platform CLI') + expect(await output.textContent()).toContain('Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI') expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px') const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index f4acc2a978..8c7f593914 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -25,7 +25,7 @@ - button "Skill editing-cordis-compositions" [expanded]: - img - text: Skill editing-cordis-compositions -- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents The Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` The Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start. Codex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool. 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 backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` The two rows are independent. Leaving both disabled preserves the copied preset; enabling one exposes only that available product tool. The Claude Code row requires its Bundle, while the Codex row requires an explicit Host composition and a host `codex` on `PATH`. The Claude Code Bundle installs and exclusively uses the matching platform CLI selected by its pinned Agent SDK; it does not inspect or fall back to a host `claude`, and a missing optional payload fails the first delegation. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base Host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Neither installing the Claude Code Bundle nor composing either preset row starts a product, authenticates an account, selects a model, probes credentials, or manages native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " +- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents The Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` The Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start. Codex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool. 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 backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " - button "Inspect" - button "Think The skill is loaded.": - img From ef75b6ff2fe9a7b83b8bd58bbce8f49e59e01519 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:39:59 +0800 Subject: [PATCH 90/95] perf(ci): parallelize coverage and web snapshots in-job --- ...30-settings-write-path-integrity.i18n.yaml | 4 +- ...026-07-30-settings-write-path-integrity.md | 2 +- ...-07-30-settings-write-path-integrity.zh.md | 2 +- ...26-07-06-parallel-pre-push-gates.i18n.yaml | 4 +- .../2026-07-06-parallel-pre-push-gates.md | 13 +- .../2026-07-06-parallel-pre-push-gates.zh.md | 13 +- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 16 +- ...evidence-based-larger-hosted-runners.zh.md | 16 +- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +- .../process/2026-07-26-ci-failover-runbook.md | 6 +- .../2026-07-26-ci-failover-runbook.zh.md | 6 +- ...-31-coverage-exempt-heavy-suites.i18n.yaml | 4 +- ...2026-07-31-coverage-exempt-heavy-suites.md | 8 +- ...6-07-31-coverage-exempt-heavy-suites.zh.md | 8 +- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 6 +- ...08-08-native-windows-pull-request-ci.zh.md | 6 +- ...8-18-in-job-partitioned-coverage.i18n.yaml | 6 + .../2026-08-18-in-job-partitioned-coverage.md | 51 ++++ ...26-08-18-in-job-partitioned-coverage.zh.md | 51 ++++ ...-30-web-browser-snapshot-ci-gate.i18n.yaml | 4 +- ...2026-07-30-web-browser-snapshot-ci-gate.md | 12 +- ...6-07-30-web-browser-snapshot-ci-gate.zh.md | 12 +- .github/workflows/ci.yml | 16 +- apps/web/tests/steering.e2e.ts | 4 +- apps/web/tests/workspace-management.e2e.ts | 5 +- package.json | 2 + .../tests/agent-instructions.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 5 +- packages/util/atomic-write/README.i18n.yaml | 4 +- packages/util/atomic-write/README.md | 2 +- packages/util/atomic-write/README.zh.md | 2 +- packages/util/atomic-write/src/index.ts | 30 ++- .../atomic-write/tests/atomic-write.spec.ts | 50 +++- scripts/coverage-partitions.spec.ts | 229 ++++++++++++++++ scripts/coverage-partitions.ts | 248 ++++++++++++++++++ scripts/install-lefthook.spec.ts | 7 +- scripts/run-coverage-partitions.ts | 30 +++ scripts/run-gates.spec.ts | 46 +++- scripts/run-gates.ts | 70 +++-- scripts/run-web-snapshots.ts | 48 ++++ vitest.config.ts | 31 ++- vitest.web.config.ts | 3 +- 44 files changed, 964 insertions(+), 134 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md create mode 100644 .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md create mode 100644 scripts/coverage-partitions.spec.ts create mode 100644 scripts/coverage-partitions.ts create mode 100644 scripts/run-coverage-partitions.ts create mode 100644 scripts/run-web-snapshots.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml index 4012912001..5e158d2c8b 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.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-30-settings-write-path-integrity.md -2026-07-30-settings-write-path-integrity.md: c01f04a9b88417115505a8fc9fd3641055e95472 -2026-07-30-settings-write-path-integrity.zh.md: 967acf3266451e5a3974b37bc5703e5d45592007 +2026-07-30-settings-write-path-integrity.md: 7a2d377586ff2bfa7caeb9d4196ee99f70d3e63f +2026-07-30-settings-write-path-integrity.zh.md: fa68bfba04382d6cafd03bf174ebadcd6bafd519 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md index c01f04a9b8..7a2d377586 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md @@ -14,7 +14,7 @@ The provider's write path could destroy state it never observed, and the Service **One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active. -**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config. +**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. `EEXIST` identifies contention directly; `EPERM` identifies it only when `lstat` confirms that the lock path exists, because Windows may report permission denial for an exclusive create against that existing path. An unrelated permission failure remains loud. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config. **Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is. diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md index 967acf3266..fa68bfba04 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 **单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其「告警并保留最后可用值」策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。 -**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。 +**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。`EEXIST` 直接表示竞争;只有 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,因为 Windows 可能把针对该现有路径的独占创建报告为权限拒绝。无关的权限故障仍会响亮失败。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。 **观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件约定现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index 09a9e73b71..029c9a46ab 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.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-07-06-parallel-pre-push-gates.md -2026-07-06-parallel-pre-push-gates.md: 538e52c5318fb6d4eab2e8786513a08c1ff0ec55 -2026-07-06-parallel-pre-push-gates.zh.md: 0830e99484ebad40aa28ba6d2cfed1f09cfbee42 +2026-07-06-parallel-pre-push-gates.md: 189d6c2dfe08a9551037b936fd8015a3e86d1e51 +2026-07-06-parallel-pre-push-gates.zh.md: 17920b189c30db57f661df41a2664e3e727d1589 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 538e52c531..189d6c2dfe 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -12,9 +12,11 @@ Aggregate jobs such as documentation synchronization hide long sequential chains ## Decision -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output by default, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. A gate marked `allowFailure` still reports its result but does not fail the aggregate. -The Node 24 consumer job is one seven-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count while dependencies control readiness: `publint` precedes built-package invariant validation, and snapshot replay, NodeNext type checks, built-bin smokes, and lint wait for that validation. Lint waits because the invariant verifier temporarily stages package views that the linter must not traverse; source compatibility checks can overlap the validation chain. +Long coordinator gates whose own subprocesses preserve useful attribution may opt into `streamOutput`. Their stdout and stderr reach the parent immediately without being buffered or printed again at completion. Partitioned coverage and parallel Web snapshots use this mode so a mid-run failure is visible without waiting for sibling work. + +The Node 24 consumer job is one ten-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count, while pull-request CI caps active gates at eight and dependencies control readiness. Build and source compatibility start immediately; after build, `publint` and built-package invariant validation run in parallel. Lint, both snapshot suites, documentation typechecking, NodeNext type checks, and built-bin smokes wait for the invariant validator to remove its temporary package views. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. @@ -22,13 +24,14 @@ The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygie ## Verification -[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins the consumer inventory and dependency edges, and exercises signal termination through a real child process. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run. +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins the consumer and native Windows inventories and their dependency or failure semantics, exercises signal termination through a real child process, and proves that streamed output is immediate and unbuffered. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run. ## Alternatives considered - **Keep aggregate jobs serial** — simpler execution but makes wall clock equal the sum of independent checks and repeats command-wrapper startup. - **Declare one CI job per leaf gate** — exposes maximum workflow parallelism but repeats checkout, setup, and install overhead and duplicates the scheduler inventory in YAML. - **Background subcommands inside shell scripts** — parallelizes work but loses per-gate timing, deterministic failure grouping, and straightforward signal handling. +- **Inherit stdio for every gate** — exposes progress immediately but interleaves ordinary independent gates and discards the scheduler's attributable output record. Streaming remains an explicit gate property. - **Declare one `publint` job per package** — exposes maximum package parallelism but creates a hand-maintained package inventory that drifts when packages change. - **Run `publint` with unbounded concurrency** — minimizes elapsed time on small repositories only by gambling with process count, memory pressure, package tarball creation, and readable logs. @@ -36,6 +39,8 @@ The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygie Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. Invalid graphs fail before partial execution. The cost is a custom scheduler with an explicit mode inventory. -The consumer validation chain delays restored-artifact consumers and lint until the shared artifact view is known-good and transient staging is gone; those downstream gates can still overlap one another. +The consumer validation chain delays validated-artifact consumers and lint until the shared artifact view is known-good and transient staging is gone; those downstream gates can still overlap one another. `publint` needs the build but not the staged validation view, so it overlaps the validator instead of extending that chain. + +Most gates retain deterministic output blocks. Selected long coordinators trade cross-gate ordering and buffered logs for immediate diagnostics, while their final status remains available to the aggregate summary. `publint-all.ts` is asynchronous and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 0830e99484..17920b189c 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -12,9 +12,11 @@ Status: implemented ## 决策 -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和按需启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,缓冲可归因的输出,分别报告进程退出与信号终止结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和按需启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,默认缓冲可归因的输出,分别报告进程退出与信号终止结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。标记为 `allowFailure` 的门禁仍会报告结果,但不会使聚合流程失败。 -Node 24 消费方任务采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 linter 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。 +自身子进程能够保留有效归因的长时间协调门禁可以选择 `streamOutput`。其 stdout 与 stderr 会立即到达父进程,不会被缓冲,也不会在结束时重复打印。分区覆盖率与并行 Web 快照使用该模式,使运行中途的失败无需等待兄弟工作结束就能显示。 + +Node 24 消费方任务采用单个包含 10 道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,拉取请求 CI 则把活动门禁限制为 8 道,并由依赖关系控制就绪状态。构建与源码兼容性立即启动;构建完成后,`publint` 与已构建包不变式验证并行运行。lint、两套快照、文档类型检查、NodeNext 类型检查和 built-bin 冒烟测试等待不变式验证器清除临时包视图。 [scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages//` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 @@ -22,13 +24,14 @@ Node 24 消费方任务采用单个包含七道门禁的模式,而非由 shell ## 验证 -[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定消费方清单和依赖边,并通过真实子进程验证信号终止。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。 +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定消费方与原生 Windows 清单及其依赖或失败语义,通过真实子进程验证信号终止,并证明流式输出会立即显示且不被缓冲。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。 ## 曾考虑的替代方案 - **保持聚合 job 串行**:执行更简单,但墙钟时间等于各独立检查之和,并重复启动命令包装器。 - **每个叶子门禁声明一个 CI job**:暴露最大工作流并行度,但会重复 checkout、设置和安装开销,并在 YAML 中复制调度器清单。 - **在 shell 脚本内后台运行子命令**:可以并行处理,但会失去各门禁计时、确定性的失败分组和直接的信号处理。 +- **让所有门禁继承 stdio**:可以立即显示进度,但会交错普通独立门禁的输出,并丢失调度器可归因的输出记录。流式输出仍是显式的门禁属性。 - **每个包声明一个 `publint` job**:暴露最大包级并行度,但会创建手工维护的包清单,包发生变化时就会漂移。 - **以无界并发运行 `publint`**:虽能最大限度缩短小型仓库的耗时,却会拿进程数量、内存压力、包 tarball 创建开销和日志可读性冒险。 @@ -36,6 +39,8 @@ Node 24 消费方任务采用单个包含七道门禁的模式,而非由 shell 由调度器支持的命令耗时取决于最慢的依赖链,而非各独立门禁耗时之和,并会报告决定总耗时的门禁。无效图会直接失败,不会先执行其中一部分。代价是维护一个具有显式模式清单的定制调度器。 -这条验证链会让使用已恢复产物的下游消费方和 lint 延后启动,直至共享产物视图经确认有效且临时暂存已清除;这些下游门禁仍可彼此重叠运行。 +这条验证链会让使用已验证产物的下游消费方和 lint 延后启动,直至共享产物视图经确认有效且临时暂存已清除;这些下游门禁仍可彼此重叠运行。`publint` 需要构建,却不依赖暂存的验证视图,因此它会与验证器重叠,而不会延长这条依赖链。 + +大多数门禁仍保留确定性的输出块。少数长时间协调器用跨门禁输出顺序和缓冲日志换取即时诊断,而其最终状态仍可供聚合摘要使用。 `publint-all.ts` 采用异步执行并缓冲命令输出,而不是实时继承 stdio。换来的是具有稳定输出顺序的包级并行,以及用于资源调节的单一环境变量。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 4580a534c5..3bc9415b40 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: e0d919851d99eac6539a25c63c9baeb49f76335f -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 673bd7643506f022b640d14918dd7c883fb60e36 +2026-07-22-evidence-based-larger-hosted-runners.md: b3310988decb2916ac895aaf154dbc106c51ed48 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 2d408173a657c77add750a53eaee4ecb9177919c diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index e0d919851d..b3310988de 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -12,19 +12,19 @@ Larger runners make it possible to pay setup once and parallelize inside the rep ## Decision -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests run the three primary Linux jobs on the 16-core Ubuntu 24.04 pool and the independent native Windows signal on the 16-core Windows 2025 pool. The required Wine signal remains on standard hosted Linux. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. -The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. +The former gate-level and coarse primary shard jobs are absent from the workflow. Their workflow-facing static, lint, coverage, snapshot, and scenario selectors are also absent, so an unused diagnostic path cannot preserve a second CI architecture. Instrumented coverage may use [process-local partitions inside its existing job](2026-08-18-in-job-partitioned-coverage.md); that coordinator neither selects workflow jobs nor transfers reports between runners. -Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler owns source and documentation gates that do not consume emitted output. The third job owns the single Linux build, then starts lint, Node 24 runtime compatibility, build-backed snapshots, documentation typechecking, and all artifact consumers against that tree. This [independent consumer build](2026-07-30-independent-ci-consumer-build.md) lets all three jobs request runners immediately without duplicating compilation or transferring a run-scoped artifact. Generated NodeNext consumer directories are excluded from Oxlint discovery because the artifact check removes them while these processes overlap. The pnpm store is restored without putting cache uploads on the pull-request critical path; Oxlint has no repository-managed result cache. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent 16-core jobs. Coverage runs alone and partitions its instrumented work inside that job with an explicit process bound; the static scheduler owns source and documentation gates that do not consume emitted output. The third job owns the single Linux build, then starts lint, Node 24 runtime compatibility, build-backed snapshots, documentation typechecking, and all artifact consumers against that tree. This [independent consumer build](2026-07-30-independent-ci-consumer-build.md) lets all three jobs request runners immediately without duplicating compilation or transferring a run-scoped artifact. Generated NodeNext consumer directories are excluded from Oxlint discovery because the artifact check removes them while these processes overlap. The pnpm store is restored without putting cache uploads on the pull-request critical path; Oxlint has no repository-managed result cache. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking consumes the consumer lane's complete project-reference output. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count. The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails. -Within this enterprise required topology, Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts, while Linux owns the duplicate lint, coverage, and snapshot inventories. The later [dual Windows pull-request topology](2026-08-08-native-windows-pull-request-ci.md) adds a separate non-blocking standard-hosted native job that independently enforces supported-source coverage without extending this paid required path. +The [dual Windows pull-request topology](2026-08-08-native-windows-pull-request-ci.md) keeps the required build and production-site verdict under Wine on standard hosted Linux. A separate non-blocking 16-core native job shares one Windows setup across workspace build, production-site validation, supported-source coverage, and the complete portability inventory. Linux owns the blocking verdict for duplicate static, documentation, package, built-artifact, lint, and snapshot checks; the native aggregate keeps those checks observational. An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: @@ -40,7 +40,7 @@ The same benchmark measured the required Windows build surfaces across every pro |---|---:|---:|---:|---:|---:|---:| | Active time | 152 s | 104 s | 104 s | 92 s | 103 s | 110 s | -Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A retargeted production validation completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. +Repository work gains little above 16 Windows cores. The native lane keeps blocking build, production-site validation, and coverage together with the observational portability inventory in one 16-core job; a 32-core comparison improved its aggregate gate time by only 1.47 seconds and failed inside Node's CJS lexer. The required Wine job remains separate because it owns critical-path status rather than native-runner scaling. The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In one exact-head candidate run, Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A cacheless all-size trace completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. @@ -72,15 +72,15 @@ An additional serial Linux reference runs on the in-house self-hosted pool (`vm- **Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path. -**Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process. +**Keep blocking and observational native Windows checks in separate jobs.** This would preserve their distinction at the workflow level but pay Windows setup twice. `run-gates` preserves the same blocking versus observational result inside one job. **Install Bubblewrap through the system package manager.** This uses the host's package database and can dominate the job even when the payload is tiny. Pinned extraction plus a confinement probe preserves the runtime contract without mutating the hosted image. ## Consequences -The required topology pays one setup wave per 32-core lane and retains no shard selectors. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful. +The primary topology pays one setup wave per 16-core lane and retains no workflow-level shard jobs or selectors. Process-local coverage partitions share that one setup and workspace. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful. -GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice, but the consumer lane owns the only built tree and coverage, static gates, and post-build consumers enter runner allocation independently; consolidating Windows avoids repeating its slower setup. +GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice, but the consumer lane owns the only built tree and coverage, static gates, and post-build consumers enter runner allocation independently. Native Windows keeps its blocking and observational inventory in one setup, while Wine remains separate to preserve the required critical path. Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 673bd76435..2d408173a6 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -12,19 +12,19 @@ Status: implemented ## 决策 -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 约定。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求在 16 核 Ubuntu 24.04 池上运行 3 个 Linux 主作业,并在 16 核 Windows 2025 池上运行独立的原生 Windows 信号。必需的 Wine 信号仍位于标准托管 Linux。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性约定,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 -原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 +原有的门禁级和粗粒度主流程分片 job 已从工作流中移除。面向工作流的静态、lint、覆盖率、快照和场景选择器也已移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。插桩覆盖率可以在[既有 job 内使用进程本地分区](2026-08-18-in-job-partitioned-coverage.md);该协调器既不选择工作流 job,也不在 runner 之间传输报告。 -Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器负责不消费生成输出的源码和文档门禁。第三个作业负责唯一一次 Linux 构建,随后让 lint、Node 24 运行时兼容性、依赖构建产物的快照、文档类型检查和所有产物消费方基于该目录树启动。这种[消费方独立构建](2026-07-30-independent-ci-consumer-build.md)使 3 个作业都能立即请求运行器,而无需重复编译或传输仅供本次运行使用的产物。生成的 NodeNext 消费方目录不会纳入 Oxlint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 会得到恢复,但缓存上传不会进入拉取请求关键路径;Oxlint 没有由仓库管理的结果缓存。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的 16 核 job。覆盖率单独运行,并按显式进程上限在该 job 内划分插桩工作;静态调度器负责不消费生成输出的源码和文档门禁。第 3 个 job 负责唯一一次 Linux 构建,随后让 lint、Node 24 运行时兼容性、依赖构建产物的快照、文档类型检查和所有产物消费方基于该目录树启动。这种[消费方独立构建](2026-07-30-independent-ci-consumer-build.md)使 3 个 job 都能立即请求 runner,而无需重复编译或传输仅供本次运行使用的产物。生成的 NodeNext 消费方目录不会纳入 Oxlint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 会得到恢复,但缓存上传不会进入拉取请求关键路径;Oxlint 没有由仓库管理的结果缓存。性能报告采用每个 job 从 `startedAt` 到 `completedAt` 的区间;runner 排队延迟是容量证据,而非仓库执行时间。 门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查以消费方通道的完整 project-reference 输出为输入。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。 产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布约定只要遗漏一个运行时分片,检查仍会失败。 -在这项企业级必需拓扑中,Windows 通过一次 32 核环境设置同时承载阻塞性构建、生产网站与观测性构建产物约定,重复的 lint、覆盖率和快照清单则由 Linux 负责。后续的[拉取请求双 Windows 拓扑](2026-08-08-native-windows-pull-request-ci.md)新增一个独立且不阻断的标准托管原生作业;该作业会独立强制执行受支持源码覆盖率,同时不延长这条付费必需路径。 +[拉取请求双 Windows 拓扑](2026-08-08-native-windows-pull-request-ci.md)把必需的构建与生产网站判定保留在标准托管 Linux 上的 Wine 中。独立且不阻断的 16 核原生作业通过一次 Windows 设置共同执行工作区构建、生产网站验证、受支持源码覆盖率与完整的可移植性清单。重复的静态检查、文档、包、构建产物、lint 与快照检查由 Linux 提供阻断性判定,原生聚合流程则保留这些观测性检查。 一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: @@ -40,7 +40,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 |---|---:|---:|---:|---:|---:|---:| | 活动耗时 | 152 秒 | 104 秒 | 104 秒 | 92 秒 | 103 秒 | 110 秒 | -Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次重新定向的生产验证在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 +Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性的构建、生产网站验证与覆盖率和观测性可移植清单保留在同一个 16 核 job 内;32 核对比仅将其聚合门禁耗时缩短 1.47 秒,且在 Node CJS lexer 内失败。必需的 Wine job 保持独立,因为它负责关键路径状态,而非原生运行器扩缩。 客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在一次分支头精确的候选运行中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次无缓存的全规格运行轨迹在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 @@ -72,15 +72,15 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 **将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。 -**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。 +**把阻断性与观测性原生 Windows 检查放在不同 job。** 此方案会在工作流层面保留二者的区别,却要承担两次 Windows 设置开销。`run-gates` 在一个 job 内保留了相同的阻断与观测结果。 **通过系统包管理器安装 Bubblewrap。** 此方案会使用主机的包数据库,即使包内容很小,也可能主导整个作业耗时。固定版本的解压方式配合隔离探针,无需修改托管映像即可保留运行时约定。 ## 后果 -必需拓扑中的每个 32 核通道只承担 1 轮设置开销,且不保留分片选择器。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows 运行器分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。 +主拓扑中的每个 16 核通道只承担 1 轮设置开销,且不保留工作流级分片 job 或选择器。进程本地 coverage 分区共享这 1 轮设置与同一个工作区。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows runner 分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。 -GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置,但消费方通道拥有唯一一份已构建目录树,且覆盖率、静态门禁与构建后消费方分别进入运行器分配;合并 Windows 则避免重复其耗时更长的设置。 +GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置,但消费方通道拥有唯一一份已构建目录树,且覆盖率、静态门禁与构建后消费方分别进入运行器分配。原生 Windows 让阻断性与观测性清单共享一次设置,Wine 则保持独立以保留必需关键路径。 性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 2dd42d87db..f8cdf8e924 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: b4522e623ffb76f3fd33d242b21c2d1d9ff2eadf -2026-07-26-ci-failover-runbook.zh.md: 58ba7ffee013d38f06afe097362f5c23f04b8121 +2026-07-26-ci-failover-runbook.md: e8a1d1dc339cc5d9be3db3be395e2cddad93b6fc +2026-07-26-ci-failover-runbook.zh.md: 8f92b7b60c075f21b6f2c83dc46a6e0e5d8acce2 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index b4522e623f..e8a1d1dc33 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym ## Decision -Each of the three required Linux worker jobs, the independent native Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through a repository variable, and the switch is split by platform so an outage on one platform does not retarget the other. The three Linux workers and the `all checks passed` verdict (whose `needs` are the required Linux workers and which runs on the `vm-backup` pool) resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows job resolves through `DSH_CI_FAILOVER_WINDOWS`. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, the corresponding jobs retarget onto the in-house self-hosted pool: under `DSH_CI_FAILOVER_LINUX`, the Linux jobs and verdict move onto the `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped; under `DSH_CI_FAILOVER_WINDOWS`, the native Windows job moves onto the `dsh-win-ci` pool. Each switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push. +Each of the three required Linux worker jobs, the independent native Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through a repository variable, and the switch is split by platform so an outage on one platform does not retarget the other. The three Linux workers and the `all checks passed` verdict (whose `needs` are the required Linux workers and which runs on the `vm-backup` pool) resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows job resolves through `DSH_CI_FAILOVER_WINDOWS`. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, the corresponding jobs retarget onto the in-house self-hosted pool: under `DSH_CI_FAILOVER_LINUX`, the Linux jobs and verdict move onto the `vm-backup` pool, snapshot concurrency drops to the shared-VM bound, and the hosted-path pnpm cache restores are skipped; under `DSH_CI_FAILOVER_WINDOWS`, the native Windows job moves onto the `dsh-win-ci` pool. Each switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push. `ci.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check. @@ -32,7 +32,7 @@ The two switches are independent: flip only the one whose platform is degraded. 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`. 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. -3. That is the entire switch. Under Linux failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). The Windows switch has no such concurrency or cache branches; it only retargets the native Windows job's pool. +3. That is the entire switch. Under Linux failover the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows job's pool. #**Dependabot exception.** Both switches' selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. @@ -59,4 +59,4 @@ The variables are writer-manageable repository state; a pull request event itsel ## Consequences -Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: the standby lanes exercise them on every master push so the failover targets never go stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform. +Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: the standby lanes exercise them on every master push so the failover targets never go stale, and the snapshot-concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 58ba7ffee0..8f92b7b60c 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -三个必需的 Linux 工作作业、独立的原生 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量解析运行器池,且开关按平台拆分,使一个平台的故障不会重定向另一个平台。三个 Linux 工作作业与 `all checks passed` 判定作业(其 `needs` 是必需的 Linux 工作作业,且运行在 `vm-backup` 池上)通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,对应作业切换到公司自有的自托管池:`DSH_CI_FAILOVER_LINUX` 下,Linux 作业与判定作业切到 `vm-backup` 池,覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;`DSH_CI_FAILOVER_WINDOWS` 下,原生 Windows 作业切到 `dsh-win-ci` 池。每个开关都是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 +三个必需的 Linux 工作作业、独立的原生 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量解析运行器池,且开关按平台拆分,使一个平台的故障不会重定向另一个平台。三个 Linux 工作作业与 `all checks passed` 判定作业(其 `needs` 是必需的 Linux 工作作业,且运行在 `vm-backup` 池上)通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,对应作业切换到公司自有的自托管池:`DSH_CI_FAILOVER_LINUX` 下,Linux 作业与判定作业切到 `vm-backup` 池,快照并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;`DSH_CI_FAILOVER_WINDOWS` 下,原生 Windows 作业切到 `dsh-win-ci` 池。每个开关都是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 `ci.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。 @@ -32,7 +32,7 @@ Status: implemented 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 -3. 切换到此完成。Linux 故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏情况下,6 × 8 = 48 个覆盖率工作进程运行在 64 核虚拟机上)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。Windows 开关没有这类并发或缓存分支;它只重定向原生 Windows 作业的运行器池。 +3. 切换到此完成。Linux 故障切换状态下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。 #**Dependabot 例外。**两个开关的选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 @@ -59,4 +59,4 @@ Status: implemented ## 后果 -从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它们,避免故障切换目标变得陈旧;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。 +从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它们,避免故障切换目标变得陈旧;而 `ci.yml` 中的快照并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。 diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml index 6c4645c72f..da50cfa848 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.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-07-31-coverage-exempt-heavy-suites.md -2026-07-31-coverage-exempt-heavy-suites.md: 7235a5193554947ecf71f62d522d09f4e21cb1da -2026-07-31-coverage-exempt-heavy-suites.zh.md: e3d6e335ecb069dadeebb06d760cf70c9b0c1fd4 +2026-07-31-coverage-exempt-heavy-suites.md: 1f468a69321b451593a9279cfebc1b457fb08a47 +2026-07-31-coverage-exempt-heavy-suites.zh.md: dafd4bda49fd0c04fc0bcb42dc3948b779b57c9a diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md index 7235a51935..1f468a6932 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md @@ -17,6 +17,8 @@ The `ci-coverage` aggregate splits into two parallel gates; every test still run - **Instrumented gate** (`test:coverage`): sets `DSH_COVERAGE_EXEMPT_HEAVY=1`, which makes `vitest.config.ts` drop the exempt suites from both projects' excludes; every remaining file runs instrumented and carries the entire threshold proof. The variable is injected through the gate's own env (the existing `Gate.env` mechanism), not the workflow-global environment, so the uninstrumented gate beside it and any local `vitest run` never see it and behave unchanged. - **Uninstrumented gate** (`test:coverage-exempt-heavy`): runs exactly the exempt suites through paired positional filters, keeping the correctness signal whole. +Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged. + `scripts/coverage-exempt.ts` is the single roster point, holding the membership contract and the filter/exclude pairs so the two sides cannot drift. ### The roster, reconciled entry by entry @@ -27,7 +29,7 @@ A suite contributes to coverage exactly when it executes measured files in-proce | --- | --- | --- | | All 6 typert generator specs | The generator's own src | Generator src is threshold-excluded as a package (`vitest.config.ts`) — outside the threshold scope to begin with | | tools-catalog.spec additionally imports | `typert-registry` and `tool-cordis` src | Each package's own tests cover them fully (verified with focused coverage runs, zero threshold errors) | -| `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry | +| `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts`, `scripts/translation-pairing-merge.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry | ### Membership contract @@ -46,7 +48,7 @@ Coverage-result invariance therefore does not rest on humans maintaining the ros - **CLI `--exclude` to drop the exempt suites from the instrumented gate.** Proven ineffective: vitest 4's `cliExclude` does not participate in per-project include resolution, so under a multi-project config the exempt suites stayed selected; the env + config route replaced it. - **Lowering worker counts or raising gate concurrency.** Measured ineffective during the incident: the lane's wall clock was pinned by the longest tail files (aggregate/wall ≈ 4× effective parallelism), and the concurrency knobs moved nothing in either direction. -- **Cross-runner sharding (`--shard` + blob merge).** Would compress the wall clock further but adds matrix, artifact-pipeline, and merge-job complexity; with the split landed the lane sits near 2 minutes, which does not justify the cost. Revisit if the suite grows substantially. +- **Cross-runner sharding (`--shard` + blob merge).** Rejected because a matrix, artifact pipeline, and merge job would add a second workflow topology. The selected [in-job partitioning](2026-08-18-in-job-partitioned-coverage.md) uses Vitest shards only as local single-worker processes inside the existing job. - **Deleting or skipping the heavy suites.** Rejected: they are the sole correctness evidence for the typert generator and the scripts tooling; running them uninstrumented in parallel preserves the full signal. ## Verification @@ -55,7 +57,7 @@ Measured on CI (16-core runner): the gate segment went from 424 seconds to the t ## Consequences -- The coverage lane's gate segment drops from about 7 minutes to about 96 seconds with no change in threshold outcome or executed test set. +- The exempt suites execute without adding instrumentation cost to the thresholded gate; partitioned wall-clock measurements belong to the [in-job partitioning decision](2026-08-18-in-job-partitioned-coverage.md). - `DSH_GATE_CONCURRENCY` has two schedulable gates in this lane again, so the aggregate scheduler is no longer a pass-through. - Adding a heavy suite to the roster requires the membership audit above; a wrong entry fails the instrumented gate loudly rather than eroding coverage silently. - The exempt suites no longer appear in the coverage report's file list of contributors; their correctness signal lives solely in the uninstrumented gate's pass/fail. diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md index e3d6e335ec..dafd4bda49 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md @@ -17,6 +17,8 @@ CI 覆盖率 lane(`check:ci:coverage`)的墙钟被少数几个重型测试 - **插桩 gate**(`test:coverage`):设 `DSH_COVERAGE_EXEMPT_HEAVY=1`,`vitest.config.ts` 据此从两个 project 的 exclude 中剔除豁免套件,其余全部文件照旧插桩并承担全部阈值证明。经 gate 自带 env 注入(既有 `Gate.env` 机制),不进 workflow 全局环境,因此并排的无插桩 gate 和本地直跑 `vitest run` 都看不到该变量、行为不变。 - **无插桩 gate**(`test:coverage-exempt-heavy`):用配对的 positional filter 恰好运行豁免套件,保证正确性信号不缩水。 +Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。 + `scripts/coverage-exempt.ts` 是唯一名单点,集中持有成员资格约定与 filter/exclude 配对,防止两侧漂移。 ### 豁免名单与逐项对账 @@ -27,7 +29,7 @@ CI 覆盖率 lane(`check:ci:coverage`)的墙钟被少数几个重型测试 | --- | --- | --- | | typert generator 全部 6 个 spec | generator 自身 src | generator src 已整包 threshold-excluded(`vitest.config.ts`),本不在阈值口径内 | | 其中 tools-catalog.spec 额外 import | `typert-registry`、`tool-cordis` 的 src | 两包各自的测试独立满覆盖(focused coverage 实测无阈值错误) | -| `scripts/install-lefthook.spec.ts`、`scripts/oxlint-contract.spec.ts`、`scripts/change-scope.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 | +| `scripts/install-lefthook.spec.ts`、`scripts/oxlint-contract.spec.ts`、`scripts/change-scope.spec.ts`、`scripts/translation-pairing-merge.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 | ### 成员资格约定 @@ -46,7 +48,7 @@ per-file 100% 阈值本身就是豁免名单的守卫,名单错误无法静默 - **CLI `--exclude` 从插桩 gate 剔除豁免套件。** 实证无效:vitest 4 的 `cliExclude` 不参与 per-project include 解析,多 project 配置下豁免套件仍被选中,故改走 env + config。 - **降低 worker 数或提高 gate 并发。** 事故期间实测无效:lane 墙钟被尾部最长文件钉死(聚合/墙钟 ≈ 4× 有效并行),并发旋钮两个方向都动不了尾巴。 -- **跨 runner 分片(`--shard` + blob 合并)。** 能进一步压墙钟但引入 matrix、artifact 管道与合并 job 的复杂度;拆分落地后 lane 已到约 2 分钟,不值得付。若未来套件规模再涨可重新评估。 +- **跨 runner 分片(`--shard` + blob 合并)。** 不予采用,因为 matrix、产物流水线和合并 job 会引入第二套工作流拓扑。所选的 [job 内分区](2026-08-18-in-job-partitioned-coverage.md)只把 Vitest shard 用作既有 job 内的本地单 worker 进程。 - **直接删除或跳过重型套件。** 拒绝:它们是 typert generator 与 scripts 工具的唯一正确性证据,无插桩并排执行保住全部信号。 ## Verification @@ -55,7 +57,7 @@ CI 实测(16 核 runner):拆分前 gate 段 424 秒,拆分后两 gate ## Consequences -- 覆盖率 lane 的 gate 段从约 7 分钟降到约 96 秒,阈值结果与执行测试集均无变化。 +- 豁免套件在执行时不会向阈值门禁叠加插桩开销;分区墙钟数据由 [job 内分区决策](2026-08-18-in-job-partitioned-coverage.md)负责记录。 - `DSH_GATE_CONCURRENCY` 在本 lane 重新拥有两个可调度对象,聚合调度器不再是直通。 - 向名单新增重型套件必须完成上述成员资格对账;错误条目会让插桩 gate 大声失败,而不是静默侵蚀覆盖率。 - 豁免套件不再出现在覆盖率报告的贡献文件列表中;其正确性信号完全由无插桩 gate 的红绿承载。 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 85fa4810cd..8d3ba3e8ff 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: 31a1a1893b0c6248a30ac6e12b409282f608a689 -2026-08-08-native-windows-pull-request-ci.zh.md: ba2520c2514580e5af367df86dd3195485a0a34c +2026-08-08-native-windows-pull-request-ci.md: 113193bcc05dae132b045382bea822b4296b9ff0 +2026-08-08-native-windows-pull-request-ci.zh.md: b038f11da5cbf7d5278b8600c9691879c93a5231 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 31a1a1893b..113193bcc0 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 @@ -16,11 +16,11 @@ The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) rem Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. -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 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. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. -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. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 60-minute job deadline still bounds a stuck run. 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 lane admits four concurrent outer gates. Workspace build, production-site validation, instrumented coverage, and exempt-heavy coverage appear first and start together; observational gates enter as those slots become available. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`; together with build and site, the initial outer schedule has about twelve active execution units instead of exceeding twenty. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. 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. +The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds, but used the whole host before the exempt, build, and site work was counted; eight shards deliberately trade some latency for headroom. 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. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. 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 ba2520c251..b038f11da5 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 @@ -16,11 +16,11 @@ Status: implemented 每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 -原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 +原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。 -16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture 可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入免覆盖率项较多的门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,60 分钟的作业截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证、插桩覆盖率与豁免重型覆盖率排在最前并同时启动,观测性门禁在这些槽位释放后进入调度。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker;再加上构建与网站,初始外层调度约有 12 个活动执行单元,而不是超过 20 个。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。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 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 +16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒,但还未计入豁免、构建与网站工作就已经占满整台宿主;8 个分片刻意用部分延迟换取余量。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml new file mode 100644 index 0000000000..417f35afa8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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/process/2026-08-18-in-job-partitioned-coverage.md +2026-08-18-in-job-partitioned-coverage.md: 5cbec688a9967bcb23a2277e11a119c7d278d7ee +2026-08-18-in-job-partitioned-coverage.zh.md: b5d7db566b3883f26ec5528084a05a97b6e97b6a diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md new file mode 100644 index 0000000000..5cbec688a9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -0,0 +1,51 @@ +# Agent Note: In-job partitioned coverage + +Status: implemented + +English | [中文](2026-08-18-in-job-partitioned-coverage.zh.md) + +## Problem + +Native Windows coverage was the longest feedback path in the complete pull-request inventory. Keeping the instrumented suite in one single-worker Vitest process avoided the worker loss and Node 24 CJS lexer failures seen with larger in-process pools, but a failure could take more than fourteen minutes to appear and the gate runner withheld the child output until completion. + +The optimization must retain every test and the merged per-file 100% thresholds. It must also stay inside the existing coverage job: splitting one suite across multiple workflow jobs would add checkout, installation, artifact transfer, and a merge job to the required topology. + +## Decision + +The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 8; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate beside the instrumented work. + +When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker and one `--shard=/` option. Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, and writes one blob report per process. + +The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. + +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates: build, production-site validation, instrumented coverage, and exempt-heavy coverage start first, then the observational inventory enters as slots become available. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. + +## Failure and output semantics + +Partition children inherit the coordinator's stdout and stderr. The coverage gate opts into `run-gates` streaming, so test progress and failures reach CI logs as they occur without buffering the complete log in the scheduler or printing it a second time at completion. When a child settles unsuccessfully, the coordinator immediately prints its spawn error, exit code, or signal before validating the complete blob set. + +A normal failed test still emits a blob through `--coverage.reportOnFailure`, allowing the merge to report the complete coverage state before the coordinator returns failure. Spawn failure, signal termination, non-zero exit, a missing or extra blob, or a failed merge all make the gate fail. The coordinator removes only its owned coverage tree and unlinks a link-shaped path instead of recursively following it. + +## Verification + +`scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, the complete Windows inventory with its blocking split, and unbuffered streamed output. + +Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds, but the sixteen-way schedule could put more than twenty active execution units beside build and exempt coverage on a 16-core runner. Eight partitions keep separate-process isolation while accepting a longer feedback path for a materially lower peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. + +## Alternatives considered + +**Use workflow-level sharding.** Rejected because multiple jobs repeat setup and need artifact upload, download, and a merge dependency. The selected partitioning uses multiple processes inside one job and one workspace. + +**Raise the Vitest worker count inside one instrumented process.** Rejected because completed Windows trials at higher fan-out exposed worker exits, fixture instability, and Node 24 CJS lexer failures. Separate single-worker processes preserve isolation while still executing the selected partitions concurrently. + +**Use one partition count on every host.** Rejected because Linux's two-process run and Windows's eight-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. + +**Apply thresholds independently in each partition.** Rejected because every partition intentionally sees only part of the suite and would report false uncovered files. Threshold ownership belongs to the merged report. + +## Consequences + +Coverage pays one Vitest startup/configuration cost per partition and one report-merge cost, but it avoids another workflow topology and keeps one final threshold verdict. Partition output may interleave, while the partition start labels and Vitest file identities retain attribution. + +Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one. + +Future tuning starts from completed runs at one fixed configuration. Slow progress alone never raises partition count or outer concurrency, because repeated restarts would erase the only evidence needed to choose a stable setting. diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md new file mode 100644 index 0000000000..b5d7db566b --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 单 job 分区覆盖率 + +Status: implemented + +[English](2026-08-18-in-job-partitioned-coverage.md) | 中文 + +## 问题 + +原生 Windows 覆盖率是拉取请求完整清单中反馈最慢的路径。把插桩套件保留在单个 Vitest 进程内并只使用 1 个 worker,可以避开较大进程内 worker 池曾出现的 worker 丢失和 Node 24 CJS lexer 故障,但一次失败可能超过 14 分钟才会显现,而且门禁调度器会在子进程结束前扣住输出。 + +这项优化必须保留全部测试以及合并后的逐文件 100% 阈值,也必须留在既有覆盖率 job 内:若把同一套件拆到多个工作流 job,就会向必需拓扑增加 checkout、安装、产物传输和合并 job。 + +## 决策 + +普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 8;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.md)仍作为独立的无插桩门禁与插桩工作并排运行。 + +启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned`。`scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker,并各自接收一个 `--shard=/` 选项。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,并让每个进程写出 1 份 blob 报告。 + +协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 + +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发:构建、生产网站验证、插桩覆盖率与豁免重型覆盖率先启动,观测性清单随后在槽位释放时进入调度。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 + +## 失败与输出语义 + +分区子进程继承协调器的 stdout 与 stderr。覆盖率门禁选择 `run-gates` 流式输出,因此测试进度与失败会在发生时进入 CI 日志;调度器不会缓冲完整日志,也不会在结束时重复打印。子进程以失败状态结算时,协调器会立即打印其 spawn 错误、退出码或信号,再校验完整的 blob 集合。 + +普通测试失败仍通过 `--coverage.reportOnFailure` 产出 blob,使合并步骤可以先报告完整覆盖率状态,再由协调器返回失败。spawn 失败、信号终止、非零退出、blob 缺失或多余,以及合并失败都会让门禁失败。协调器只删除自己拥有的覆盖率目录树;若该路径是链接,则只 unlink,不递归跟随。 + +## 验证 + +`scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。 + +已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒,但 16 路调度与构建、豁免覆盖率并行时,会在 16 核运行器上形成超过 20 个活动执行单元。8 个分区继续保留独立进程隔离,同时接受更长的反馈路径,以显著降低峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 + +## 曾考虑的替代方案 + +**使用工作流级分片。** 不予采用,因为多个 job 会重复设置工作,并需要上传、下载产物以及合并依赖。所选分区方案只在同一个 job 和工作区内使用多个进程。 + +**提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。 + +**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的双进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 + +**在每个分区内独立应用阈值。** 不予采用,因为每个分区有意只看到套件的一部分,会误报未覆盖文件。阈值归合并报告所有。 + +## 后果 + +每个分区都要支付 1 次 Vitest 启动与配置开销,最后还要执行 1 次报告合并,但它不引入另一套工作流拓扑,并保留唯一的最终阈值判定。分区输出可能交错,但分区启动标签和 Vitest 文件标识仍可用于归因。 + +Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。 + +未来调优从一个固定配置的完整运行开始。进度缓慢本身绝不会提高分区数量或外层并发,因为反复重启会抹掉选择稳定设置所需的唯一证据。 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index 227135714f..8557998d06 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.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/testing/2026-07-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: 14402485034cd85ec5781477ce67481165d47e62 -2026-07-30-web-browser-snapshot-ci-gate.zh.md: 28a7ef9a7046516a853b3e18a44163c01d43a318 +2026-07-30-web-browser-snapshot-ci-gate.md: 72a7e33d0e84105f7680429443df41661ced288a +2026-07-30-web-browser-snapshot-ci-gate.zh.md: 161f99ab98984ca1d938f11c5e3de5176ca4da66 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index 1440248503..72a7e33d0e 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -10,15 +10,17 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs ## Decision -For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. +For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. When `DSH_WEB_SNAPSHOT_WORKERS` is configured, `scripts/run-gates.ts` registers `test:web:ci` as the `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. The consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions. -Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. +Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. + +CI's `scripts/run-web-snapshots.ts` first runs `hmr-live.e2e.ts` and `cordis-tool-round.e2e.ts` as separate serial Vitest invocations. The HMR scenario mutates built workspace state, while the Cordis scenario owns a lifecycle-sensitive approval and steering sequence whose turn grouping is made deterministic by waiting for the initial turn to settle before approval. After both pass, one six-worker Vitest pool runs every remaining file. Every child inherits stdio, and the enclosing gate streams that output through `run-gates`. For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. -An observed self-hosted consumer run measured `web-snapshot` at 112.15 seconds and the full consumer aggregate at 114.97 seconds. The gate scheduler starts it as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule. +Completed local replays measured the six-worker browser command at about 65–71 seconds. A twelve-worker comparison completed in about 50 seconds, so halving the browser worker budget adds about 15–20 seconds rather than doubling wall time. The gate scheduler starts browser snapshots as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule. ## Alternatives considered @@ -28,8 +30,10 @@ An observed self-hosted consumer run measured `web-snapshot` at 112.15 seconds a **Create a standalone browser job and rebuild the entire repository.** Rejected: it would duplicate dependency installation and the publishable build. The existing Linux consumer job already owns that build and is part of the unified required verdict. +**Run HMR and Cordis inside the parallel pool.** Rejected because HMR mutates shared built state and the Cordis approval continuation requires a serial preflight. Every other file shares one bounded pool; dedicated long-file processes add scheduling code and leave part of a reduced worker budget idle after those files complete. + **Replace real Chromium with jsdom snapshots.** Rejected: jsdom does not cover the browser, HTTP/SSE carriage, or the composition of real client plugin bundles. It remains useful for fast lower-layer feedback, but cannot replace the assembled browser chain. ## Consequences -Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. +Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium provisioning, two serial scenarios, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index 28a7ef9a70..161f99ab98 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -10,15 +10,17 @@ Status: implemented ## 决策 -Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts` 把 `test:web:built` 作为 `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 +Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。配置 `DSH_WEB_SNAPSHOT_WORKERS` 后,`scripts/run-gates.ts` 把 `test:web:ci` 登记为 `ci-consumers` 门禁,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的预期输出与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。 -本地 `pnpm run test:web` 仍先构建再运行完整的浏览器套件;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。 +本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。 + +CI 的 `scripts/run-web-snapshots.ts` 先用相互独立的 Vitest 调用串行运行 `hmr-live.e2e.ts` 与 `cordis-tool-round.e2e.ts`。HMR 场景会修改已构建工作区状态;Cordis 场景则拥有一条对生命周期时序敏感的批准与 steering(中途引导)序列,它通过在批准前等待初始轮次结束来确定轮次分组。两者通过后,其余全部文件进入同一个 6-worker Vitest 池。所有子进程都继承 stdio,外围门禁再通过 `run-gates` 流式传递输出。 对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 -一次自托管消费方运行中,`web-snapshot` 实测耗时 112.15 秒,完整消费方聚合实测耗时 114.97 秒。gate 调度器会在 `built-package-invariants` 成功后立即启动它,并发运行彼此独立的 gate,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。 +完整本地 replay 中,6-worker 浏览器命令耗时约 65–71 秒。12-worker 对比约为 50 秒,因此把浏览器 worker 预算减半只增加约 15–20 秒,而不是让墙钟时间翻倍。门禁调度器会在 `built-package-invariants` 成功后立即启动浏览器快照,并发运行彼此独立的门禁,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。 ## 曾考虑的替代方案 @@ -28,8 +30,10 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览 **新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux 消费方 job 已负责该构建,并已被统一的 required verdict 聚合。 +**把 HMR 与 Cordis 也放进并行池。** 不予采用,因为 HMR 会修改共享的已构建状态,Cordis 批准 continuation 则需要串行预检。其余全部文件共用一个有界池;专用长文件进程会增加调度代码,并在这些文件结束后让缩减后的部分 worker 预算闲置。 + **用 jsdom 快照代替真实 Chromium。** 已否决:jsdom 不覆盖浏览器、HTTP/SSE 承载及真实客户端插件包的组合;它仍可用于快速的下层反馈,但不能替代组装后的浏览器链路。 ## 后果 -每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是消费方 job 需要安装 Chromium,并串行运行一轮浏览器场景;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。 +每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致;漏刷会在改变该组装的同一个 PR 中失败。成本是消费方 job 需要安装 Chromium、串行运行 2 个场景并执行 1 个有界 6-worker 池;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。并行文件的失败会立即流式显示,但 worker 预算的任何变化仍需要完整端到端测量,而不能依据运行中耗时猜测。门禁不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b3253389a..38c049458d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,11 +121,10 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / coverage env: - # The hosted 16-core runner uses six coverage workers. The failover pool - # shares one 64-core VM across six always-on runner instances, so each - # instance may use eight while keeping the worst case at 8 × 6 = 48 - # workers; process-bound suites remain isolated in forks. - DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '6' }} + # Partitioning replaces the instrumented share; this budget gives the + # exempt-heavy gate two workers on both hosted and failover runners. + DSH_COVERAGE_MAX_WORKERS: '6' + DSH_COVERAGE_PARTITIONS: '4' DSH_GATE_CONCURRENCY: '3' steps: - uses: actions/checkout@v6 @@ -188,6 +187,7 @@ jobs: DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_OXLINT_THREADS: '8' DSH_PUBLINT_CONCURRENCY: '8' + DSH_WEB_SNAPSHOT_WORKERS: '6' # Failover halves snapshot concurrency for the shared 64-core VM. DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: @@ -454,12 +454,12 @@ jobs: name: windows node 24 / native complete timeout-minutes: 120 env: - DSH_COVERAGE_MAX_WORKERS: '2' + DSH_COVERAGE_MAX_WORKERS: '6' + DSH_COVERAGE_PARTITIONS: '8' # Instrumented process and polling fixtures can exceed Vitest's defaults # under the complete lane's concurrent gate load. DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' - DSH_GATE_CONCURRENCY: '2' - DSH_PUBLINT_CONCURRENCY: '8' + DSH_GATE_CONCURRENCY: '4' steps: - uses: actions/checkout@v6 with: diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index f01f7d8c06..82baea4aa8 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -27,9 +27,9 @@ const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() // The question composer replaces the textarea, so fill → Queue row → Steer // must finish inside the first replay chunk window. At 15 ms that window is -// shorter than Playwright's round trips; 100 ms supplies test-only headroom, +// shorter than Playwright's round trips; 50 ms supplies test-only headroom, // while larger values lengthen all three replay scenarios linearly. -const REPLAY_PACE_MS = 100 +const REPLAY_PACE_MS = 50 const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.' const STEER = 'Interjection: include the word BANANA in your final reply.' diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 4ab839c87e..21913181bf 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -52,8 +52,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() - await dialog.getByLabel('Edit path').fill(path) - await dialog.getByLabel('Edit path').press('Enter') + const pathInput = dialog.locator('input[aria-label="Edit path"]') + await pathInput.fill(path) + await pathInput.press('Enter') return dialog } diff --git a/package.json b/package.json index 69ad740dbb..3ec9a6c1c5 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "duplication": "jscpd --config .jscpd.json packages scripts", "test": "vitest run", "test:coverage": "vitest run --coverage", + "test:coverage:partitioned": "tsx scripts/run-coverage-partitions.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:issue-management": "node .github/issue-management/policy.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", @@ -42,6 +43,7 @@ "test:web": "npm run build && npm run test:web:built", "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts", "test:web:built": "vitest run --config vitest.web.config.ts", + "test:web:ci": "tsx scripts/run-web-snapshots.ts", "test:web:perf": "npm run build && npm run test:web:perf:built", "test:web:perf:built": "DSH_SNAPSHOT=replay vitest run --config vitest.web.perf.config.ts", "test:web:stress": "npm run build && vitest run --config vitest.web-stress.config.ts", diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 171f7322f0..fe37fef48a 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -3268,7 +3268,7 @@ describe('dynamic nested workspace context injection', () => { try { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'pkg/AGENTS.md'), 'canonical nested rule') - await write(join(root, 'pkg/CLAUDE.md'), 'divergent nested rule') + await write(join(root, 'pkg/CLAUDE.md'), 'initial divergent nested rule') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) @@ -3280,7 +3280,7 @@ describe('dynamic nested workspace context injection', () => { }) const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(firstText).toContain('canonical nested rule') - expect(firstText).toContain('divergent nested rule') + expect(firstText).toContain('initial divergent nested rule') await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule') await ctx.tools.execute({ diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index b24bc8ceb1..783e4e1e6f 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -188,7 +188,10 @@ describe('real Loader composition', () => { // behavior, not the chooser's); await that debounced write so it cannot // race the temp-dir removal, and pin that the persisted row is the // chooser itself — the resolved backend still never reaches the file. - await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') + await expect.poll( + async () => await readFile(configPath, 'utf8'), + { timeout: 15_000 }, + ).toContain('disabled: true') expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE) }) diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index d293beb656..c54e974be8 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/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/util/atomic-write/README.md -README.md: a767f24064c368b60d85fed6fa1d88349cab9587 -README.zh.md: 6388e264898e0025fb6586acecab450be3eb9e55 +README.md: 4d0b55291955c9d37f4788c7d37ad8e6ce728f70 +README.zh.md: c2d7f0b49fa123befbb663ac43862a40b4ef19b4 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index a767f24064..4d0b552919 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -28,7 +28,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { - **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic. - Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content. -`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer. +`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms that the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer. ## Model Experience diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index 6388e26489..c2d7f0b49f 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -28,7 +28,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { - **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 - 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。 -`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。 +`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。 ## 模型体验 diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts index 70af9fa40b..21c9de5f35 100644 --- a/packages/util/atomic-write/src/index.ts +++ b/packages/util/atomic-write/src/index.ts @@ -11,7 +11,7 @@ */ import { randomBytes } from 'node:crypto' -import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' /** @@ -63,9 +63,18 @@ export async function writeFileAtomic(filename: string, content: string, options } } -/** Whether an exclusive create failed because the path already exists. */ -function isEEXIST(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +/** Whether an exclusive create found an existing lock. */ +async function isLockContention(error: unknown, lockPath: string): Promise { + const code = (error as NodeJS.ErrnoException | null)?.code + if (code === 'EEXIST') return true + if (code !== 'EPERM') return false + try { + await lstat(lockPath) + return true + } catch { + // Keep the original EPERM authoritative when lock existence is unproven. + return false + } } /** @@ -82,10 +91,13 @@ const LOCK_TIMEOUT_MS = 2_000 * Hold the cross-process writer lock for `filename` around one operation. The * lock is a `wx`-created sibling (`.lock`); paired with the * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and - * only writers contend. Contention backs off exponentially and fails with a - * timed-out error after the deadline. The contender never removes an existing - * lock because file age cannot prove that its owner stopped; orphan recovery - * is an operator action. The parent directory must exist. + * only writers contend. `EEXIST` is contention directly; an `EPERM` is + * contention only when a fresh `lstat` confirms the lock path exists, covering + * Windows exclusive-create behavior without hiding an unrelated permission + * failure. Contention backs off exponentially and fails with a timed-out error + * after the deadline. The contender never removes an existing lock because + * file age cannot prove that its owner stopped; orphan recovery is an operator + * action. The parent directory must exist. * @param filename - the file whose writers this lock serializes. * @param operation - the read-render-commit cycle to run while holding the lock. * @returns the operation's result; the lock releases on both outcomes. @@ -102,7 +114,7 @@ export async function withFileLock( await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) break } catch (error) { - if (!isEEXIST(error)) throw error + if (!await isLockContention(error, lockPath)) throw error } if (Date.now() >= deadline) { throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`) diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index e71e5b7abd..42cbd287c0 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -1,9 +1,29 @@ -import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { withFileLock, writeFileAtomic } from '../src/index.ts' +const state = vi.hoisted(() => ({ failLockCreateWithEPERM: false })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFile: (async (path: unknown, ...rest: never[]) => { + if (state.failLockCreateWithEPERM && String(path).endsWith('.lock')) { + state.failLockCreateWithEPERM = false + throw Object.assign(new Error('EPERM: injected exclusive-create failure'), { code: 'EPERM' }) + } + return (actual.writeFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.writeFile, + } +}) + +afterEach(() => { + state.failLockCreateWithEPERM = false +}) + async function scratch(): Promise { return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) } @@ -48,6 +68,32 @@ describe('writeFileAtomic', () => { }) describe('withFileLock', () => { + it('retries EPERM only when the lock path currently exists', async () => { + const dir = await scratch() + const target = join(dir, 'document') + const lockPath = `${target}.lock` + await writeFile(lockPath, 'holder\n') + const release = setTimeout(() => { void rm(lockPath, { force: true }) }, 50) + state.failLockCreateWithEPERM = true + let called = false + + try { + await withFileLock(target, async () => { called = true }) + } finally { + clearTimeout(release) + } + expect(called).toBe(true) + }) + + it('preserves EPERM when no lock path exists', async () => { + const dir = await scratch() + const operation = vi.fn(async () => {}) + state.failLockCreateWithEPERM = true + + await expect(withFileLock(join(dir, 'document'), operation)).rejects.toMatchObject({ code: 'EPERM' }) + expect(operation).not.toHaveBeenCalled() + }) + it('rejects an invalid parent hierarchy before running the operation', async () => { const dir = await scratch() const parent = join(dir, 'not-a-directory') diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts new file mode 100644 index 0000000000..81040f650e --- /dev/null +++ b/scripts/coverage-partitions.spec.ts @@ -0,0 +1,229 @@ +import { access, mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + COVERAGE_PARTITION_MODE_ENV, + COVERAGE_PARTITIONS_ENV, + COVERAGE_TEST_TIMEOUT_ENV, + CoveragePartitionCoordinator, + coverageTestTimeoutArgs, + forwardedCoverageArgs, + parseCoveragePartitionCount, + type CoverageCommand, + type CoverageCommandResult, +} from './coverage-partitions.ts' + +const passed: CoverageCommandResult = { exitCode: 0, signalCode: null } + +afterEach(() => vi.restoreAllMocks()) + +async function writeBlob(command: CoverageCommand): Promise { + if (command.blobPath === undefined) return + await mkdir(dirname(command.blobPath), { recursive: true }) + await writeFile(command.blobPath, '{}') +} + +async function temporaryRoot(): Promise { + return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-')) +} + +describe('coverage partition count', () => { + it.each([ + [undefined, undefined], + ['', undefined], + ['2', 2], + ['3', 3], + ])('parses %j as %j', (raw, expected) => { + expect(parseCoveragePartitionCount(raw)).toBe(expected) + }) + + it.each(['0', '1', '2.5', '02', 'many'])('rejects %j', (raw) => { + expect(() => parseCoveragePartitionCount(raw)) + .toThrow(`${COVERAGE_PARTITIONS_ENV} must be an integer greater than 1`) + }) +}) + +describe('coverage partition timeout', () => { + it('applies one configured timeout to tests and polling', () => { + expect(coverageTestTimeoutArgs('30000')).toEqual([ + '--testTimeout=30000', + '--expect.poll.timeout=30000', + ]) + }) + + it('keeps Vitest defaults when the timeout is absent', () => { + expect(coverageTestTimeoutArgs(undefined)).toEqual([]) + }) + + it('rejects invalid timeout input', () => { + expect(() => coverageTestTimeoutArgs('0')) + .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`) + }) +}) + +describe('coverage forwarded arguments', () => { + it('removes one package-script separator', () => { + expect(forwardedCoverageArgs(['--', 'scripts/example.spec.ts'])).toEqual(['scripts/example.spec.ts']) + }) + + it('preserves direct arguments and a subsequent Vitest separator', () => { + expect(forwardedCoverageArgs(['--testNamePattern=example'])).toEqual(['--testNamePattern=example']) + expect(forwardedCoverageArgs(['--', '--', 'example'])).toEqual(['--', 'example']) + }) +}) + +describe('coverage partition coordinator', () => { + it('runs every single-worker partition before one merged threshold check', async () => { + const root = await temporaryRoot() + const commands: CoverageCommand[] = [] + const runCommand = vi.fn(async (command: CoverageCommand) => { + commands.push(command) + await writeBlob(command) + return passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 3, + pnpmEntrypoint: '/pnpm.cjs', + vitestArgs: ['--testTimeout=30000'], + runCommand, + }) + + await expect(coordinator.run()).resolves.toBe(0) + + expect(commands.map(command => command.label)).toEqual([ + 'partition 1/3', + 'partition 2/3', + 'partition 3/3', + 'merged coverage report', + ]) + for (const [index, command] of commands.slice(0, 3).entries()) { + expect(command.args).toEqual(expect.arrayContaining([ + '--coverage', + '--coverage.reportOnFailure', + '--maxWorkers=1', + `--shard=${index + 1}/3`, + '--reporter=default', + '--reporter=blob', + '--testTimeout=30000', + ])) + expect(command.env).toEqual({ + [COVERAGE_PARTITIONS_ENV]: undefined, + [COVERAGE_PARTITION_MODE_ENV]: '1', + }) + } + const mergeCommand = commands[3] + if (mergeCommand === undefined) throw new Error('coverage merge command was not observed') + expect(mergeCommand.args).toContain('--coverage') + expect(mergeCommand.args.some(argument => argument.startsWith('--merge-reports='))).toBe(true) + expect(mergeCommand.env).toEqual({ + [COVERAGE_PARTITIONS_ENV]: undefined, + [COVERAGE_PARTITION_MODE_ENV]: undefined, + }) + }) + + it('merges normal test failures and returns their failed status', async () => { + const root = await temporaryRoot() + const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const runCommand = vi.fn(async (command: CoverageCommand) => { + await writeBlob(command) + return command.label === 'partition 2/2' + ? { exitCode: 1, signalCode: null } + : passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).resolves.toBe(1) + expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (exit 1)') + expect(runCommand).toHaveBeenCalledTimes(3) + }) + + it('rejects a missing partition blob before merge', async () => { + const root = await temporaryRoot() + const runCommand = vi.fn(async (command: CoverageCommand) => { + if (command.label !== 'partition 2/2') await writeBlob(command) + return passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).rejects.toThrow('coverage partitions produced') + expect(runCommand).toHaveBeenCalledTimes(2) + }) + + it('reports signal termination before missing-blob validation', async () => { + const root = await temporaryRoot() + const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const runCommand = vi.fn(async (command: CoverageCommand) => { + if (command.label === 'partition 1/2') await writeBlob(command) + return command.label === 'partition 2/2' + ? { exitCode: null, signalCode: 'SIGTERM' as const } + : passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).rejects.toThrow('coverage partitions produced') + expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (signal SIGTERM)') + }) + + it('waits for every partition after one spawn failure', async () => { + const root = await temporaryRoot() + const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined) + let secondFinished = false + const runCommand = vi.fn(async (command: CoverageCommand) => { + await writeBlob(command) + if (command.label === 'partition 1/2') { + return { exitCode: null, signalCode: null, error: 'spawn unavailable' } + } + if (command.label === 'partition 2/2') secondFinished = true + return passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).resolves.toBe(1) + expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 1/2 (spawn unavailable)') + expect(secondFinished).toBe(true) + expect(runCommand).toHaveBeenCalledTimes(3) + }) + + it('unlinks a link-shaped coverage path without touching its target', async () => { + const root = await temporaryRoot() + const target = await temporaryRoot() + const marker = join(target, 'marker.txt') + await writeFile(marker, 'owned elsewhere') + await symlink(target, join(root, 'coverage'), process.platform === 'win32' ? 'junction' : 'dir') + const runCommand = vi.fn(async (command: CoverageCommand) => { + await writeBlob(command) + return passed + }) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + runCommand, + }) + + await expect(coordinator.run()).resolves.toBe(0) + await expect(access(marker)).resolves.toBeUndefined() + }) +}) diff --git a/scripts/coverage-partitions.ts b/scripts/coverage-partitions.ts new file mode 100644 index 0000000000..9302eea612 --- /dev/null +++ b/scripts/coverage-partitions.ts @@ -0,0 +1,248 @@ +/** Coordinate single-worker Vitest coverage partitions and one merged report. */ +import { spawn } from 'node:child_process' +import { lstat, mkdir, readdir, rm, unlink } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' + +/** Environment variable selecting the number of instrumented coverage processes. */ +export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS' + +/** Internal marker that suppresses reports and thresholds inside a partition process. */ +export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE' + +/** Environment variable overriding instrumented test and polling timeouts. */ +export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS' + +/** One child command owned by the coverage coordinator. */ +export interface CoverageCommand { + /** Diagnostic identity. */ + label: string + /** Node arguments; the first argument is pnpm's JavaScript entrypoint. */ + args: string[] + /** Environment additions for the child. */ + env: Record + /** Working directory for the child. */ + cwd: string + /** Blob the partition must produce; absent for the merge command. */ + blobPath?: string +} + +/** Observable child-process completion. */ +export interface CoverageCommandResult { + /** Numeric process status, or `null` when a signal ended the child. */ + exitCode: number | null + /** Terminating signal, or `null` after an ordinary exit. */ + signalCode: NodeJS.Signals | null + /** Spawn failure recorded independently from process completion. */ + error?: string +} + +/** Execute one coordinator command with inherited output. */ +export type CoverageCommandRunner = (command: CoverageCommand) => Promise + +/** Construction inputs for {@link CoveragePartitionCoordinator}. */ +export interface CoveragePartitionCoordinatorOptions { + /** Repository root that owns coverage output. */ + root: string + /** Number of concurrent single-worker Vitest processes. */ + partitions: number + /** pnpm JavaScript entrypoint from `npm_execpath`. */ + pnpmEntrypoint: string + /** Additional arguments shared by every partition. */ + vitestArgs?: string[] + /** Child executor, injectable for scheduler tests. */ + runCommand?: CoverageCommandRunner +} + +/** Parse an optional coverage partition count. */ +export function parseCoveragePartitionCount(raw: string | undefined): number | undefined { + if (raw === undefined || raw === '') return undefined + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 2 || String(parsed) !== raw) { + throw new Error(`${COVERAGE_PARTITIONS_ENV} must be an integer greater than 1, got ${JSON.stringify(raw)}.`) + } + return parsed +} + +/** Resolve the paired Vitest timeout arguments used by coverage partitions. */ +export function coverageTestTimeoutArgs(raw: string | undefined): string[] { + if (raw === undefined || raw === '') return [] + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { + throw new Error(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) + } + return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`] +} + +/** Remove pnpm's package-script separator before forwarding Vitest arguments. */ +export function forwardedCoverageArgs(args: readonly string[]): string[] { + return [...args.slice(args[0] === '--' ? 1 : 0)] +} + +/** Run instrumented partitions, validate their blobs, and merge once. */ +export class CoveragePartitionCoordinator { + private readonly root: string + private readonly partitions: number + private readonly pnpmEntrypoint: string + private readonly vitestArgs: string[] + private readonly runCommand: CoverageCommandRunner + private readonly temporaryRoot: string + private readonly blobsRoot: string + + /** Create a coordinator from validated process-independent inputs. */ + public constructor(options: CoveragePartitionCoordinatorOptions) { + if (!Number.isSafeInteger(options.partitions) || options.partitions < 2) { + throw new Error(`coverage partitions must be an integer greater than 1, got ${String(options.partitions)}.`) + } + this.root = options.root + this.partitions = options.partitions + this.pnpmEntrypoint = options.pnpmEntrypoint + this.vitestArgs = options.vitestArgs ?? [] + this.runCommand = options.runCommand ?? runCoverageCommand + this.temporaryRoot = join(this.root, 'coverage', '.partitioned') + this.blobsRoot = join(this.temporaryRoot, 'blobs') + } + + /** + * Run every partition before one merged threshold check. + * @returns zero only when every partition and the merge command succeed. + */ + public async run(): Promise { + await removeOwnedTree(join(this.root, 'coverage')) + await mkdir(this.blobsRoot, { recursive: true }) + + try { + const commands = Array.from( + { length: this.partitions }, + (_, index) => this.partitionCommand(index + 1), + ) + const results = await Promise.all(commands.map(async (command) => { + console.log(`coverage-partitions: start ${command.label}`) + const result = await this.runCommand(command) + if (commandFailed(result)) { + console.error(`coverage-partitions: FAIL ${command.label} (${commandFailureReason(result)})`) + } + return result + })) + await this.assertCompleteBlobSet(commands) + + const mergeCommand = this.mergeCommand() + console.log(`coverage-partitions: start ${mergeCommand.label}`) + const mergeResult = await this.runCommand(mergeCommand) + return results.some(commandFailed) || commandFailed(mergeResult) ? 1 : 0 + } finally { + await removeOwnedTree(this.temporaryRoot) + } + } + + private partitionCommand(index: number): CoverageCommand { + const blobPath = join(this.blobsRoot, `partition-${index}.json`) + const reportsDirectory = join(this.temporaryRoot, `coverage-${index}`) + return { + label: `partition ${index}/${this.partitions}`, + args: [ + this.pnpmEntrypoint, + 'exec', + 'vitest', + 'run', + '--coverage', + '--coverage.reportOnFailure', + '--maxWorkers=1', + `--shard=${index}/${this.partitions}`, + '--reporter=default', + '--reporter=blob', + `--outputFile.blob=${this.relativePath(blobPath)}`, + `--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`, + ...this.vitestArgs, + ], + env: { + [COVERAGE_PARTITIONS_ENV]: undefined, + [COVERAGE_PARTITION_MODE_ENV]: '1', + }, + cwd: this.root, + blobPath, + } + } + + private mergeCommand(): CoverageCommand { + return { + label: 'merged coverage report', + args: [ + this.pnpmEntrypoint, + 'exec', + 'vitest', + `--merge-reports=${this.relativePath(this.blobsRoot)}`, + '--coverage', + ], + env: { + [COVERAGE_PARTITIONS_ENV]: undefined, + [COVERAGE_PARTITION_MODE_ENV]: undefined, + }, + cwd: this.root, + } + } + + private relativePath(path: string): string { + return relative(this.root, path).split(sep).join('/') + } + + private async assertCompleteBlobSet(commands: CoverageCommand[]): Promise { + const expected = commands.map((command) => { + if (command.blobPath === undefined) throw new Error(`${command.label} has no blob path.`) + return this.relativePath(command.blobPath) + }).sort() + const actual = (await readdir(this.blobsRoot)) + .map(name => this.relativePath(join(this.blobsRoot, name))) + .sort() + if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) { + throw new Error(`coverage partitions produced ${JSON.stringify(actual)}; expected ${JSON.stringify(expected)}.`) + } + } +} + +/** Spawn one pnpm-backed command without a platform shell. */ +function runCoverageCommand(command: CoverageCommand): Promise { + return new Promise((resolveCommand) => { + const env = { ...process.env } + for (const [name, value] of Object.entries(command.env)) { + if (value === undefined) Reflect.deleteProperty(env, name) + else env[name] = value + } + const child = spawn(process.execPath, command.args, { + cwd: command.cwd, + env, + stdio: 'inherit', + }) + child.once('error', (error: Error) => { + resolveCommand({ exitCode: null, signalCode: null, error: error.message }) + }) + child.once('exit', (exitCode, signalCode) => { + resolveCommand({ exitCode, signalCode }) + }) + }) +} + +function commandFailed(result: CoverageCommandResult): boolean { + return result.exitCode !== 0 || result.signalCode !== null || result.error !== undefined +} + +function commandFailureReason(result: CoverageCommandResult): string { + const facts = [ + result.error, + result.exitCode === null ? undefined : `exit ${result.exitCode}`, + result.signalCode === null ? undefined : `signal ${result.signalCode}`, + ].filter((fact): fact is string => fact !== undefined) + return facts.join(', ') || 'no exit code or signal' +} + +async function removeOwnedTree(path: string): Promise { + const metadata = await lstat(path).catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined + throw error + }) + if (metadata === undefined) return + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + await unlink(path) + return + } + await rm(path, { recursive: true, force: true }) +} diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 56ca6315d6..f0c76ead6f 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -529,7 +529,12 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => { const lockPath = installLockPath(fixture) const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running') const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' }) - await waitForPath(runningPath) + try { + await waitForPath(runningPath) + } catch (error) { + await install + throw error + } const replacementRecord = 'replacement owner\n' writeFileSync(lockPath, replacementRecord) diff --git a/scripts/run-coverage-partitions.ts b/scripts/run-coverage-partitions.ts new file mode 100644 index 0000000000..8626665b96 --- /dev/null +++ b/scripts/run-coverage-partitions.ts @@ -0,0 +1,30 @@ +/** CLI entry for partitioned Vitest coverage. */ +import { resolve } from 'node:path' +import { + COVERAGE_PARTITIONS_ENV, + COVERAGE_TEST_TIMEOUT_ENV, + CoveragePartitionCoordinator, + coverageTestTimeoutArgs, + forwardedCoverageArgs, + parseCoveragePartitionCount, +} from './coverage-partitions.ts' + +const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV]) +if (partitions === undefined) { + throw new Error(`${COVERAGE_PARTITIONS_ENV} is required by partitioned coverage.`) +} +const pnpmEntrypoint = process.env.npm_execpath +if (pnpmEntrypoint === undefined || pnpmEntrypoint === '') { + throw new Error('partitioned coverage must be invoked through a pnpm package script.') +} + +const coordinator = new CoveragePartitionCoordinator({ + root: resolve(import.meta.dirname, '..'), + partitions, + pnpmEntrypoint, + vitestArgs: [ + ...coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]), + ...forwardedCoverageArgs(process.argv.slice(2)), + ], +}) +process.exitCode = await coordinator.run() diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index d1de2914e8..7ca0e78084 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -101,13 +101,16 @@ describe('gate graph validation', () => { }, ) - it('keeps native Windows coverage blocking while portability inventory remains observational', () => { - const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')) - const byId = new Map(gates.map(subject => [subject.id, subject])) + it('keeps native Windows coverage blocking while retaining the observational inventory', () => { + const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')) + const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational')) + .filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build') + const byId = new Map(complete.map(subject => [subject.id, subject])) expect(byId.get('coverage')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true) - expect(byId.get('duplication')?.allowFailure).toBe(true) + expect(observational).not.toHaveLength(0) + for (const gate of observational) expect(byId.get(gate.id)?.allowFailure).toBe(true) }) it('applies one configured test and polling timeout to both coverage gates', () => { @@ -139,6 +142,23 @@ describe('gate graph validation', () => { .toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer') }) + it('selects partitioned coverage only when explicitly configured', () => { + const coverage = withEnv('DSH_COVERAGE_PARTITIONS', '3', () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete').find(subject => subject.id === 'coverage'))) + + expect(coverage).toMatchObject({ + displayCommand: 'DSH_COVERAGE_PARTITIONS=3 pnpm run test:coverage:partitioned', + args: ['/private/pnpm.cjs', 'run', 'test:coverage:partitioned'], + streamOutput: true, + }) + }) + + it('rejects an invalid coverage partition count before starting a gate', () => { + expect(() => withEnv('DSH_COVERAGE_PARTITIONS', '1', () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))) + .toThrow('DSH_COVERAGE_PARTITIONS must be an integer greater than 1') + }) + it.each([ ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], @@ -290,7 +310,7 @@ describe('Node 24 lane ownership', () => { 'built-bin-smoke', ]) expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build']) - expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['build']) expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) for (const id of [ 'snapshot', @@ -332,6 +352,22 @@ describe('Linux primary graph', () => { }) describe('gate process outcomes', () => { + it('streams selected gate output without retaining it', async () => { + const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + try { + const result = await runGate(gate('streamed', { + args: ['-e', "process.stdout.write('live output')"], + streamOutput: true, + })) + + expect(result.status).toBe('passed') + expect(result.output).toEqual([]) + expect(write).toHaveBeenCalledWith('live output') + } finally { + write.mockRestore() + } + }) + it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => { const result = await runGate(gate('terminated', { args: ['-e', "process.kill(process.pid, 'SIGTERM')"], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d26fa77320..b7d8963d8e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -10,6 +10,12 @@ import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts' +import { + COVERAGE_PARTITIONS_ENV, + COVERAGE_TEST_TIMEOUT_ENV, + coverageTestTimeoutArgs, + parseCoveragePartitionCount, +} from './coverage-partitions.ts' /** A named aggregate exposed by the gate runner. */ export type Mode = @@ -40,7 +46,10 @@ export interface Gate { args: string[] needs?: string[] env?: Record + /** Keep a failure visible without failing the aggregate. */ allowFailure?: boolean + /** Write child output as it arrives instead of buffering it until completion. */ + streamOutput?: boolean } /** The observed outcome of one gate process. */ @@ -395,7 +404,7 @@ function ciConsumerGates(): Gate[] { pnpmScript('build', 'build'), pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), pnpmScript('publint', 'publint', { needs: builtTree }), - builtPackageInvariantsGate(['publint']), + builtPackageInvariantsGate(builtTree), pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', { label: 'lint and duplication', needs: validatedBuild, @@ -415,6 +424,20 @@ function ciConsumerGates(): Gate[] { } function webSnapshotGate(needs: string[]): Gate { + const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS + if (workerRaw !== undefined && workerRaw !== '') { + const workers = Number.parseInt(workerRaw, 10) + if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) { + throw new Error(`run-gates: DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`) + } + return pnpmScript('web-snapshot', 'test:web:ci', { + label: 'web browser snapshot', + displayCommand: `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=${workers} pnpm run test:web:ci`, + env: { DSH_SNAPSHOT: 'replay' }, + needs, + streamOutput: true, + }) + } return pnpmScript('web-snapshot', 'test:web:built', { label: 'web browser snapshot', displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', @@ -479,13 +502,14 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // under v8 instrumentation while contributing nothing the thresholds need // (membership rules in scripts/coverage-exempt.ts). // -// DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel -// gates split it instead of each claiming it whole (the failover pool's -// 8 x 6-instance bound assumes one lane never exceeds its value). The exempt +// DSH_COVERAGE_MAX_WORKERS is the ordinary lane's worker budget, so the two +// parallel gates split it instead of each claiming it whole. When +// DSH_COVERAGE_PARTITIONS is set, its single-worker processes replace the +// instrumented share while this budget still sizes the exempt gate. The exempt // gate's wall clock is dominated by its longest single file, so it takes the -// small share. A budget of 1 gives each gate 1 worker; lanes that need a -// strict total of one (the serial reference jobs) also set -// DSH_GATE_CONCURRENCY=1, which keeps the gates from overlapping at all. +// small share. A budget of 1 gives each gate 1 worker; lanes that need a strict +// total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1, +// which keeps the gates from overlapping at all. // DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test and expect.poll // defaults together for instrumented lanes whose scheduling overhead exceeds // those defaults. Explicit fixture timeouts remain authoritative. @@ -501,18 +525,12 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { } } -function coverageTimeoutArgs(): string[] { - return [ - ...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--testTimeout'), - ...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--expect.poll.timeout'), - ] -} - function coverageGates(): Gate[] { const workers = coverageWorkerArgs() - const timeouts = coverageTimeoutArgs() - return [ - pnpmExec('coverage', [ + const timeouts = coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]) + const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV]) + const instrumented = partitions === undefined + ? pnpmExec('coverage', [ 'vitest', 'run', '--coverage', @@ -521,7 +539,15 @@ function coverageGates(): Gate[] { ], { label: 'test:coverage', env: { [COVERAGE_EXEMPT_ENV]: '1' }, - }), + }) + : pnpmScript('coverage', 'test:coverage:partitioned', { + label: 'test:coverage', + displayCommand: `${COVERAGE_PARTITIONS_ENV}=${partitions} pnpm run test:coverage:partitioned`, + env: { [COVERAGE_EXEMPT_ENV]: '1' }, + streamOutput: true, + }) + return [ + instrumented, pnpmExec('coverage-exempt-heavy', [ 'vitest', 'run', @@ -823,10 +849,12 @@ export async function runGate(gate: Gate): Promise { child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') child.stdout.on('data', (chunk: string) => { - output.push({ stream: 'stdout', text: chunk }) + if (gate.streamOutput === true) process.stdout.write(chunk) + else output.push({ stream: 'stdout', text: chunk }) }) child.stderr.on('data', (chunk: string) => { - output.push({ stream: 'stderr', text: chunk }) + if (gate.streamOutput === true) process.stderr.write(chunk) + else output.push({ stream: 'stderr', text: chunk }) }) child.on('error', (error) => { spawnError = `failed to start command: ${error.message}` @@ -880,7 +908,7 @@ function printResult(result: GateResult): void { console.error(`command: ${result.gate.displayCommand}`) console.error(`outcome: ${formatGateResultReason(result)}`) } - printOutput(result.output) + if (result.gate.streamOutput !== true) printOutput(result.output) } function printSummary(results: GateResult[], durationMs: number): void { diff --git a/scripts/run-web-snapshots.ts b/scripts/run-web-snapshots.ts new file mode 100644 index 0000000000..c73047085c --- /dev/null +++ b/scripts/run-web-snapshots.ts @@ -0,0 +1,48 @@ +/** Run serial browser owners before one bounded snapshot pool. */ +import { spawn } from 'node:child_process' + +const serialFiles = [ + 'apps/web/tests/hmr-live.e2e.ts', + 'apps/web/tests/cordis-tool-round.e2e.ts', +] +const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS +const workers = Number.parseInt(workerRaw ?? '', 10) +if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) { + throw new Error(`DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`) +} +const pnpmEntrypoint = process.env.npm_execpath +if (pnpmEntrypoint === undefined || pnpmEntrypoint === '') { + throw new Error('parallel web snapshots must be invoked through a pnpm package script.') +} + +const baseArgs = [pnpmEntrypoint, 'exec', 'vitest', 'run', '--config', 'vitest.web.config.ts'] +let serialStatus = 0 +for (const file of serialFiles) { + serialStatus = await run([...baseArgs, file]) + if (serialStatus !== 0) break +} +if (serialStatus === 0) { + process.exitCode = await run([ + ...baseArgs, + ...serialFiles.map(file => `--exclude=${file}`), + '--fileParallelism', + `--maxWorkers=${String(workers)}`, + ]) +} else { + process.exitCode = serialStatus +} + +function run(args: string[]): Promise { + return new Promise((resolveRun, reject) => { + const child = spawn(process.execPath, args, { stdio: 'inherit' }) + child.once('error', reject) + child.once('exit', (exitCode, signalCode) => { + if (signalCode !== null) { + console.error(`web snapshots terminated by ${signalCode}`) + resolveRun(1) + return + } + resolveRun(exitCode ?? 1) + }) + }) +} diff --git a/vitest.config.ts b/vitest.config.ts index 1c351fab6e..b255083cb8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ import { resolvePwshPath } from './packages/shell/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' +import { COVERAGE_PARTITION_MODE_ENV } from './scripts/coverage-partitions.ts' // Prints exact `path:line:col` records for every uncovered statement, branch // path, and function when a file misses the per-file 100% gate — the built-in @@ -100,6 +101,12 @@ const coverageExemptExcludes = coverageExemptRaw === '1' ? coverageExemptHeavySuites.map(suite => suite.exclude) : [] +const coveragePartitionRaw = process.env[COVERAGE_PARTITION_MODE_ENV] +if (coveragePartitionRaw !== undefined && coveragePartitionRaw !== '' && coveragePartitionRaw !== '1') { + throw new Error(`vitest config: ${COVERAGE_PARTITION_MODE_ENV} must be '1' or unset, got ${JSON.stringify(coveragePartitionRaw)}.`) +} +const coveragePartitionMode = coveragePartitionRaw === '1' + // These suites exercise process-global state, process APIs, or timing-sensitive process I/O // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. @@ -270,16 +277,20 @@ export default defineConfig({ // Per-file so a well-covered big file can't subsidize a bare one. // Every v8 ignore comment must carry a reason — see the quality-gates Agent Note // (.agents/notes/implemented/process/2026-06-11-quality-gates.md). - thresholds: { - perFile: true, - statements: 100, - branches: 100, - functions: 100, - lines: 100, - }, - reporter: process.env.CI - ? ['text', uncoveredLocationsReporter] - : ['text', 'html', uncoveredLocationsReporter], + thresholds: coveragePartitionMode + ? undefined + : { + perFile: true, + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + reporter: coveragePartitionMode + ? [] + : process.env.CI + ? ['text', uncoveredLocationsReporter] + : ['text', 'html', uncoveredLocationsReporter], }, }, }) diff --git a/vitest.web.config.ts b/vitest.web.config.ts index 1179144f61..7c20ab6462 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -27,7 +27,8 @@ export default defineConfig({ 'apps/web/tests/**/*.e2e.ts', 'apps/web/tests/**/*.snapshot.ts', ], - // Browser boot + real-model turns are slow; files share one browser, run serial. + // Local and record runs stay serial. CI runs workspace-mutating HMR and + // dynamic Cordis lifecycle coverage before parallelizing the remaining files. testTimeout: 180_000, hookTimeout: 120_000, fileParallelism: false, From 5ba9e50bb0e899d64e2f313c230338c82301e2c8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:16:50 +0800 Subject: [PATCH 91/95] fix(ci): stabilize native Windows coverage --- ...2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../2026-08-08-native-windows-pull-request-ci.md | 2 +- .../2026-08-08-native-windows-pull-request-ci.zh.md | 2 +- .../2026-08-18-in-job-partitioned-coverage.i18n.yaml | 4 ++-- .../process/2026-08-18-in-job-partitioned-coverage.md | 2 +- .../2026-08-18-in-job-partitioned-coverage.zh.md | 2 +- .../tests/fixtures/process-exit-host.ts | 2 -- .../subprocess-local/tests/process-exit.spec.ts | 4 ---- scripts/run-gates.spec.ts | 10 +++++++++- scripts/run-gates.ts | 10 ++++++++-- 10 files changed, 25 insertions(+), 17 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 8d3ba3e8ff..cb84ea9e03 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: 113193bcc05dae132b045382bea822b4296b9ff0 -2026-08-08-native-windows-pull-request-ci.zh.md: b038f11da5cbf7d5278b8600c9691879c93a5231 +2026-08-08-native-windows-pull-request-ci.md: 7bcdfa7a3e560f247b3041ddf6dd214031c540fc +2026-08-08-native-windows-pull-request-ci.zh.md: 12c853364cbfb191e3bf7c06734559dcdd498ab3 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 113193bcc0..7bcdfa7a3e 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. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. -The 16-core lane admits four concurrent outer gates. Workspace build, production-site validation, instrumented coverage, and exempt-heavy coverage appear first and start together; observational gates enter as those slots become available. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`; together with build and site, the initial outer schedule has about twelve active execution units instead of exceeding twenty. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. 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 lane admits four concurrent outer gates. Workspace build, production-site validation, instrumented coverage, and exempt-heavy coverage appear first and start together; every observational gate waits for both coverage gates before entering the available slots, so source-scanning tests cannot race static gates that create temporary contract files. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`; together with build and site, the initial outer schedule has about twelve active execution units instead of exceeding twenty. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. 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. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds, but used the whole host before the exempt, build, and site work was counted; eight shards deliberately trade some latency for headroom. 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 b038f11da5..12c853364c 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 负责。 -16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证、插桩覆盖率与豁免重型覆盖率排在最前并同时启动,观测性门禁在这些槽位释放后进入调度。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker;再加上构建与网站,初始外层调度约有 12 个活动执行单元,而不是超过 20 个。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证、插桩覆盖率与豁免重型覆盖率排在最前并同时启动;每道观测性门禁都要等待两道覆盖率门禁完成后才进入可用槽位,避免扫描源码的测试与创建临时约定文件的静态门禁发生竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker;再加上构建与网站,初始外层调度约有 12 个活动执行单元,而不是超过 20 个。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒,但还未计入豁免、构建与网站工作就已经占满整台宿主;8 个分片刻意用部分延迟换取余量。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index 417f35afa8..62baafd8ed 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: 5cbec688a9967bcb23a2277e11a119c7d278d7ee -2026-08-18-in-job-partitioned-coverage.zh.md: b5d7db566b3883f26ec5528084a05a97b6e97b6a +2026-08-18-in-job-partitioned-coverage.md: d6b8f98095ebb77c6caf88ce67999e683c71f74c +2026-08-18-in-job-partitioned-coverage.zh.md: 36a49b91b2544c611bf77350bda66b06a2708ba6 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index 5cbec688a9..d6b8f98095 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -18,7 +18,7 @@ When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:cove The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. -`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates: build, production-site validation, instrumented coverage, and exempt-heavy coverage start first, then the observational inventory enters as slots become available. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates: build, production-site validation, instrumented coverage, and exempt-heavy coverage start first, and the observational inventory waits for both coverage gates before entering the available slots. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. ## Failure and output semantics diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index b5d7db566b..36a49b91b2 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -18,7 +18,7 @@ Status: implemented 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 -`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发:构建、生产网站验证、插桩覆盖率与豁免重型覆盖率先启动,观测性清单随后在槽位释放时进入调度。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发:构建、生产网站验证、插桩覆盖率与豁免重型覆盖率先启动,观测性清单等待两道覆盖率门禁完成后才进入可用槽位。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 ## 失败与输出语义 diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index e59289be09..721c5134c3 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -13,7 +13,6 @@ if ((kind !== 'ordinary' && kind !== 'terminal') } const treeState = join(root, 'tree.json') -const ready = join(root, 'ready') const proceed = join(root, 'proceed') const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) @@ -58,7 +57,6 @@ const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unkn if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { throw new Error('managed tree published invalid process ids') } -await writeFile(ready, 'ready') await waitForFile(proceed) if (trigger === 'dispose') { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index 217338fa1e..cdfc4f4ae0 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -107,10 +107,6 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { let treeGone = false try { state = await readTree(join(root, 'tree.json')) - await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { - interval: 10, - timeout: scenarioTimeoutMs, - }) if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) await writeFile(join(root, 'proceed'), 'proceed') const outcome = await child diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 7ca0e78084..5e29bad199 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -110,7 +110,15 @@ describe('gate graph validation', () => { expect(byId.get('coverage')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true) expect(observational).not.toHaveLength(0) - for (const gate of observational) expect(byId.get(gate.id)?.allowFailure).toBe(true) + for (const gate of observational) { + const completeGate = byId.get(gate.id) + expect(completeGate?.allowFailure).toBe(true) + expect(completeGate?.needs).toEqual(expect.arrayContaining([ + 'coverage', + 'coverage-exempt-heavy', + ...(gate.needs ?? []), + ])) + } }) it('applies one configured test and polling timeout to both coverage gates', () => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b7d8963d8e..92b30fcdb0 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -454,15 +454,21 @@ function ciWindowsBlockingGates(): Gate[] { } function ciWindowsCompleteGates(): Gate[] { + const coverage = coverageGates() + const coverageNeeds = coverage.map(gate => gate.id) const observational = ciWindowsObservationalGates() // The required production site replaces the observational MPA build; both // VitePress modes write the same output directory and cannot overlap. .filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build') - .map(gate => ({ ...gate, allowFailure: true })) + .map(gate => ({ + ...gate, + allowFailure: true, + needs: [...new Set([...coverageNeeds, ...(gate.needs ?? [])])], + })) return [ pnpmScript('build', 'build'), pnpmScript('windows-site', 'docs:build', { label: 'production site' }), - ...coverageGates(), + ...coverage, ...observational, ] } From 975bf864ef1d57db715142be46c6cce3235f8e18 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:19:06 +0800 Subject: [PATCH 92/95] fix(ci): make Windows fixtures portable --- packages/subagent/subagent-codex/tests/real-product.spec.ts | 4 +--- scripts/verify-cordis-config.ts | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 4e65489d82..1e71d1065a 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -385,9 +385,7 @@ describe('real @openai/codex 0.147.0 product', () => { it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => { const sideEffect = 'bypass-side-effect' - const command = process.platform === 'win32' - ? `cmd /c echo bypass>${sideEffect}` - : `printf bypass > ${sideEffect}` + const command = `echo bypass>${sideEffect}` const commandCalls = [ { name: 'exec_command', diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index ebfbbbc939..281be99f50 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -294,11 +294,12 @@ function validateAppResolution(): string[] { /** * Discover workspace Bundle packages from their manifest declaration. * @param repoRoot Repository root to scan. - * @returns Sorted repository-relative package manifest paths. + * @returns Sorted slash-normalized repository-relative package manifest paths. */ export function bundleManifestPaths(repoRoot: string = root): string[] { return globSync('packages/*/*/package.json', { cwd: repoRoot }) .filter(path => typeof readManifest(path, repoRoot).dsh?.bundle?.patch === 'string') + .map(path => path.replaceAll('\\', '/')) .sort() } From ce128804e317cabe7e80ec177f15abce88a752d3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:34:55 +0800 Subject: [PATCH 93/95] fix(test): restore Codex fixture command path --- packages/subagent/subagent-codex/tests/real-product.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 1e71d1065a..abf3c5147a 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -10,7 +10,7 @@ import { import { rm } from 'node:fs/promises' import { createRequire } from 'node:module' 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 '@deepseek-ai/cordis' @@ -100,7 +100,7 @@ async function realInstanceFixture( CODEX_HOME: codexHome, HOME: root, XDG_CONFIG_HOME: join(root, 'xdg'), - PATH: root, + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', From d54f6382c8ccda21f71fc199c152ea0ce86d0ad2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:45:25 +0800 Subject: [PATCH 94/95] fix(ci): preserve Windows coverage failures --- ...8-18-in-job-partitioned-coverage.i18n.yaml | 4 +- .../2026-08-18-in-job-partitioned-coverage.md | 2 +- ...26-08-18-in-job-partitioned-coverage.zh.md | 2 +- .../subagent-codex/tests/real-product.spec.ts | 50 +++++++++++-------- scripts/coverage-partitions.spec.ts | 5 +- scripts/coverage-partitions.ts | 29 +++++++++-- 6 files changed, 62 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index 62baafd8ed..4b33d023ed 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: d6b8f98095ebb77c6caf88ce67999e683c71f74c -2026-08-18-in-job-partitioned-coverage.zh.md: 36a49b91b2544c611bf77350bda66b06a2708ba6 +2026-08-18-in-job-partitioned-coverage.md: b7335c490c4a4921e5c786809e0db492613ef5c8 +2026-08-18-in-job-partitioned-coverage.zh.md: 5e2a6a60d59751a22c7d664d4bc50d93390c6995 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index d6b8f98095..b7335c490c 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -22,7 +22,7 @@ The coordinator waits for every child, validates that the blob directory contain ## Failure and output semantics -Partition children inherit the coordinator's stdout and stderr. The coverage gate opts into `run-gates` streaming, so test progress and failures reach CI logs as they occur without buffering the complete log in the scheduler or printing it a second time at completion. When a child settles unsuccessfully, the coordinator immediately prints its spawn error, exit code, or signal before validating the complete blob set. +Partition children stream stdout and stderr through the coordinator. The coverage gate opts into `run-gates` streaming, so test progress and failures reach CI logs as they occur without buffering the complete log in the scheduler. The coordinator also retains a bounded 64 KiB combined tail per child; when a child settles unsuccessfully, it prints the spawn error, exit code, or signal and repeats that tail before validating the complete blob set, keeping the specific Vitest failure beside the final partition diagnostic. A normal failed test still emits a blob through `--coverage.reportOnFailure`, allowing the merge to report the complete coverage state before the coordinator returns failure. Spawn failure, signal termination, non-zero exit, a missing or extra blob, or a failed merge all make the gate fail. The coordinator removes only its owned coverage tree and unlinks a link-shaped path instead of recursively following it. diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index 36a49b91b2..5e2a6a60d5 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -22,7 +22,7 @@ Status: implemented ## 失败与输出语义 -分区子进程继承协调器的 stdout 与 stderr。覆盖率门禁选择 `run-gates` 流式输出,因此测试进度与失败会在发生时进入 CI 日志;调度器不会缓冲完整日志,也不会在结束时重复打印。子进程以失败状态结算时,协调器会立即打印其 spawn 错误、退出码或信号,再校验完整的 blob 集合。 +分区子进程通过协调器流式传递 stdout 与 stderr。覆盖率门禁选择 `run-gates` 流式输出,因此测试进度与失败会在发生时进入 CI 日志,调度器不会缓冲完整日志。协调器还会为每个子进程保留一份有界的 64 KiB 混合输出尾部;子进程以失败状态结算时,它会打印 spawn 错误、退出码或信号,并在校验完整 blob 集合前重印这份尾部,使具体 Vitest 失败与最终分区诊断相邻。 普通测试失败仍通过 `--coverage.reportOnFailure` 产出 blob,使合并步骤可以先报告完整覆盖率状态,再由协调器返回失败。spawn 失败、信号终止、非零退出、blob 缺失或多余,以及合并失败都会让门禁失败。协调器只删除自己拥有的覆盖率目录树;若该路径是链接,则只 unlink,不递归跟随。 diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index abf3c5147a..3b95de4f79 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -65,17 +65,19 @@ interface RealInstanceFixture { readonly workspace: string } +type ResponsesScript = readonly ResponsesBehavior[] | ((workspace: string) => readonly ResponsesBehavior[]) + async function realInstanceFixture( - script: readonly ResponsesBehavior[], + script: ResponsesScript, ): Promise { const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-')) roots.push(root) const workspace = join(root, 'workspace') const codexHome = join(root, 'codex-home') - const fixture = await startResponsesFixture(script) - fixtures.push(fixture) mkdirSync(workspace) mkdirSync(codexHome) + const fixture = await startResponsesFixture(typeof script === 'function' ? script(workspace) : script) + fixtures.push(fixture) writeFileSync(join(codexHome, 'config.toml'), [ 'model = "fixture-model"', 'model_provider = "fixture"', @@ -133,7 +135,7 @@ async function realRuntime(): Promise { } async function realHarness( - script: readonly ResponsesBehavior[], + script: ResponsesScript, permissionMode?: CodexPermissionMode, ): Promise<{ readonly harness: RealHarness @@ -385,25 +387,30 @@ describe('real @openai/codex 0.147.0 product', () => { it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => { const sideEffect = 'bypass-side-effect' - const command = `echo bypass>${sideEffect}` - const commandCalls = [ - { - name: 'exec_command', - arguments: { - cmd: command, + const { harness, fixture } = await realHarness((workspace): readonly ResponsesBehavior[] => { + const target = join(workspace, sideEffect) + const command = process.platform === 'win32' + ? `powershell.exe -NoLogo -NoProfile -NonInteractive -Command "Set-Content -LiteralPath '${target.replaceAll("'", "''")}' -Value 'bypass' -NoNewline"` + : `printf bypass > ${JSON.stringify(target)}` + const commandCalls = [ + { + name: 'exec_command', + arguments: { + cmd: command, + }, }, - }, - { - name: 'shell_command', - arguments: { - command, + { + name: 'shell_command', + arguments: { + command, + }, }, - }, - ] as const - const { harness } = await realHarness([ - { kind: 'advertisedFunctionCall', choices: commandCalls }, - { kind: 'complete', text: 'bypass complete' }, - ], 'dangerously-bypass-approvals-and-sandbox') + ] as const + return [ + { kind: 'advertisedFunctionCall', choices: commandCalls }, + { kind: 'complete', text: 'bypass complete' }, + ] + }, 'dangerously-bypass-approvals-and-sandbox') const target = join(harness.workspace, sideEffect) const run = await harness.ctx.subagents.start('codex', { prompt: [{ type: 'text', text: 'Create the fixture side effect.' }], @@ -414,6 +421,7 @@ describe('real @openai/codex 0.147.0 product', () => { output: [{ type: 'text', text: 'bypass complete' }], stopReason: 'completed', }) + expect(existsSync(target), JSON.stringify(fixture.requests.at(-1)?.body.input)).toBe(true) expect(readFileSync(target, 'utf8').trim()).toBe('bypass') await run.dispose() await expectQuiescent(harness.handles) diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts index 81040f650e..749a63d6a7 100644 --- a/scripts/coverage-partitions.spec.ts +++ b/scripts/coverage-partitions.spec.ts @@ -129,7 +129,7 @@ describe('coverage partition coordinator', () => { const runCommand = vi.fn(async (command: CoverageCommand) => { await writeBlob(command) return command.label === 'partition 2/2' - ? { exitCode: 1, signalCode: null } + ? { exitCode: 1, signalCode: null, outputTail: 'specific Vitest failure' } : passed }) const coordinator = new CoveragePartitionCoordinator({ @@ -141,6 +141,9 @@ describe('coverage partition coordinator', () => { await expect(coordinator.run()).resolves.toBe(1) expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (exit 1)') + expect(reported).toHaveBeenCalledWith( + 'coverage-partitions: output tail for partition 2/2:\nspecific Vitest failure', + ) expect(runCommand).toHaveBeenCalledTimes(3) }) diff --git a/scripts/coverage-partitions.ts b/scripts/coverage-partitions.ts index 9302eea612..d9abc06101 100644 --- a/scripts/coverage-partitions.ts +++ b/scripts/coverage-partitions.ts @@ -34,6 +34,8 @@ export interface CoverageCommandResult { signalCode: NodeJS.Signals | null /** Spawn failure recorded independently from process completion. */ error?: string + /** Bounded combined stdout/stderr tail repeated when the command fails. */ + outputTail?: string } /** Execute one coordinator command with inherited output. */ @@ -120,6 +122,9 @@ export class CoveragePartitionCoordinator { const result = await this.runCommand(command) if (commandFailed(result)) { console.error(`coverage-partitions: FAIL ${command.label} (${commandFailureReason(result)})`) + if (result.outputTail !== undefined && result.outputTail !== '') { + console.error(`coverage-partitions: output tail for ${command.label}:\n${result.outputTail}`) + } } return result })) @@ -202,6 +207,7 @@ export class CoveragePartitionCoordinator { /** Spawn one pnpm-backed command without a platform shell. */ function runCoverageCommand(command: CoverageCommand): Promise { return new Promise((resolveCommand) => { + let outputTail = '' const env = { ...process.env } for (const [name, value] of Object.entries(command.env)) { if (value === undefined) Reflect.deleteProperty(env, name) @@ -210,17 +216,32 @@ function runCoverageCommand(command: CoverageCommand): Promise { + process.stdout.write(chunk) + outputTail = appendOutputTail(outputTail, chunk) + }) + child.stderr.on('data', (chunk: string) => { + process.stderr.write(chunk) + outputTail = appendOutputTail(outputTail, chunk) }) child.once('error', (error: Error) => { - resolveCommand({ exitCode: null, signalCode: null, error: error.message }) + resolveCommand({ exitCode: null, signalCode: null, error: error.message, outputTail }) }) - child.once('exit', (exitCode, signalCode) => { - resolveCommand({ exitCode, signalCode }) + child.once('close', (exitCode, signalCode) => { + resolveCommand({ exitCode, signalCode, outputTail }) }) }) } +function appendOutputTail(previous: string, chunk: string): string { + const combined = previous + chunk + return combined.length <= 65_536 ? combined : combined.slice(-65_536) +} + function commandFailed(result: CoverageCommandResult): boolean { return result.exitCode !== 0 || result.signalCode !== null || result.error !== undefined } From 45a73e3ed598cf834d2d804bbacfc8707f580d21 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:03:20 +0800 Subject: [PATCH 95/95] fix(ci): preserve Windows gate ordering --- ...26-07-06-parallel-pre-push-gates.i18n.yaml | 4 +- .../2026-07-06-parallel-pre-push-gates.md | 4 +- .../2026-07-06-parallel-pre-push-gates.zh.md | 4 +- ...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 +- ...8-18-in-job-partitioned-coverage.i18n.yaml | 4 +- .../2026-08-18-in-job-partitioned-coverage.md | 4 +- ...26-08-18-in-job-partitioned-coverage.zh.md | 4 +- .github/workflows/ci.yml | 1 + .../tests/process-exit.spec.ts | 2 + scripts/run-gates.spec.ts | 30 +++++++- scripts/run-gates.ts | 74 +++++++++++-------- 13 files changed, 89 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index 029c9a46ab..0f915e737d 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.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-07-06-parallel-pre-push-gates.md -2026-07-06-parallel-pre-push-gates.md: 189d6c2dfe08a9551037b936fd8015a3e86d1e51 -2026-07-06-parallel-pre-push-gates.zh.md: 17920b189c30db57f661df41a2664e3e727d1589 +2026-07-06-parallel-pre-push-gates.md: 2ae08b8c87939085a0f8c7e0cb3ac69fb3ab8e91 +2026-07-06-parallel-pre-push-gates.zh.md: d31397966e7561cb7edcd9815a57b22a4a3ba8e1 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 189d6c2dfe..2ae08b8c87 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -12,7 +12,7 @@ Aggregate jobs such as documentation synchronization hide long sequential chains ## Decision -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output by default, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. A gate marked `allowFailure` still reports its result but does not fail the aggregate. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output by default, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. A `needs` edge requires the predecessor to pass and skips its dependent otherwise; an `after` edge waits for any terminal outcome and then permits the follower to run. A gate marked `allowFailure` still reports its result but does not fail the aggregate. Long coordinator gates whose own subprocesses preserve useful attribution may opt into `streamOutput`. Their stdout and stderr reach the parent immediately without being buffered or printed again at completion. Partitioned coverage and parallel Web snapshots use this mode so a mid-run failure is visible without waiting for sibling work. @@ -24,7 +24,7 @@ The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygie ## Verification -[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins the consumer and native Windows inventories and their dependency or failure semantics, exercises signal termination through a real child process, and proves that streamed output is immediate and unbuffered. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run. +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins pass-required and settle-only ordering, pins the consumer and native Windows inventories and their failure semantics, exercises signal termination through a real child process, and proves that streamed output is immediate and unbuffered. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 17920b189c..d31397966e 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和按需启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,默认缓冲可归因的输出,分别报告进程退出与信号终止结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。标记为 `allowFailure` 的门禁仍会报告结果,但不会使聚合流程失败。 +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和按需启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,默认缓冲可归因的输出,分别报告进程退出与信号终止结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。`needs` 边要求前置门禁通过,否则跳过依赖方;`after` 边只等待前置门禁以任意结果结算,随后仍允许后继门禁运行。标记为 `allowFailure` 的门禁仍会报告结果,但不会使聚合流程失败。 自身子进程能够保留有效归因的长时间协调门禁可以选择 `streamOutput`。其 stdout 与 stderr 会立即到达父进程,不会被缓冲,也不会在结束时重复打印。分区覆盖率与并行 Web 快照使用该模式,使运行中途的失败无需等待兄弟工作结束就能显示。 @@ -24,7 +24,7 @@ Node 24 消费方任务采用单个包含 10 道门禁的模式,而非由 shel ## 验证 -[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定消费方与原生 Windows 清单及其依赖或失败语义,通过真实子进程验证信号终止,并证明流式输出会立即显示且不被缓冲。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。 +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定必须通过与只等结算两种顺序,锁定消费方与原生 Windows 清单及其失败语义,通过真实子进程验证信号终止,并证明流式输出会立即显示且不被缓冲。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.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 cb84ea9e03..a9fc45086c 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: 7bcdfa7a3e560f247b3041ddf6dd214031c540fc -2026-08-08-native-windows-pull-request-ci.zh.md: 12c853364cbfb191e3bf7c06734559dcdd498ab3 +2026-08-08-native-windows-pull-request-ci.md: d4883cf1363a33a444f1172829149c0c41f21c10 +2026-08-08-native-windows-pull-request-ci.zh.md: c6eb91f0cbc0f3a97599f0c1bb60b8bf98d9c844 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 7bcdfa7a3e..d4883cf136 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. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. -The 16-core lane admits four concurrent outer gates. Workspace build, production-site validation, instrumented coverage, and exempt-heavy coverage appear first and start together; every observational gate waits for both coverage gates before entering the available slots, so source-scanning tests cannot race static gates that create temporary contract files. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`; together with build and site, the initial outer schedule has about twelve active execution units instead of exceeding twenty. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. 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 lane admits four concurrent outer gates. Workspace build, production-site validation, and instrumented coverage start immediately. Exempt-heavy coverage waits for the build to pass, so its temporary Oxlint contract probes cannot race source compilation. Every observational gate waits for both coverage gates to settle, regardless of outcome, before entering an available slot; its own `needs` edges still require their predecessors to pass. This also keeps later static gates that create temporary contract files from racing either coverage scan. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`. The initial phase therefore has about ten active execution units; after build, starting exempt-heavy while build leaves keeps the peak near eleven when site and instrumented coverage are still running. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. 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. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds, but used the whole host before the exempt, build, and site work was counted; eight shards deliberately trade some latency for headroom. 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 12c853364c..c6eb91f0cb 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 负责。 -16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证、插桩覆盖率与豁免重型覆盖率排在最前并同时启动;每道观测性门禁都要等待两道覆盖率门禁完成后才进入可用槽位,避免扫描源码的测试与创建临时约定文件的静态门禁发生竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker;再加上构建与网站,初始外层调度约有 12 个活动执行单元,而不是超过 20 个。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证与插桩覆盖率会立即启动。豁免重型覆盖率等待构建通过后再启动,使其临时 Oxlint 约定探针不会与源码编译竞态。每道观测性门禁只等待两道覆盖率门禁以任意结果结算后再进入可用槽位;各门禁自身的 `needs` 边仍要求前置门禁通过。这也使随后创建临时约定文件的静态门禁不会与任一覆盖率扫描竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker。因此初始阶段约有 10 个活动执行单元;构建结束并启动豁免重型门禁后,如果网站与插桩覆盖率仍在运行,峰值约为 11 个。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒,但还未计入豁免、构建与网站工作就已经占满整台宿主;8 个分片刻意用部分延迟换取余量。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index 4b33d023ed..6129ead7c4 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: b7335c490c4a4921e5c786809e0db492613ef5c8 -2026-08-18-in-job-partitioned-coverage.zh.md: 5e2a6a60d59751a22c7d664d4bc50d93390c6995 +2026-08-18-in-job-partitioned-coverage.md: f86c2dffb6d3d30fdccfa445c57043c5217e439b +2026-08-18-in-job-partitioned-coverage.zh.md: c7b1df28f1603a558d8dd3a3f0f26c6fb1edc3bd diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index b7335c490c..f86c2dffb6 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -18,7 +18,7 @@ When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:cove The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. -`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates: build, production-site validation, instrumented coverage, and exempt-heavy coverage start first, and the observational inventory waits for both coverage gates before entering the available slots. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. Build, production-site validation, and instrumented coverage start immediately; exempt-heavy coverage starts only after build passes, preventing its temporary Oxlint probes from racing source compilation. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. ## Failure and output semantics @@ -38,7 +38,7 @@ Completed native Windows comparisons measured two partitions near 405 seconds an **Raise the Vitest worker count inside one instrumented process.** Rejected because completed Windows trials at higher fan-out exposed worker exits, fixture instability, and Node 24 CJS lexer failures. Separate single-worker processes preserve isolation while still executing the selected partitions concurrently. -**Use one partition count on every host.** Rejected because Linux's two-process run and Windows's eight-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. +**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's eight-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. **Apply thresholds independently in each partition.** Rejected because every partition intentionally sees only part of the suite and would report false uncovered files. Threshold ownership belongs to the merged report. diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index 5e2a6a60d5..c7b1df28f1 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -18,7 +18,7 @@ Status: implemented 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 -`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发:构建、生产网站验证、插桩覆盖率与豁免重型覆盖率先启动,观测性清单等待两道覆盖率门禁完成后才进入可用槽位。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。构建、生产网站验证与插桩覆盖率会立即启动;豁免重型覆盖率只在构建通过后启动,避免其临时 Oxlint 探针与源码编译竞态。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 ## 失败与输出语义 @@ -38,7 +38,7 @@ Status: implemented **提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。 -**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的双进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 +**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 **在每个分区内独立应用阈值。** 不予采用,因为每个分区有意只看到套件的一部分,会误报未覆盖文件。阈值归合并报告所有。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38c049458d..3cab0cf791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,6 +460,7 @@ jobs: # under the complete lane's concurrent gate load. DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' DSH_GATE_CONCURRENCY: '4' + DSH_PUBLINT_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 with: diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index cdfc4f4ae0..cfea99f12a 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -106,6 +106,8 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { let settled = false let treeGone = false try { + // The host validates tree.json before waiting for proceed, so observing it + // is sufficient readiness; a second marker only adds a redundant Windows poll. state = await readTree(join(root, 'tree.json')) if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) await writeFile(join(root, 'proceed'), 'proceed') diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 5e29bad199..dce448b2e0 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -109,15 +109,16 @@ describe('gate graph validation', () => { expect(byId.get('coverage')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true) + expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build') expect(observational).not.toHaveLength(0) for (const gate of observational) { const completeGate = byId.get(gate.id) expect(completeGate?.allowFailure).toBe(true) - expect(completeGate?.needs).toEqual(expect.arrayContaining([ + expect(completeGate?.after).toEqual(expect.arrayContaining([ 'coverage', 'coverage-exempt-heavy', - ...(gate.needs ?? []), ])) + expect(completeGate?.needs).toEqual(gate.needs) } }) @@ -171,7 +172,9 @@ describe('gate graph validation', () => { ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/], + ['unknown ordering predecessors', [gate('subject', { after: ['missing'] })], /waits for unknown gate "missing"/], ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/], + ['mixed cycles', [gate('first', { after: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/], ] as const)('rejects %s before starting a child', async (_label, invalid, message) => { const execute = vi.fn(async (subject: Gate) => resultFor(subject)) @@ -197,6 +200,29 @@ describe('gate graph validation', () => { expect(execute).toHaveBeenCalledWith(root) expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' }) }) + + it('runs an ordered follower after its predecessor fails', async () => { + const follower = gate('follower', { after: ['root'] }) + const root = gate('root') + const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed')) + + const results = await runGates([follower, root], 2, execute) + + expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower']) + expect(results.map(result => result.status)).toEqual(['passed', 'failed']) + }) + + it('runs an ordered follower after its predecessor is skipped', async () => { + const follower = gate('follower', { after: ['dependent'] }) + const dependent = gate('dependent', { needs: ['root'] }) + const root = gate('root') + const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed')) + + const results = await runGates([follower, dependent, root], 2, execute) + + expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower']) + expect(results.map(result => result.status)).toEqual(['passed', 'skipped', 'failed']) + }) }) describe('Oxlint gate', () => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 92b30fcdb0..1f65fed97e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -45,6 +45,8 @@ export interface Gate { command: string args: string[] needs?: string[] + /** Gate ids that must settle, regardless of outcome, before this gate starts. */ + after?: string[] env?: Record /** Keep a failure visible without failing the aggregate. */ allowFailure?: boolean @@ -454,8 +456,10 @@ function ciWindowsBlockingGates(): Gate[] { } function ciWindowsCompleteGates(): Gate[] { - const coverage = coverageGates() - const coverageNeeds = coverage.map(gate => gate.id) + const coverage = coverageGates().map(gate => gate.id === 'coverage-exempt-heavy' + ? { ...gate, needs: [...new Set(['build', ...(gate.needs ?? [])])] } + : gate) + const coverageAfter = coverage.map(gate => gate.id) const observational = ciWindowsObservationalGates() // The required production site replaces the observational MPA build; both // VitePress modes write the same output directory and cannot overlap. @@ -463,7 +467,7 @@ function ciWindowsCompleteGates(): Gate[] { .map(gate => ({ ...gate, allowFailure: true, - needs: [...new Set([...coverageNeeds, ...(gate.needs ?? [])])], + after: [...new Set([...coverageAfter, ...(gate.after ?? [])])], })) return [ pnpmScript('build', 'build'), @@ -713,6 +717,11 @@ function validateGateGraph(gates: readonly Gate[]): void { throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}.`) } } + for (const predecessor of gate.after ?? []) { + if (!ids.has(predecessor)) { + throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} waits for unknown gate ${JSON.stringify(predecessor)}.`) + } + } } const cycle = findDependencyCycle(gates) @@ -734,8 +743,8 @@ function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { active.set(id, path.length) path.push(id) - for (const dependency of gate.needs ?? []) { - const cycle = visit(dependency) + for (const predecessor of [...(gate.needs ?? []), ...(gate.after ?? [])]) { + const cycle = visit(predecessor) if (cycle !== undefined) return cycle } path.pop() @@ -776,7 +785,7 @@ export async function runGates( for (;;) { let madeProgress = false while (running.length < maxActive) { - const ready = gates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) + const ready = gates.find(gate => states.get(gate.id) === 'pending' && predecessorsReady(gate, states)) if (ready === undefined) break states.set(ready.id, 'running') running.push({ gate: ready, promise: execute(ready) }) @@ -785,32 +794,24 @@ export async function runGates( } if (running.length === 0) { - let pending = gates.filter(gate => states.get(gate.id) === 'pending') - while (pending.length > 0) { - const gate = pending.find(item => (item.needs ?? []).some((id) => { - const state = states.get(id) - return state === 'failed' || state === 'skipped' - })) - if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.') - const failedDeps = (gate.needs ?? []).filter((id) => { - const state = states.get(id) - return state === 'failed' || state === 'skipped' - }) - const result: GateResult = { - gate, - status: 'skipped', - durationMs: 0, - output: [], - exitCode: null, - signalCode: null, - error: `dependency failed or skipped: ${failedDeps.join(', ')}`, - } - states.set(gate.id, 'skipped') - results.set(gate.id, result) - observe(result) - pending = pending.filter(item => item !== gate) + const pending = gates.filter(gate => states.get(gate.id) === 'pending') + if (pending.length === 0) break + const gate = pending.find(item => (item.needs ?? []).some(id => gateFailed(states.get(id)))) + if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.') + const failedDeps = (gate.needs ?? []).filter(id => gateFailed(states.get(id))) + const result: GateResult = { + gate, + status: 'skipped', + durationMs: 0, + output: [], + exitCode: null, + signalCode: null, + error: `dependency failed or skipped: ${failedDeps.join(', ')}`, } - break + states.set(gate.id, 'skipped') + results.set(gate.id, result) + observe(result) + continue } if (!madeProgress) { @@ -829,8 +830,17 @@ export async function runGates( }) } -function dependenciesPassed(gate: Gate, states: Map): boolean { +function predecessorsReady(gate: Gate, states: Map): boolean { return (gate.needs ?? []).every(id => states.get(id) === 'passed') + && (gate.after ?? []).every(id => gateSettled(states.get(id))) +} + +function gateSettled(state: GateState | undefined): boolean { + return state === 'passed' || state === 'failed' || state === 'skipped' +} + +function gateFailed(state: GateState | undefined): boolean { + return state === 'failed' || state === 'skipped' } /**