From 106e5ce0bc199547e1e93a866bcc0cd7d2df35e7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 25 Aug 2026 12:00:24 +0800 Subject: [PATCH 01/38] feat(bundle): default session telemetry to feedback-gated sharing --- ...2026-08-10-telemetry-default-off.i18n.yaml | 4 +-- .../2026-08-10-telemetry-default-off.md | 2 +- .../2026-08-10-telemetry-default-off.zh.md | 2 +- ...feedback-gated-telemetry-default.i18n.yaml | 6 ++++ ...-08-25-feedback-gated-telemetry-default.md | 29 +++++++++++++++++++ ...-25-feedback-gated-telemetry-default.zh.md | 29 +++++++++++++++++++ apps/cli/reference/README.i18n.yaml | 4 +-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 14 +++++---- packages/bundle/base/tests/base.spec.ts | 2 +- 11 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md create mode 100644 .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 1132da788f..59cab0c69c 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: 3f56817c9c23ec55f2173b66fa915ab05646b2a7 -2026-08-10-telemetry-default-off.zh.md: ea89ea94dfea3106886e555e172ad8a6ab39305b +2026-08-10-telemetry-default-off.md: db55eda83628dd2908b312000457c75e0bc07c8f +2026-08-10-telemetry-default-off.zh.md: e444f4aad2782eb46c4b787f5fd14dd84b67003e diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index 3f56817c9c..db55eda836 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -10,7 +10,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the ## Decision -Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. +Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. The shared base's session-backend default was later superseded by the [feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md), which resolves an unset `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY`; the hard opt-out and the launcher rule below remain current. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md). diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index ea89ea94df..e444f4aad2 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 ## 决策 -两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.zh.md)继续负责 endpoint、批处理节奏和退出排空设置。 +两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。共享基础配置中会话后端的默认值后来被[反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)取代,未设置的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY`;硬性退出开关和下文的启动器规则仍然有效。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.zh.md)继续负责 endpoint、批处理节奏和退出排空设置。 dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.zh.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)删除之前,仅取代了启动器默认允许上报的规则。 diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml new file mode 100644 index 0000000000..9a7b9066ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.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-25-feedback-gated-telemetry-default.md +2026-08-25-feedback-gated-telemetry-default.md: 1a3766ee44907cee330a5b381aad3a91c7efc58f +2026-08-25-feedback-gated-telemetry-default.zh.md: 677574fdb51a38f36db7cc45ba479a36b5bdd629 diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md new file mode 100644 index 0000000000..1a3766ee44 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md @@ -0,0 +1,29 @@ +# Agent Note: Feedback-gated session-telemetry default + +Status: implemented + +English | [中文](2026-08-25-feedback-gated-telemetry-default.zh.md) + +## Problem + +Diagnosing a `/feedback` report needs the session data the report describes. With the shared base resolving an unset `DSH_TELEMETRY_MODE` to `DISABLED`, a default installation's feedback reached its receiver with no session data at all, and the reporter had no way to grant access at the moment they asked for help; only deployments that had exported `DSH_TELEMETRY_MODE` beforehand ever delivered a diagnosable report. + +## Decision + +The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`; recording feedback releases the canonical session-log prefix through that exact event to the configured OTLP endpoint, and the acknowledgement's sharing disclosure states that recording feedback releases the session prefix. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it. + +This supersedes the session-backend default of the [default-off decision](2026-08-10-telemetry-default-off.md), accepting the user's explicit feedback action as the release authorization that note required a deployment setting for. That note's hard opt-out and its launcher-feed history remain current, and the [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. + +## Alternatives considered + +**Keep `DISABLED` and instruct reporters to re-run with `DSH_TELEMETRY_MODE=FEEDBACK_ONLY`.** Rejected: the session that exhibited the problem is the one worth uploading, and re-running loses it. + +**Default to `FULL`.** Rejected: continuous export without any user action is exactly what the default-off decision forbids, and nothing in a fresh installation authorizes it. + +**Gate the official DeepSeek `dsh_session_log` request contribution on feedback instead of reviving the OTel default.** Not taken here: that contribution uploads through subsequent LLM requests rather than at the feedback boundary, so a session's final feedback would never be delivered; a feedback-triggered flush on that path is a larger design than a default flip. + +## Consequences + +- A fresh installation uploads the session-log prefix to the production collector when — and only when — the user records `/feedback`; no other trigger uploads. +- Released exports remain the raw captured copy: the shipped base mounts no `session-telemetry/record` redaction rule, so they can contain message text, tool arguments and results, and workspace paths. +- The sharing disclosure is part of the `/feedback` acknowledgement, so the user reads it after the release has been triggered. A deployment that requires prior informed consent must override the default to `DISABLED` or add a pre-upload confirmation before this default is defensible there. diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md new file mode 100644 index 0000000000..677574fdb5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 反馈门控的会话遥测默认值 + +Status: implemented + +[English](2026-08-25-feedback-gated-telemetry-default.md) | 中文 + +## 问题 + +诊断一条 `/feedback` 报告需要报告所描述的会话数据。共享基础配置把未设置的 `DSH_TELEMETRY_MODE` 解析为 `DISABLED`,因此默认安装发出的反馈到达接收方时不带任何会话数据,报告者在求助的那一刻也没有授权共享的途径;只有事先导出了 `DSH_TELEMETRY_MODE` 的部署才能交付可诊断的报告。 + +## 决定 + +共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据;记录反馈时通过该事件把权威会话日志前缀释放到已配置的 OTLP 端点,确认信息中的共享声明会说明记录反馈将释放会话前缀。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。 + +本决定取代[默认关闭决定](2026-08-10-telemetry-default-off.zh.md)中会话后端的默认值,把用户显式的反馈动作接受为该决定原本要求由部署设置提供的释放授权。该决定的强制关闭开关和 launcher 上报历史仍然有效,端点、批处理节奏和退出排空设置仍由[默认挂载决定](2026-07-31-web-telemetry-default-mount.zh.md)持有。 + +## 考虑过的替代方案 + +**保持 `DISABLED`,让报告者带着 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 重跑。** 否决:值得上传的正是出现问题的那个会话,重跑会丢掉它。 + +**默认 `FULL`。** 否决:没有任何用户动作的持续导出正是默认关闭决定所禁止的,全新安装中没有任何东西授权它。 + +**改为在反馈时门控官方 DeepSeek `dsh_session_log` 请求贡献,而不是恢复 OTel 默认值。** 此处未采用:该贡献通过后续 LLM 请求上传,而不是在反馈边界上传,会话的最后一条反馈永远不会被交付;在那条路径上做反馈触发的冲刷是比翻转默认值更大的设计。 + +## 后果 + +- 全新安装只在用户记录 `/feedback` 时把会话日志前缀上传到生产 collector;没有其他触发上传的途径。 +- 释放的导出仍是未加工的原始副本:随附基础配置没有挂载 `session-telemetry/record` 脱敏规则,导出可能包含消息文本、工具参数和结果,以及 workspace 路径。 +- 共享声明是 `/feedback` 确认信息的一部分,用户读到它时释放已被触发。要求事先知情同意的部署必须把默认值覆盖为 `DISABLED`,或在上传前增加确认步骤,此默认值在那类部署中才站得住。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 96e7b4c7b0..9e858e257b 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: 33de399dc4b2b8e67ed24ed046fcc0da2ff0a7ac -README.zh.md: 0f20245f318e31159780d29cca955fc85a5b5481 +README.md: e2814bbda5249fbf0ca0fb9c7f2e98175c5d4e0c +README.zh.md: e714e08147f5430871c17023e67563eefa85118f diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 33de399dc4..e2814bbda5 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -91,7 +91,7 @@ New sessions in base-backed profiles default to the `workspace-write` permission The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. -Session telemetry stays local by default. `DSH_TELEMETRY_MODE=FULL` streams every projected session event as OTLP/HTTP logs, while `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` uploads a session-log suffix only when feedback is recorded. `DSH_TELEMETRY_OTLP_URL` selects another collector, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. The shipped base has no telemetry redaction rule, so explicitly enabled exports can contain message text, tool arguments and results, and workspace paths; the [default-off Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md) owns that deployment decision. +Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and recording feedback releases the session-log prefix through that event. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision. Install external plugin bundles through `dsh plugin --profile add `. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 0f20245f31..e714e08147 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -91,7 +91,7 @@ dsh web --help 基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 -会话遥测默认留在本地。`DSH_TELEMETRY_MODE=FULL` 将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 则仅在记录反馈时上传会话日志后缀。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空的 `DSH_TELEMETRY_DISABLED` 都是具有最终效力的遥测强制关闭开关。随附基础配置没有遥测脱敏规则,因此显式启用的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[默认关闭 Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md)。 +会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,记录反馈时通过该事件释放会话日志前缀。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。 通过 `dsh plugin --profile add ` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 981e791fb4..33f8b693e2 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -138,11 +138,13 @@ - id: session-projection name: '@deepseek-ai/dsh-session-projection' - # Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE - # explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors - # session-log records onto OTLP/HTTP logs with no session-telemetry/record redaction - # rule, so exports are the raw captured copy. The deployment stance, env - # seams, and follow-ups are pinned in the default-off Agent Note. + # Session telemetry defaults to feedback-gated sharing: FEEDBACK_ONLY + # uploads the canonical session-log prefix only after the user records + # /feedback. DSH_TELEMETRY_MODE overrides to FULL or DISABLED; uploading + # mirrors session-log records onto OTLP/HTTP logs with no session-telemetry/record + # redaction rule, so exports are the raw captured copy. The deployment + # stance, env seams, and follow-ups are pinned in the feedback-gated-default + # Agent Note. # DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty # DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the # process out (the launchers patch the row disabled; config cannot disable @@ -160,7 +162,7 @@ - id: session-telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: - mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED' + mode: !!js process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY' shutdownTimeoutMillis: 3000 exporter: url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs' diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 4fc16ead7c..df3882968b 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -33,7 +33,7 @@ describe('dsh-base bundle', () => { expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({ - __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'", + __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'", }) expect(rows.find(row => row.id === 'hmr')).toMatchObject({ disabled: true, From ac4a2f979272ecf6bb51d546a9d4bb2b0a59a129 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 10:05:31 +0800 Subject: [PATCH 02/38] =?UTF-8?q?fix(feedback):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20accurate=20release=20wording,=20current-state=20not?= =?UTF-8?q?es,=20default-mode=20snapshot=20lane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...7-31-web-telemetry-default-mount.i18n.yaml | 4 +- .../2026-07-31-web-telemetry-default-mount.md | 8 +- ...26-07-31-web-telemetry-default-mount.zh.md | 8 +- ...2026-08-10-telemetry-default-off.i18n.yaml | 4 +- .../2026-08-10-telemetry-default-off.md | 4 +- .../2026-08-10-telemetry-default-off.zh.md | 4 +- ...feedback-gated-telemetry-default.i18n.yaml | 4 +- ...-08-25-feedback-gated-telemetry-default.md | 4 +- ...-25-feedback-gated-telemetry-default.zh.md | 4 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 4 +- apps/web/tests/feedback-release.e2e.ts | 137 ++++++++++++++++++ apps/web/tests/scaffold.ts | 12 +- apps/web/tsconfig.json | 1 + packages/bundle/base/cordis.patch.yml | 6 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 2 +- .../feedback/command-feedback/README.zh.md | 2 +- .../feedback/command-feedback/src/index.ts | 2 +- .../tests/command-feedback.spec.ts | 2 +- .../web/feedback-release/ack.expected.md | 46 ++++++ snapshots/web/feedback-release/snapshot.yml | 9 ++ tsconfig.host.json | 1 + 24 files changed, 239 insertions(+), 41 deletions(-) create mode 100644 apps/web/tests/feedback-release.e2e.ts create mode 100644 snapshots/web/feedback-release/ack.expected.md create mode 100644 snapshots/web/feedback-release/snapshot.yml diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml index 2d37aaba9f..dd8a8cdb84 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.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-web-telemetry-default-mount.md -2026-07-31-web-telemetry-default-mount.md: a492356eccba9f272ee216777eb518750c7b6b62 -2026-07-31-web-telemetry-default-mount.zh.md: 3d852229c069ae32f328c8dae38cfdf1744c7293 +2026-07-31-web-telemetry-default-mount.md: aea90ef928afaa7669df8046fa98f16c575618f0 +2026-07-31-web-telemetry-default-mount.zh.md: 122b7ad593c18cd547ade835827e8903d34da441 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md index a492356ecc..aea90ef928 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md @@ -10,12 +10,12 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry ## Decision -The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps the mounted row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. +The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) originally kept the mounted row in `DISABLED` mode; the [feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md) now resolves an unset mode to `FEEDBACK_ONLY`, uploading only when the user records `/feedback`. The endpoint alone still does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. | Ruling | Value | Rationale | |---|---|---| | Mount surface | `packages/bundle/base/cordis.patch.yml` | One capability row for every profile that loads the shared base | -| Sharing mode | `DSH_TELEMETRY_MODE`, default `DISABLED`; explicit `FULL` or `FEEDBACK_ONLY` opts in | A fresh profile makes no telemetry network request, while internal deployments retain both upload policies | +| Sharing mode | `DSH_TELEMETRY_MODE`, default `FEEDBACK_ONLY` ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)); explicit `FULL` or `DISABLED` overrides | A fresh profile uploads only when the user records `/feedback`, while internal deployments retain both explicit policies | | Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs | | Hard opt-out | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables the row | The launcher patch takes effect before load-time transport validation and overrides every configured mode | | Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) in uploading modes | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval | @@ -23,7 +23,7 @@ The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the | Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth | | CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in GitHub workflows | Defense in depth keeps test sessions local even when a job explicitly selects an uploading mode | -The base bundle test pins the shipped `DISABLED` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery. +The base bundle test pins the shipped `FEEDBACK_ONLY` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery. ## Alternatives considered @@ -35,6 +35,6 @@ The base bundle test pins the shipped `DISABLED` mode expression, the backend su ## Consequences -- A developer running `dsh web` without telemetry configuration makes no telemetry network request. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector. +- A developer running `dsh web` without telemetry configuration makes no telemetry network request until they record `/feedback`. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector. - **No redaction rule is mounted**: explicitly enabled exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `session-telemetry/record` rules first — the redaction rule, remaining identity Resource attributes, and usage metrics remain separate deployment work. The anonymous user id ships through the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md). - Test rigs remain local by default; explicit uploading-mode tests provide their own collector and mode. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md index 3d852229c0..122b7ad593 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -10,12 +10,12 @@ Status: implemented ## 决策 -共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)让已挂载配置项保持 `DISABLED` 模式,除非部署方显式选择 `FULL` 或 `FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 +共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)最初让已挂载配置项保持 `DISABLED` 模式;[反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)现在把未设置的模式解析为 `FEEDBACK_ONLY`,只在用户记录 `/feedback` 时上传。仅配置 endpoint 仍不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 | 决策项 | 取值 | 理由 | |---|---|---| | 挂载面 | `packages/bundle/base/cordis.patch.yml` | 每个加载共享基础组合包的 profile 都使用同一个能力配置行 | -| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `DISABLED`;显式设置 `FULL` 或 `FEEDBACK_ONLY` 即启用 | 新 profile 不发出遥测网络请求,内部部署仍可使用两种上传策略 | +| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `FEEDBACK_ONLY`([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md));显式设置 `FULL` 或 `DISABLED` 即覆盖 | 新 profile 只在用户记录 `/feedback` 时上传,内部部署仍可使用两种显式策略 | | endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 | | 硬性退出 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即禁用该配置行 | 启动器 patch 在加载期传输校验之前生效,并覆盖所有已配置模式 | | 上报节奏 | 上传模式中为 `processor.scheduledDelayMillis: 10000`(10s/批) | 在会话运行期间流式上报,而非仅在退出时上报;崩溃至多丢失最后一个尚未导出间隔内的数据 | @@ -24,7 +24,7 @@ Status: implemented | CI 隔离 | GitHub 工作流顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | 即使 CI 任务显式选择上传模式,纵深防御也会让测试会话留在本地 | -基础组合包测试固定交付的 `DISABLED` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式。 +基础组合包测试固定交付的 `FEEDBACK_ONLY` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式。 ## 考虑过的替代方案 @@ -36,6 +36,6 @@ Status: implemented ## 后果 -- 开发者运行没有遥测配置的 `dsh web` 时,不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL` 指向其他 collector。 +- 开发者运行没有遥测配置的 `dsh web` 时,在记录 `/feedback` 之前不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL` 指向其他 collector。 - **没有挂载任何脱敏规则**:显式启用的导出即原始捕获副本(用户/助手消息全文、工具参数与工具结果、系统提示词、`session.cwd` 本地路径)。跨信任边界前必须先挂载 `session-telemetry/record` 规则;脱敏规则、其余身份 Resource 属性和使用情况指标仍是独立的部署工作。匿名 user id 由[匿名 user id Note](2026-07-31-telemetry-anonymous-user-id.zh.md)交付。 - 测试载具默认将数据留在本地;显式启用上传模式的测试提供自己的 collector 和模式。 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 59cab0c69c..823b1327b0 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: db55eda83628dd2908b312000457c75e0bc07c8f -2026-08-10-telemetry-default-off.zh.md: e444f4aad2782eb46c4b787f5fd14dd84b67003e +2026-08-10-telemetry-default-off.md: 1bc9719f7036509f1fff8c28f0607669505280ad +2026-08-10-telemetry-default-off.zh.md: 8f8bbb57810125059c177dc1c94f7631040a1d58 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index db55eda836..1bc9719f70 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -14,7 +14,7 @@ Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md). -The [CLI reference README](../../../../apps/cli/reference/README.md) documents the deployment stance: Session Log upload is off by default, `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` are the two opt-in choices, and explicitly enabled exports can contain complete session content. The restored [testing-stage onboarding notice](2026-08-13-shared-modal-product-onboarding.md) contains no telemetry copy, so the product still presents no prompt about enabling upload. +The [CLI reference README](../../../../apps/cli/reference/README.md) documents the current deployment stance: the shared base defaults to feedback-gated sharing ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)), `DSH_TELEMETRY_MODE=FULL` and `DSH_TELEMETRY_MODE=DISABLED` are the explicit overrides, and enabled exports can contain complete session content. The restored [testing-stage onboarding notice](2026-08-13-shared-modal-product-onboarding.md) contains no telemetry copy, so the product still presents no prompt about enabling upload. ## Alternatives considered @@ -28,4 +28,4 @@ The [CLI reference README](../../../../apps/cli/reference/README.md) documents t ## Consequences -Fresh profiles and projects make no telemetry network request. Internal deployments select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. +Fresh profiles and projects make no telemetry network request until the user records `/feedback` ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)). `FULL` still requires an explicit setting; the launcher feed it once also enabled was deleted with the SDK project toolchain. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index e444f4aad2..8f8bbb5781 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -14,7 +14,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.zh.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)删除之前,仅取代了启动器默认允许上报的规则。 -[CLI reference README](../../../../apps/cli/reference/README.zh.md) 记录了这一部署口径:会话日志上传默认关闭,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 是两种显式启用选项,显式开启后的导出可能包含完整会话内容。恢复后的[测试阶段引导声明](2026-08-13-shared-modal-product-onboarding.zh.md)不包含遥测文案,因此产品仍不提供任何关于开启上传的提示。 +[CLI reference README](../../../../apps/cli/reference/README.zh.md) 记录了当前的部署口径:共享基础配置默认按反馈门控共享([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)),`DSH_TELEMETRY_MODE=FULL` 和 `DSH_TELEMETRY_MODE=DISABLED` 是显式覆盖值,开启后的导出可能包含完整会话内容。恢复后的[测试阶段引导声明](2026-08-13-shared-modal-product-onboarding.zh.md)不包含遥测文案,因此产品仍不提供任何关于开启上传的提示。 ## 考虑过的替代方案 @@ -28,4 +28,4 @@ dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cord ## 后果 -全新 profile 和项目不发出任何遥测网络请求。内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 +全新 profile 和项目在用户记录 `/feedback` 之前不发出任何遥测网络请求([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md))。`FULL` 仍需显式设置;它曾一并启用的启动器数据流已随 SDK 项目工具链删除。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml index 9a7b9066ce..6221d9d868 100644 --- a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md -2026-08-25-feedback-gated-telemetry-default.md: 1a3766ee44907cee330a5b381aad3a91c7efc58f -2026-08-25-feedback-gated-telemetry-default.zh.md: 677574fdb51a38f36db7cc45ba479a36b5bdd629 +2026-08-25-feedback-gated-telemetry-default.md: 772d134da53386292083790148dce738a98f2c0f +2026-08-25-feedback-gated-telemetry-default.zh.md: ea05d4d687bc2270f907983a71f592b880d7449c diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md index 1a3766ee44..772d134da5 100644 --- a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md @@ -10,7 +10,7 @@ Diagnosing a `/feedback` report needs the session data the report describes. Wit ## Decision -The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`; recording feedback releases the canonical session-log prefix through that exact event to the configured OTLP endpoint, and the acknowledgement's sharing disclosure states that recording feedback releases the session prefix. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it. +The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`; each recorded feedback uploads the not-yet-shared session-log records — from the last handoff through that exact event — to the configured OTLP endpoint, a resumed session shares only its current lifecycle, and the acknowledgement's sharing disclosure states that recording feedback uploads the records not yet shared. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it. This supersedes the session-backend default of the [default-off decision](2026-08-10-telemetry-default-off.md), accepting the user's explicit feedback action as the release authorization that note required a deployment setting for. That note's hard opt-out and its launcher-feed history remain current, and the [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. @@ -24,6 +24,6 @@ This supersedes the session-backend default of the [default-off decision](2026-0 ## Consequences -- A fresh installation uploads the session-log prefix to the production collector when — and only when — the user records `/feedback`; no other trigger uploads. +- A fresh installation uploads the not-yet-shared session-log records to the production collector when — and only when — the user records `/feedback`; no other trigger uploads. - Released exports remain the raw captured copy: the shipped base mounts no `session-telemetry/record` redaction rule, so they can contain message text, tool arguments and results, and workspace paths. - The sharing disclosure is part of the `/feedback` acknowledgement, so the user reads it after the release has been triggered. A deployment that requires prior informed consent must override the default to `DISABLED` or add a pre-upload confirmation before this default is defensible there. diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md index 677574fdb5..ea05d4d687 100644 --- a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决定 -共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据;记录反馈时通过该事件把权威会话日志前缀释放到已配置的 OTLP 端点,确认信息中的共享声明会说明记录反馈将释放会话前缀。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。 +共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据;每条已记录的反馈把尚未共享的会话日志记录——自上次交接至该事件为止——上传到已配置的 OTLP 端点,恢复的会话只共享当前生命周期,确认信息中的共享声明会说明记录反馈将上传尚未共享的记录。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。 本决定取代[默认关闭决定](2026-08-10-telemetry-default-off.zh.md)中会话后端的默认值,把用户显式的反馈动作接受为该决定原本要求由部署设置提供的释放授权。该决定的强制关闭开关和 launcher 上报历史仍然有效,端点、批处理节奏和退出排空设置仍由[默认挂载决定](2026-07-31-web-telemetry-default-mount.zh.md)持有。 @@ -24,6 +24,6 @@ Status: implemented ## 后果 -- 全新安装只在用户记录 `/feedback` 时把会话日志前缀上传到生产 collector;没有其他触发上传的途径。 +- 全新安装只在用户记录 `/feedback` 时把尚未共享的会话日志记录上传到生产 collector;没有其他触发上传的途径。 - 释放的导出仍是未加工的原始副本:随附基础配置没有挂载 `session-telemetry/record` 脱敏规则,导出可能包含消息文本、工具参数和结果,以及 workspace 路径。 - 共享声明是 `/feedback` 确认信息的一部分,用户读到它时释放已被触发。要求事先知情同意的部署必须把默认值覆盖为 `DISABLED`,或在上传前增加确认步骤,此默认值在那类部署中才站得住。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 9e858e257b..17e6b131b0 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: e2814bbda5249fbf0ca0fb9c7f2e98175c5d4e0c -README.zh.md: e714e08147f5430871c17023e67563eefa85118f +README.md: ae6af4bafde7e08bc49f8cb208a206615e6a4c2b +README.zh.md: 96198b6294f2812cc81c7f627a5ba8c986340c33 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index e2814bbda5..ae6af4bafd 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -89,9 +89,9 @@ New sessions in base-backed profiles default to the `workspace-write` permission ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and feedback-gated session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. -Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and recording feedback releases the session-log prefix through that event. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision. +Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and each recorded feedback uploads the session records not yet shared, through that event; a resumed session shares only its current lifecycle. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision. Install external plugin bundles through `dsh plugin --profile add `. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index e714e08147..96198b6294 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -89,9 +89,9 @@ dsh web --help ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和按反馈门控的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 -会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,记录反馈时通过该事件释放会话日志前缀。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。 +会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,每条已记录的反馈通过该事件上传尚未共享的会话记录;恢复的会话只共享当前生命周期。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。 通过 `dsh plugin --profile add ` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts new file mode 100644 index 0000000000..7ba2f881ce --- /dev/null +++ b/apps/web/tests/feedback-release.e2e.ts @@ -0,0 +1,137 @@ +// Keyless assembled-browser coverage for the shipped FEEDBACK_ONLY default +// over the Web bundles and the real host wire. The scaffold mounts the +// shipped telemetry row in FEEDBACK_ONLY mode against this suite's own +// loopback mock collector, so the default release path is real: /feedback +// releases the session records through that event (exactly one OTLP request, +// carrying the drive prompt and the feedback text), the acknowledgement pins +// the feedback-gated disclosure sentence, and a second feedback releases only +// the records since the first handoff — the earlier prompt does not repeat. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { createServer, type Server } from 'node:http' +import { once } from 'node:events' +import { gunzipSync } from 'node:zlib' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-release', import.meta.url)) +// The release path needs only a settled ordinary turn, so this lane replays +// the feedback-command scenario's recorded session (declared as this +// manifest's `session.source`) instead of recording a duplicate. +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.jsonl', import.meta.url)) +const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const MODE = webSnapshotMode() + +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' + +describe('web e2e: feedback-gated release under the shipped default mode', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let collector: Server + const uploads: string[] = [] + + beforeAll(async () => { + collector = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + const raw = Buffer.concat(chunks) + uploads.push((request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw).toString()) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) + }) + collector.listen(0, '127.0.0.1') + await once(collector, 'listening') + const address = collector.address() + if (address === null || typeof address === 'string') throw new Error('collector has no port') + scaffold = await launchWebScaffold({ + telemetryUrl: `http://127.0.0.1:${address.port}/v1/logs`, + telemetryMode: 'FEEDBACK_ONLY', + // The replayed session.jsonl belongs to the feedback-command scenario; + // comparing (or refreshing) the persisted session here would rewrite + // that shared source with this lane's feedback events. The release + // evidence lives in this lane's golden and collector assertions. + compareReplaySession: false, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + collector?.close() + collector?.closeAllConnections() + }) + + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-drive')) + if (MODE !== 'record') { + // Drift guard: the shared fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + // Re-records the SHARED feedback-command session this lane replays. + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 60_000) + + it.skipIf(MODE === 'record')('releases the session records through the feedback and pins the disclosure', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release')) + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + expect(uploads).toEqual([]) + const input = page.locator('textarea').first() + await input.fill('/feedback the diff view is unreadable') + await input.press('Enter') + + await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) + expect(await page.getByText(/recording feedback uploads the session records not yet shared/).count()).toBe(1) + + // FEEDBACK_ONLY releases through the committed feedback event: exactly + // one request reaches the collector, carrying the whole unshared range. + await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(1) + expect(uploads[0]).toContain('the diff view is unreadable') + expect(uploads[0]).toContain(PROMPT) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('releases only the records since the last handoff on a second feedback', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-suffix')) + const input = page.locator('textarea').first() + await input.fill('/feedback the second remark') + await input.press('Enter') + await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(2) + // Suffix semantics: the second release starts after the first feedback's + // handoff, so the drive prompt already shared must not repeat. + expect(uploads[1]).toContain('the second remark') + expect(uploads[1]).not.toContain(PROMPT) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a4e1adc07b..d5c5f16fee 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -319,12 +319,14 @@ export interface LaunchOptions { default: string } /** - * Mount the shipped telemetry row in FULL mode against this exporter URL - * instead of disabling it. Used to pin a real backend disclosure in - * assembled coverage; point the URL at a local dead endpoint so no record - * leaves the process. + * Mount the shipped telemetry row against this exporter URL instead of + * disabling it. Used to pin a real backend disclosure in assembled + * coverage; point the URL at a local endpoint (a dead port, or a scenario's + * own mock collector) so no record leaves the machine. */ telemetryUrl?: string + /** Uploading mode for the mounted telemetry row. Defaults to `FULL`. */ + telemetryMode?: 'FULL' | 'FEEDBACK_ONLY' /** * Browse through a trusted non-loopback hostname that the browser resolves * to loopback (for example `*.localhost`). The test server stays bound to @@ -487,7 +489,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { const test = await harness('feedback-only') await expect(run(test, ' gated sharing')).resolves.toEqual({ kind: 'success', - text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}. Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`, + text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared.`, }) expect(feedbackTexts(test.session)).toEqual(['gated sharing']) }) diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md new file mode 100644 index 0000000000..47326f6769 --- /dev/null +++ b/snapshots/web/feedback-release/ack.expected.md @@ -0,0 +1,46 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with the single word" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Reply with the single word LIGHTHOUSE and stop. {{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 reply with a single word. Let me comply.": + - img + - img + - text: Think The user wants me to reply with a single word. Let me comply. +- paragraph: LIGHTHOUSE +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."': + - img + - img + - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared." +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok diff --git a/snapshots/web/feedback-release/snapshot.yml b/snapshots/web/feedback-release/snapshot.yml new file mode 100644 index 0000000000..40ee5c4a20 --- /dev/null +++ b/snapshots/web/feedback-release/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: feedback-release +profile: web +composition: web-default +recording: live +session: + source: ../feedback-command/session.jsonl +header: + class: web-default diff --git a/tsconfig.host.json b/tsconfig.host.json index 109addfd96..6f0eb4019d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -68,6 +68,7 @@ "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/schedule-after.e2e.ts", "apps/web/tests/feedback-command.e2e.ts", + "apps/web/tests/feedback-release.e2e.ts", "apps/web/tests/goal-command-presentation.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", From 66f2938b6313058205eb57237c3581bca7a412f5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 10:14:20 +0800 Subject: [PATCH 03/38] fix(web): adopt authenticated scaffold URL and post-merge golden in feedback-release lane --- apps/web/tests/feedback-release.e2e.ts | 2 +- snapshots/web/feedback-release/ack.expected.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index 7ba2f881ce..256555944f 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -66,7 +66,7 @@ describe('web e2e: feedback-gated release under the shipped default mode', () => browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 47326f6769..18103c07f7 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img From e87a47692d01ca4a8c7793bd6321848602bfd175 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 13:12:53 +0800 Subject: [PATCH 04/38] ci: isolate the Windows pnpm setup destination per job The windows-* jobs keep a separate standalone pnpm executable under runner.temp/setup-pnpm-js. A previous job on the same self-hosted runner can leave a locked @reflink native module there, so the next job's pnpm/action-setup fails with EPERM during unlink before any test runs. Suffix the destination with run_id, run_attempt, and job so every job gets a fresh directory even when sequential jobs land on the same runner; apply the same to the python SDK exe build. Update the pnpm setup isolation note to record the Windows-specific destination. --- ...7-29-pnpm-setup-runner-isolation.i18n.yaml | 4 ++-- .../2026-07-29-pnpm-setup-runner-isolation.md | 2 +- ...26-07-29-pnpm-setup-runner-isolation.zh.md | 2 +- .../workflows/build-exe-for-python-sdk.yml | 2 +- .github/workflows/ci.yml | 8 +++---- scripts/ci-workflow.spec.ts | 21 ++++++++++++++++++- 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index 525c192406..68f963f3ea 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.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-07-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: c7c076f34dcd4b905a6bb54411538d6cf61bc1d0 -2026-07-29-pnpm-setup-runner-isolation.zh.md: 2dd866404a5a799fe33e8b9c70c17787dfed0c2a +2026-07-29-pnpm-setup-runner-isolation.md: 14a609c56eb45f706c7451d2659ab397c2886411 +2026-07-29-pnpm-setup-runner-isolation.zh.md: cdb13f9cfb7e2abf7d1563c39526ea2d9e1ca562 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index c7c076f34d..14a609c56e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -10,7 +10,7 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Decision -Every `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). +Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. The Windows native jobs use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). [The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and `ci-master.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index 2dd866404a..cdb13f9cfb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个 `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 +[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。Windows 原生作业在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 [工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 与 `ci-master.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 0a3dc200f2..0cb0c1789e 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -161,7 +161,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - name: Enable Windows Developer Mode (symlink support) if: runner.os == 'Windows' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20fa5fc99c..74d4955fa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,7 +430,7 @@ jobs: run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -470,7 +470,7 @@ jobs: run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -508,7 +508,7 @@ jobs: run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -554,7 +554,7 @@ jobs: run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 40a665d667..7c0b762bdc 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' const root = resolve(import.meta.dirname, '..') const runnerPrivatePnpmDestination = '${{ runner.temp }}/setup-pnpm' -const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js' +const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}' describe('CI workflow', () => { it('isolates every pnpm action setup destination per runner', () => { @@ -36,6 +36,25 @@ describe('CI workflow', () => { } }) + it('isolates the python SDK exe pnpm setup destination per job', () => { + const workflow: unknown = yaml.load(readFileSync(resolve(root, '.github/workflows/build-exe-for-python-sdk.yml'), 'utf8')) + if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError('build-exe-for-python-sdk.yml must define jobs') + const setups: Array<{ step: unknown }> = [] + for (const job of Object.values(workflow.jobs)) { + if (!isRecord(job) || !Array.isArray(job.steps)) continue + for (const step of job.steps) { + if (!isRecord(step) || typeof step.uses !== 'string' || !step.uses.startsWith('pnpm/action-setup@')) continue + setups.push({ step }) + } + } + expect(setups.length).toBeGreaterThan(0) + for (const { step } of setups) { + expect(step).toMatchObject({ + with: { dest: nativeWindowsPnpmDestination }, + }) + } + }) + it('keeps required Wine and split native Windows jobs with failover, plus a master-only standby', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml') From e9cb003e9e6c1b225c04abc2d72b930ec0aadffa Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 15:04:45 +0800 Subject: [PATCH 05/38] test: widen oxlint contract and built-bin spawn budgets Both suites spawn real subprocesses (oxlint probes; the dsh built bin) that cold-start slowly on the contended self-hosted Windows pool, so their 20-25s timeouts fire before the child finishes. Raise the oxlint contract case timeouts to 60s and the built-bin execa timeouts to 60s, matching the tool-ralph budget treatment. --- apps/cli/tests/built-bin.e2e.ts | 8 ++++---- scripts/oxlint-contract.spec.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ef175ac199..98008cf006 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -36,7 +36,7 @@ async function runBuiltBin( ) const result = await execa(process.execPath, [dshBin, ...args], { input: '', - timeout: 25_000, + timeout: 60_000, killSignal: 'SIGKILL', reject: false, env: childEnv, @@ -310,7 +310,7 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { cwd: fixture.home, input: '', reject: false, - timeout: 25_000, + timeout: 60_000, killSignal: 'SIGKILL', env: { DSH_HOME: fixture.home, @@ -423,7 +423,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], { cwd: home, reject: false, - timeout: 25_000, + timeout: 60_000, killSignal: 'SIGKILL', env: { ...process.env, @@ -484,7 +484,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = execa(process.execPath, [dshBin, '--profile', 'acp'], { cwd: home, reject: false, - timeout: 25_000, + timeout: 60_000, killSignal: 'SIGKILL', env: { ...process.env, diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 847d0a7593..24fbbd4af0 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -105,7 +105,7 @@ probePromise() rm(configPath, { force: true }), ]) } - }, 20_000) + }, 60_000) it('runs JavaScript compatibility and nursery rules', async () => { const suffix = randomUUID() @@ -152,7 +152,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }, 20_000) + }, 60_000) it('keeps the complete stylistic contract in Oxlint', async () => { const oxlintPath = join(repositoryRoot, '.oxlintrc.json') @@ -252,7 +252,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }, 20_000) + }, 60_000) it('accepts an ignored-only staged selection', () => { const result = runOxlint([ @@ -371,6 +371,6 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + await rm(directory, { recursive: true, force: true }) } }, - 20_000, + 60_000, ) }) From c20cfe77c60e9277ad55fc9db8e93d0b3dcf37a2 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 16:13:46 +0800 Subject: [PATCH 06/38] test: align built-bin spawn budget with its outer case budgets The execa timeout was widened to 60s but the outer vitest case budgets stayed at 30s, so a cold-starting built bin would trip the vitest budget first and the execa SIGKILL cleanup could not run inside it. Extract SPAWN_TIMEOUT_MS, share it across the execa deadline, its error text, waitForFile, and the outer case budgets (60s spawn + 30s headroom), so the widening is coherent. --- apps/cli/tests/built-bin.e2e.ts | 56 ++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 98008cf006..c133320ace 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -19,6 +19,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +// The dsh built bin cold-starts slowly on the contended self-hosted Windows pool; the +// execa deadline, its error text, the outer vitest case budget, and waitForFile all +// share this value so a widening cannot leave a stale 25s diagnostic behind. +const SPAWN_TIMEOUT_MS = 60_000 // The release version, including a prerelease such as 0.0.1-rc.1: `--version` // prints what this manifest carries, so no test may pin it to a literal. const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version @@ -36,7 +40,7 @@ async function runBuiltBin( ) const result = await execa(process.execPath, [dshBin, ...args], { input: '', - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', reject: false, env: childEnv, @@ -44,13 +48,13 @@ async function runBuiltBin( ...cwd === undefined ? {} : { cwd }, }) if (result.timedOut) { - throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + throw new Error(`dsh built bin did not exit within ${SPAWN_TIMEOUT_MS / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) } return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr } } async function waitForFile(file: string): Promise { - const deadline = Date.now() + 20_000 + const deadline = Date.now() + SPAWN_TIMEOUT_MS while (!existsSync(file)) { if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`) await new Promise(resolve => setTimeout(resolve, 20)) @@ -310,7 +314,7 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { cwd: fixture.home, input: '', reject: false, - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', env: { DSH_HOME: fixture.home, @@ -335,7 +339,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const result = await runBuiltBin(removed) expect(result.code).toBe(1) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('routes help and usage errors without activating startup-dependent rows', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) @@ -416,14 +420,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-')) const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], { cwd: home, reject: false, - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', env: { ...process.env, @@ -471,7 +475,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await child rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => { const apiKey = 'built-acp-profile-key' @@ -484,7 +488,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = execa(process.execPath, [dshBin, '--profile', 'acp'], { cwd: home, reject: false, - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', env: { ...process.env, @@ -555,7 +559,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await server.close() rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('runs the headless profile through its app-owned task positional', async () => { const apiKey = 'built-dsh-headless-key' @@ -583,7 +587,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await server.close() rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('does not load a project environment for --version', async () => { const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-')) @@ -606,7 +610,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('uses the launching endpoint and managed credential through the published entry', async () => { const apiKey = 'built-home-layer-key' @@ -646,7 +650,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', rmSync(home, { recursive: true, force: true }) rmSync(project, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('reports a patch-overlay boot failure without hanging', async () => { // The HMR main watcher's initial scan once refreshed the include @@ -666,7 +670,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() @@ -682,7 +686,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => { const fixture = createProfileLifecycleFixture() @@ -734,7 +738,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('hands the app arguments to the profile, which applies them before its rows start', async () => { const fixture = createStartupFixture() @@ -750,7 +754,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('starts a consumer on its composed value when the invocation carries no app arguments', async () => { const fixture = createStartupFixture() @@ -764,7 +768,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('keeps the app arguments across a user patch reload', async () => { // A live edit recomposes every row while the provider service remains @@ -798,7 +802,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it("prints the app's own help, starts none of its rows, and exits", async () => { const fixture = createStartupFixture() @@ -811,7 +815,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('anchors a relative add spec to the invoking directory, not the profile', async () => { // `dsh plugin --profile x add .` from a plugin checkout must install THAT @@ -829,7 +833,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], { cwd: checkout, input: '', - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', reject: false, env: { DSH_HOME: home }, @@ -897,7 +901,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) describe('config dump', () => { let home: string @@ -913,7 +917,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == @deepseek-ai/dsh-base') expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false) - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('prints the headless profile without Host or browser layers', async () => { const { stdout, code, stderr } = await runBuiltBin( @@ -926,7 +930,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/) expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/) - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('prints the exact standalone sdk-minimal tree without dsh-base', async () => { const { stdout, code, stderr } = await runBuiltBin( @@ -959,7 +963,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') expect(stdout).not.toContain('@deepseek-ai/dsh-base') expect(stdout).not.toContain('@deepseek-ai/dsh-web-app') - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. @@ -998,6 +1002,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // Both layers patched the row; the comment lists them in application order. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) }) }) From 43b5b473bf09bd1bf10195a72966a1fdfff18b4d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 16:16:37 +0800 Subject: [PATCH 07/38] ci: drop the stale pnpm setup cleanup steps The windows-* jobs now install pnpm under a run/attempt/job-suffixed destination, so the pre-install step that cleared the old fixed setup-pnpm-js path no longer touches the actual destination and its comment claims stale state. The suffix already gives every job a fresh directory, so remove the four cleanup steps. --- .github/workflows/ci.yml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74d4955fa5..7d9b3e5925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -421,13 +421,6 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort: a torn-down job on this self-hosted pool can leave a - # locked @reflink native module under the action's install destination, - # and pnpm/action-setup's self-installer then fails its unlink with - # EPERM. Clearing the destination gives every attempt fresh state. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -464,10 +457,6 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -502,10 +491,6 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -548,10 +533,6 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} From 84692044af90811536380a8be16c00d0e99624c0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 17:17:47 +0800 Subject: [PATCH 08/38] test: raise the contended Windows spawn budgets to 90s The per-case 15-30s budgets on the Windows native and coverage lanes fire before oxlint, workflow-worker-thread, and other subprocess-spawning cases finish under the loaded self-hosted pool; the failures rotate across cases as load shifts, so per-case widening only moved the flake. Raise the lane defaults (DSH_COVERAGE_TEST_TIMEOUT_MS and the native --testTimeout) to 90s, align the oxlint and workflow-worker-thread case budgets, and keep the built-bin SPAWN_TIMEOUT_MS at 60s under a 90s outer budget. --- .github/workflows/ci.yml | 4 ++-- .../tests/workflow-worker-thread.spec.ts | 18 +++++++++--------- scripts/ci-workflow.spec.ts | 2 +- scripts/oxlint-contract.spec.ts | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d9b3e5925..45d345b9a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -446,7 +446,7 @@ jobs: env: DSH_COVERAGE_MAX_WORKERS: '6' DSH_COVERAGE_PARTITIONS: '4' - DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' + DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' DSH_GATE_CONCURRENCY: '3' steps: - uses: actions/checkout@v6 @@ -505,7 +505,7 @@ jobs: run: >- pnpm exec vitest run --no-file-parallelism - --testTimeout 30000 + --testTimeout 90000 packages/shell/tool-pwsh/tests/loader.spec.ts packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts packages/workflow/tool-ralph/tests/integration.spec.ts diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index acd2702dab..7ed206e934 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -787,7 +787,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(result.error).toContain('raced the completion') expect(narration).toEqual(['started']) await handle.dispose() - }, 15_000) + }, 90_000) it('cancel() force-settles a script parked on a promise no hook owns, and TERMINATES its worker', async () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } }) @@ -1027,7 +1027,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(provider.runs[0]!.disposeCalls).toBe(1) await handle.dispose() await ctx.fiber.dispose() - }, 15_000) + }, 90_000) it('dispose() on a wedged worker host-drives child disposal inside the grace: it returns with the children DISPOSED, not with their teardown still in flight', async () => { const { ctx, parent, provider } = await setup({ @@ -1062,7 +1062,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(provider.runs[0]!.disposed).toBe(true) const result = await handle.result expect(result.stopReason).toBe('cancelled') - }, 15_000) + }, 90_000) it('a live child disposed by the dispose() drive is disposed ONCE, and the worker\'s late dispose RPC still gets its ack (the script settles, not the grace)', async () => { const { ctx, parent, provider } = await setup({ manual: true }) @@ -1127,7 +1127,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { // can finalize its state at run-end without dangling agents. expect(order.indexOf('run-end')).toBe(order.length - 1) await handle.dispose() - }, 15_000) + }, 90_000) it('graceful cancellation keeps pairing worker-authored: exactly one agent-end per start, nothing synthesized on top', async () => { const { ctx, parent, provider } = await setup({ manual: true }) @@ -1305,7 +1305,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { await Promise.resolve() expect(result.stopReason).toBe('error') await handle.dispose() - }, 15_000) + }, 90_000) it('an uncaught exception inside the worker surfaces as an error result and reaps the in-flight child', async () => { const { ctx, parent, provider } = await setup({ manual: true }) @@ -1331,7 +1331,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) await handle.dispose() - }, 15_000) + }, 90_000) it('a worker death pairs every stranded start: the synthesized cancelled agent-end precedes the error workflow/end', async () => { const { ctx, parent, provider } = await setup({ manual: true }) @@ -1366,7 +1366,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { ]) expect(order.indexOf('run-end')).toBe(order.length - 1) await handle.dispose() - }, 15_000) + }, 90_000) it('a dispose ack racing the worker death is dropped, not crashed (post after exit)', async () => { // Slow child disposal: the ack resolves only AFTER the worker died, so @@ -1395,7 +1395,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { // tight explicit bound (see the helper's doc comment). await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) await handle.dispose() - }, 15_000) + }, 90_000) it('a worker death AFTER a cancel reports cancelled, not error', async () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } }) @@ -1421,7 +1421,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('stop it') await handle.dispose() - }, process.platform === 'win32' ? 30_000 : 15_000) + }, process.platform === 'win32' ? 90_000 : 15_000) }) describe('service API', () => { diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 7c0b762bdc..f9c8269903 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -134,7 +134,7 @@ describe('CI workflow', () => { )) const nativeTestCommand = nativeTestCommands.map(step => step.run).join('\n') expect(nativeTestCommand).toContain('--no-file-parallelism') - expect(nativeTestCommand).toContain('--testTimeout 30000') + expect(nativeTestCommand).toContain('--testTimeout 90000') expect(nativeTestCommand).toContain('tool-pwsh/tests/loader.spec.ts') expect(nativeTestCommand).toContain('workflow-worker-thread.spec.ts') diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 24fbbd4af0..1141067ad2 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -105,7 +105,7 @@ probePromise() rm(configPath, { force: true }), ]) } - }, 60_000) + }, 90_000) it('runs JavaScript compatibility and nursery rules', async () => { const suffix = randomUUID() @@ -152,7 +152,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }, 60_000) + }, 90_000) it('keeps the complete stylistic contract in Oxlint', async () => { const oxlintPath = join(repositoryRoot, '.oxlintrc.json') @@ -252,7 +252,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }, 60_000) + }, 90_000) it('accepts an ignored-only staged selection', () => { const result = runOxlint([ @@ -371,6 +371,6 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + await rm(directory, { recursive: true, force: true }) } }, - 60_000, + 90_000, ) }) From b648ed75c9244135eae710274eff8002a7641c7c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 17:43:57 +0800 Subject: [PATCH 09/38] test: unify the last Windows spawn budgets to the 90s pattern The built-bin help/usage case still used a win32-conditional 60/30s outer budget while serializing six runBuiltBin calls, and startProfileLifecycle lacked the execa timeout/killSignal the sibling helper has; the tool-ralph cases pinned 20-30s explicit timeouts that the 90s lane default cannot override. Align all of them to the SPAWN_TIMEOUT_MS + 30s (or 90s) pattern. --- apps/cli/tests/built-bin.e2e.ts | 4 +++- packages/workflow/tool-ralph/tests/integration.spec.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c133320ace..2a688b5b00 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -150,6 +150,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], { cwd: fixture.home, input: '', + timeout: SPAWN_TIMEOUT_MS, + killSignal: 'SIGKILL', reject: false, env: { DSH_HOME: fixture.home, @@ -396,7 +398,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, process.platform === 'win32' ? 60_000 : 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('reports SDK startup failure when stdin reaches EOF first', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-')) diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index 124364e7b4..8b4d4e4e42 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -35,7 +35,7 @@ async function mountRalph(script: MockScript, config: toolRalph.Config) { } describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { - it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', { timeout: 30_000 }, async () => { + it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', { timeout: 90_000 }, async () => { const firstReport = { status: 'continue', summary: 'ROUND_ONE_HANDOFF', @@ -115,7 +115,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { await parentHandle.dispose() }) - it('reports the failed round and last good handoff when a child fails', { timeout: 30_000 }, async () => { + it('reports the failed round and last good handoff when a child fails', { timeout: 90_000 }, async () => { const firstReport = { status: 'continue', summary: 'ROUND_ONE_HANDOFF', @@ -235,7 +235,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { await parentHandle.dispose() }) - it('cancels the real worker and fresh child to quiescence', { timeout: 20_000 }, async () => { + it('cancels the real worker and fresh child to quiescence', { timeout: 90_000 }, async () => { const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 }) const children: Agent[] = [] const outcomes: string[] = [] From 6cbd3dda21a242835548b61be8a363caa4570737 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 18:20:43 +0800 Subject: [PATCH 10/38] test: give the multi-call built-bin cases a 210s outer budget The requires-profile and routes-help cases serialize 4-6 runBuiltBin calls, each with a 60s execa cap; under the loaded pool the 90s outer budget was exhausted before the last call and vitest truncated the run without the execa diagnostics. Raise both to SPAWN_TIMEOUT_MS * 3 + 30s. --- apps/cli/tests/built-bin.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 2a688b5b00..4515d70b2e 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -341,7 +341,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const result = await runBuiltBin(removed) expect(result.code).toBe(1) } - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 3 + 30_000) it('routes help and usage errors without activating startup-dependent rows', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) @@ -398,7 +398,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 3 + 30_000) it('reports SDK startup failure when stdin reaches EOF first', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-')) From 075cfc3b4d82995661450a23d82c4c4a457cf1b7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 21:52:44 +0800 Subject: [PATCH 11/38] test: give the dual-call built-bin cases a 150s outer budget The plugin add and dump-default-config cases serialize two runBuiltBin calls, each with a 60s execa cap; the 90s outer budget could be exhausted before the second call. Raise them to SPAWN_TIMEOUT_MS * 2 + 30s, matching the multi-call treatment. --- apps/cli/tests/built-bin.e2e.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 4515d70b2e..c34a9a0896 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -903,7 +903,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) describe('config dump', () => { let home: string @@ -965,7 +965,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') expect(stdout).not.toContain('@deepseek-ai/dsh-base') expect(stdout).not.toContain('@deepseek-ai/dsh-web-app') - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. @@ -1004,6 +1004,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // Both layers patched the row; the comment lists them in application order. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) }) }) From d77b64e7cfe364e7cc61c7a4ab969a3944119f2e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 22:42:55 +0800 Subject: [PATCH 12/38] test: derive the plugin add/remove budget and note the exe build The anchors-a-relative-add-spec case serializes two subprocesses (plugin add + remove) under a hardcoded 90s budget, which the 2x60s worst case exhausts; derive it from SPAWN_TIMEOUT_MS * 2 + 30s like the other dual-call cases. The pnpm setup isolation note now also records the python SDK exe build's suffixed destination and its regression-test coverage. --- .../bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml | 4 ++-- .../bug-fix/2026-07-29-pnpm-setup-runner-isolation.md | 4 ++-- .../bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md | 4 ++-- apps/cli/tests/built-bin.e2e.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index 68f963f3ea..d87d9b8af7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.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-07-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: 14a609c56eb45f706c7451d2659ab397c2886411 -2026-07-29-pnpm-setup-runner-isolation.zh.md: cdb13f9cfb7e2abf7d1563c39526ea2d9e1ca562 +2026-07-29-pnpm-setup-runner-isolation.md: 40a34460171777a1cf2a8d2fb940d74012113c1c +2026-07-29-pnpm-setup-runner-isolation.zh.md: 4c307bc00e980975d35a1a0c3e776d5fe371cec9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index 14a609c56e..40a3446017 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -10,9 +10,9 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Decision -Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. The Windows native jobs use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). +Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. The Windows native jobs and the [python SDK exe build](../../../../.github/workflows/build-exe-for-python-sdk.yml) use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). -[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and `ci-master.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. +[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml`, `ci-master.yml`, and `build-exe-for-python-sdk.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index cdb13f9cfb..4c307bc00e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。Windows 原生作业在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 +[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。Windows 原生作业与 [python SDK exe 构建](../../../../.github/workflows/build-exe-for-python-sdk.yml)在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 -[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 与 `ci-master.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 +[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml`、`ci-master.yml` 与 `build-exe-for-python-sdk.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 ## 曾考虑的替代方案 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c34a9a0896..63f58916f2 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -866,7 +866,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', rmSync(home, { recursive: true, force: true }) rmSync(checkout, { recursive: true, force: true }) } - }, 90_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) it('activates a dependency that gained dsh.bundle in a later update', async () => { // Reconcile runs against the INSTALLED state on every successful pnpm From 6107e10c2515584574f0b581fa60c59c1e470bd4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 11:32:23 +0800 Subject: [PATCH 13/38] test(web): refresh feedback release golden --- snapshots/web/feedback-release/ack.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 18103c07f7..4b108200bf 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -38,7 +38,7 @@ - img - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared." -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write From 2413eab8479ee68d0c6a994e9a5b833840fb34bd Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 23:31:33 +0800 Subject: [PATCH 14/38] ci: isolate non-Windows pnpm setup per run attempt Stacked on #3115: keep its windows-* setup-pnpm-js--- destination, and extend the same isolation to non-Windows jobs in ci.yml and ci-master.yml with setup-pnpm--. This prevents sequential self-hosted Windows jobs from tripping over a stale locked pnpm.exe/reflink native module. --- ...07-29-pnpm-setup-runner-isolation.i18n.yaml | 4 ++-- .../2026-07-29-pnpm-setup-runner-isolation.md | 2 +- ...026-07-29-pnpm-setup-runner-isolation.zh.md | 2 +- .github/workflows/ci-master.yml | 10 +++++----- .github/workflows/ci.yml | 10 +++++----- scripts/ci-workflow.spec.ts | 18 +++++++++--------- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index d87d9b8af7..01d1644420 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.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-07-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: 40a34460171777a1cf2a8d2fb940d74012113c1c -2026-07-29-pnpm-setup-runner-isolation.zh.md: 4c307bc00e980975d35a1a0c3e776d5fe371cec9 +2026-07-29-pnpm-setup-runner-isolation.md: d5cd02cceba920368f0dfe6535e4bd03ee075417 +2026-07-29-pnpm-setup-runner-isolation.zh.md: 5266112224b940c06ea2567247532eb15ce7fce8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index 40a3446017..d5cd02cceb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -10,7 +10,7 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Decision -Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. The Windows native jobs and the [python SDK exe build](../../../../.github/workflows/build-exe-for-python-sdk.yml) use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). +Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory, and the run/attempt suffix also protects sequential jobs on the same runner from a stale locked `pnpm.exe`. The Windows native jobs and the [python SDK exe build](../../../../.github/workflows/build-exe-for-python-sdk.yml) use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). [The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml`, `ci-master.yml`, and `build-exe-for-python-sdk.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index 4c307bc00e..5266112224 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。Windows 原生作业与 [python SDK exe 构建](../../../../.github/workflows/build-exe-for-python-sdk.yml)在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 +[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录;run/attempt 后缀还能防止同一 runner 上顺序作业因残留的锁定 `pnpm.exe` 而失败。Windows 原生作业与 [python SDK exe 构建](../../../../.github/workflows/build-exe-for-python-sdk.yml)在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 [工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml`、`ci-master.yml` 与 `build-exe-for-python-sdk.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index f324900c9e..b86720a5d3 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -87,7 +87,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -131,7 +131,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -176,7 +176,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -264,7 +264,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} # The benchmark's Windows lanes deliberately skip the store cache like # the independent native Windows job; an empty input disables caching. @@ -355,7 +355,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} # Unlike the larger-runner suite, both platforms cache the store here: # the consolidated topology measures cache mechanics as workload. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45d345b9a3..e7f4ea86e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -109,7 +109,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -173,7 +173,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -261,7 +261,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -329,7 +329,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 6f4fa8d90d..0a59f36ae7 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -4,7 +4,7 @@ import * as yaml from 'js-yaml' import { describe, expect, it } from 'vitest' const root = resolve(import.meta.dirname, '..') -const runnerPrivatePnpmDestination = '${{ runner.temp }}/setup-pnpm' +const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}$/ const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}' describe('CI workflow', () => { @@ -25,14 +25,14 @@ describe('CI workflow', () => { expect(setups.length).toBeGreaterThan(0) for (const { jobName, step } of setups) { - expect(step, `${jobName} must not share pnpm/action-setup's default destination`).toMatchObject({ - with: { - dest: jobName.startsWith('windows-') - ? nativeWindowsPnpmDestination - : runnerPrivatePnpmDestination, - }, - }) - if (jobName.startsWith('windows-')) expect(step).not.toMatchObject({ with: { standalone: true } }) + const stepDest = (step as { with?: { dest?: unknown } }).with?.dest + if (jobName.startsWith('windows-')) { + expect(stepDest, `${jobName} must use the native Windows pnpm destination`).toBe(nativeWindowsPnpmDestination) + expect(step).not.toMatchObject({ with: { standalone: true } }) + } else { + expect(typeof stepDest, `${jobName} must use a runner-and-run-private pnpm destination`).toBe('string') + expect(stepDest as string).toMatch(runnerPrivatePnpmDestination) + } } }) From f887a8f9076ea319af7d22644c7a103f8b83da2a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 24 Aug 2026 20:39:58 +0800 Subject: [PATCH 15/38] fix(web): move subagent model switch to Plugins --- ...8-model-selected-subagent-routes.i18n.yaml | 4 +- ...26-08-18-model-selected-subagent-routes.md | 2 +- ...08-18-model-selected-subagent-routes.zh.md | 2 +- .../models-settings/configured.expected.md | 4 - .../models-settings/declared-edit.expected.md | 4 - .../models-settings/declared.expected.md | 4 - .../models-settings/empty.expected.md | 4 - .../models.expected.md | 4 - .../dismissed.expected.md | 4 - .../plugin-config/section.expected.md | 4 + apps/web/tests/plugin-config.e2e.ts | 21 +++- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 4 +- docs/tool-catalog.zh.md | 4 +- packages/bundle/web-app/cordis.patch.yml | 2 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 - .../client/ui-settings-models/README.zh.md | 4 - .../src/client/ModelsSection.module.css | 81 ------------- .../src/client/ModelsSection.tsx | 13 --- .../src/client/SubagentModelSelectionCard.tsx | 88 -------------- .../ui-settings-models/src/client/locales.ts | 8 -- .../tests/components.client.spec.tsx | 67 ----------- .../tests/store.client.spec.ts | 10 +- .../ui-settings-plugins/README.i18n.yaml | 4 +- packages/client/ui-settings-plugins/README.md | 8 +- .../client/ui-settings-plugins/README.zh.md | 8 +- .../SubagentModelSelectionCard.module.css | 87 ++++++++++++++ .../src/client/SubagentModelSelectionCard.tsx | 47 ++++++++ .../ui-settings-plugins/src/client/index.ts | 21 +++- .../ui-settings-plugins/src/client/locales.ts | 12 ++ ...ubagent-model-selection-card-controller.ts | 108 ++++++++++++++++++ .../tests/apply.client.spec.ts | 4 +- .../tests/section.client.spec.tsx | 57 +++++++++ .../tests/stores.client.spec.ts | 83 ++++++++++++++ .../src/client/slot-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 2 +- 37 files changed, 474 insertions(+), 318 deletions(-) delete mode 100644 packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx create mode 100644 packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css create mode 100644 packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx create mode 100644 packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index 39403b4062..ee76abc897 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: bf4788b141370933197d9ec1a1ad3c8e76a6740c -2026-08-18-model-selected-subagent-routes.zh.md: 1d83e2e91ffe87fff7f8e9d1988320cb2bb8f2f7 +2026-08-18-model-selected-subagent-routes.md: c6e4ad70571d70b6a9a7d803b2b372e79599b184 +2026-08-18-model-selected-subagent-routes.zh.md: 6fac3f60e19ce824eb07ae2df83bdd05c7e75090 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index bf4788b141..c6e4ad7057 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -18,7 +18,7 @@ Provider and model form one route and must be supplied together. An effort may b An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. -An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. +An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. The Plugins settings page exposes that namespace as a direct switch. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index 1d83e2e91f..6fac3f60e1 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -18,7 +18,7 @@ Status: implemented 显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 -启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 +启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。插件设置页将该命名空间显示为直接开关。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index 6ac6d76796..3c3be0922c 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - status: 已保存 minimax-cn。 - list: - listitem: diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 2f7d4a3a30..1d538bcfe4 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index bb129bf2ea..df48328fd3 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index dfeb637b5f..54bf1db3c3 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list - text: 提供方 - combobox "提供方": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 020f70d095..a302932e65 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 73c66388f3..496443b057 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 54ac42b4a7..6c17e68db9 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -24,6 +24,10 @@ - tab "插件列表" - tabpanel "插件配置": - list: + - listitem "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - listitem: - 'button "展开设置: 终端"': - text: 终端 限制 agent 运行的每一条命令。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 0235f687de..ab8515d323 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -75,8 +75,10 @@ describe('web e2e: plugin configuration section', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards')) const dialog = await openPlugins() - // Every card the shipped web composition exposes: the shell executor, the - // agent loop, and the DeepSeek search provider. + // Every card the shipped web composition exposes: subagent selection, the + // shell executor, the agent loop, and the DeepSeek search provider. + await dialog.getByText('Subagent 自选模型', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByRole('switch', { name: '允许 subagent 自选模型' }).getAttribute('aria-checked')).toBe('false') await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) @@ -88,6 +90,21 @@ describe('web e2e: plugin configuration section', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('immediately persists the subagent model-selection preference', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection')) + const dialog = await openPlugins() + const toggle = dialog.getByRole('switch', { name: '允许 subagent 自选模型' }) + + await toggle.click() + + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') + await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) + .toBe(true) + expect(await settingsDocument()).toContain('enabled: true') + expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('stages an edit and writes it only when saved', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write')) const dialog = await openPlugins() diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 5b23decb3c..46e2eebdd3 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-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/tool-catalog.md -tool-catalog.md: 7b166243fc3f5ef2c1bacdddaf5ee44c5b155622 -tool-catalog.zh.md: b44a0de4968dbcd760db546037f35e844608c819 +tool-catalog.md: be8f503ed983e68e39da1a9401ffbe70a029968c +tool-catalog.zh.md: 16fd7de1235250d91dfa7df2304d1a66ba192b0d diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7b166243fc..be8f503ed9 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | @@ -1601,7 +1601,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. +The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index b44a0de496..16fd7de123 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -37,7 +37,7 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | @@ -1607,7 +1607,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 +注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 3f889dd8b4..d69e66753a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -42,7 +42,7 @@ # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: # Host-owned opt-in sampled when a new Web session receives its preset - # delegation tools. The Models page edits this settings namespace. + # delegation tools. The Plugins page edits this settings namespace. - id: subagent-model-selection-settings name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 35e7f7557a..9772bd7a42 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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-settings-models/README.md -README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da -README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f +README.md: 658b357992a1926f1f21e109c19b7c0975da58ea +README.zh.md: dcc19f80a15e6e7e81c3dea128a999b83e8311ba diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 6ed2bde147..658b357992 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -35,10 +35,6 @@ The primary field on an editor card is a single **API key** input — the page n The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits. -### Subagent model selection - -When the Host advertises the `subagent-model-selection` settings namespace, Models shows a localized switch above the provider rows. The switch defaults off and writes only `{ enabled }` through `settings.update` with the namespace revision. The Host samples the value when it composes a new top-level Session, so changing it does not reconfigure running Sessions. Child Sessions inherit their parent's recorded decision. - ### Adding and deleting providers The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks `llm.discoverModels` about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 3806244d4d..dcc19f80a1 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -35,10 +35,6 @@ kind: "package-reference" 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。 -### 子代理模型选择 - -当宿主提供 `subagent-model-selection` 设置 namespace 时,Models 会在提供方行上方显示一个本地化开关。该开关默认关闭,并通过 `settings.update` 携带 namespace revision、只写入 `{ enabled }`。宿主在组合新的顶层 Session 时读取此值,因此更改它不会重新配置正在运行的 Session。子 Session 会继承其父级已记录的决定。 - ### 新增与删除提供方 「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**就表单显示的端点询问 `llm.discoverModels`,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 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 1c767386dd..fe2fe87d3a 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -40,87 +40,6 @@ color: var(--dsw-alias-state-success-primary); } -.preferenceCard { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px 16px; - margin-top: 4px; - padding: 14px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; -} - -.preferenceCopy { - min-width: 0; -} - -.preferenceTitle { - margin: 0; - font-size: 14px; - line-height: 22px; - font-weight: 500; - color: var(--dsw-alias-label-primary); -} - -.preferenceDescription { - margin: 2px 0 0; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - -.switch { - box-sizing: border-box; - position: relative; - width: 36px; - height: 20px; - padding: 2px; - border: 0; - border-radius: 10px; - background: var(--dsw-alias-border-l3); - cursor: pointer; -} - -.switchOn { - background: var(--dsw-alias-brand-primary); -} - -.switch:disabled { - cursor: default; - opacity: 0.5; -} - -.switch:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); -} - -.switchThumb { - display: block; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--dsw-alias-label-primary-foreground); - transition: transform 120ms ease; -} - -.switchOn .switchThumb { - transform: translateX(16px); -} - -.preferenceStatus, -.preferenceCard > .error { - grid-column: 1 / -1; -} - -.preferenceStatus { - margin: 0; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-state-success-primary); -} - .rows { list-style: none; /* Extra air between the title/intro block and the first provider card. */ diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index b12fb9fc01..7b884ac906 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -23,7 +23,6 @@ import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './stor import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' -import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -309,24 +308,12 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS // one whose schema names the protocols one may speak; without it mounted // there is nothing to declare and the entry point stays disabled. const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema) - const subagentModelSelection = state.namespaces.get('subagent-model-selection') return (

{t('title')}

{t('intro')}

{!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} - {subagentModelSelection === undefined - ? null - : ( - - )} {savedIdentity === undefined ? null : ( diff --git a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx deleted file mode 100644 index 8f69f41087..0000000000 --- a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** User control for model-selectable subagent delegation in new sessions. */ - -import { useState } from 'react' -import type { ReactNode } from 'react' -import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { ModelsSettingsStore } from './store.ts' -import type { en } from './locales.ts' -import { messageOf } from './store.ts' -import styles from './ModelsSection.module.css' - -/** Props for the Host-owned subagent model-selection preference. */ -export interface SubagentModelSelectionCardProps { - /** Current redacted namespace view. */ - namespace: SettingsNamespaceView - /** Whether the settings provider accepts writes. */ - writable: boolean - /** Settings wire face. */ - api: SettingsWireFace - /** Models page controller to refresh after a commit. */ - controller: ModelsSettingsStore - /** Localized Models copy. */ - t: (key: keyof typeof en) => string -} - -/** Read the schema-validated resolved boolean from a namespace view. */ -function enabledOf(namespace: SettingsNamespaceView): boolean { - if (typeof namespace.value !== 'object' || namespace.value === null) return false - return (namespace.value as { enabled?: unknown }).enabled === true -} - -/** Render and persist the default-off new-session preference. */ -export function SubagentModelSelectionCard({ - namespace, - writable, - api, - controller, - t, -}: SubagentModelSelectionCardProps): ReactNode { - const [saving, setSaving] = useState(false) - const [saved, setSaved] = useState(false) - const [error, setError] = useState(undefined) - const enabled = enabledOf(namespace) - - const toggle = (): void => { - setSaving(true) - setSaved(false) - setError(undefined) - void api.settings.update( - namespace.ns, - { enabled: !enabled }, - namespace.revision, - ).then(async (response) => { - if (!response.ok) throw new Error(response.error.message) - controller.acceptNamespace(response.value) - await controller.load() - setSaved(true) - }).catch((reason: unknown) => { - setError(messageOf(reason)) - }).finally(() => { setSaving(false) }) - } - - return ( -
-
-

- {t('subagentModelSelectionTitle')} -

-

{t('subagentModelSelectionDescription')}

-
- - {saved - ?

{t('subagentModelSelectionSaved')}

- : null} - {error === undefined ? null :

{error}

} -
- ) -} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 176e33fe5e..f1b0718ba5 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -5,10 +5,6 @@ export const en = { nav: 'Models', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', - subagentModelSelectionTitle: 'Subagent model selection', - subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', - subagentModelSelectionToggle: 'Allow subagents to choose models', - subagentModelSelectionSaved: 'Saved. New sessions use this setting.', edit: 'Edit', editProvider: 'Edit {provider}', remove: 'Delete', @@ -113,10 +109,6 @@ export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', - subagentModelSelectionTitle: 'Subagent 自选模型', - subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', - subagentModelSelectionToggle: '允许 subagent 自选模型', - subagentModelSelectionSaved: '已保存,新会话将使用此设置。', edit: '编辑', editProvider: '编辑 {provider}', remove: '删除', diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 512a6573e5..da23418375 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -8,7 +8,6 @@ import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' -import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { @@ -328,72 +327,6 @@ describe('ModelsSection', () => { expect(screen.getByLabelText(en.keyInput)).toBeTruthy() expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false) }) - - it('persists the default-off subagent model-selection switch for new sessions', async () => { - const enabledNamespace: SettingsNamespaceView = { - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: { enabled: true }, - user: { enabled: true }, - revision: 5, - } - const update = vi.fn(() => Promise.resolve(remoteOk(enabledNamespace))) - await mountSection({ update }) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - fireEvent.click(toggle) - - await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) - expect(update).toHaveBeenCalledWith( - 'subagent-model-selection', - { enabled: true }, - 4, - ) - expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) - }) - - it('reports rejected subagent model-selection updates and permits a retry', async () => { - const update = vi.fn() - .mockResolvedValueOnce(remoteFail('revision changed', 'settings-rejected')) - .mockResolvedValueOnce(remoteOk({ - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: { enabled: true }, - revision: 5, - })) - await mountSection({ update }) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - fireEvent.click(toggle) - expect((await screen.findByRole('alert')).textContent).toBe('revision changed') - - fireEvent.click(toggle) - await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) - expect(screen.queryByRole('alert')).toBeNull() - }) - - it('keeps malformed and read-only subagent preferences off', () => { - const namespace = { - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: null, - } as unknown as SettingsNamespaceView - const mutate = vi.fn() - render( - , - ) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - expect((toggle as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(toggle) - expect(mutate).not.toHaveBeenCalled() - }) - it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { await mountFirstRun() // Nothing is reachable yet, and DeepSeek has no configured credential and 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 677ca14418..c9745db2b7 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -1,5 +1,5 @@ /** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -84,6 +84,14 @@ function api(overrides: { } describe('ModelsSettingsStore', () => { + it('forwards accepted writes into the shared settings mirror', () => { + const { face } = api() + const acceptView = vi.fn() + const store = new ModelsSettingsStore(face, settingsSchema, { acceptView } as never) + store.acceptNamespace(NAMESPACES[0]!) + expect(acceptView).toHaveBeenCalledWith(NAMESPACES[0]) + }) + it('joins rows with configured, removable, and credential state', async () => { const { face, mirror, seenRefs } = api() const store = new ModelsSettingsStore(face, settingsSchema, mirror) diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 150c835953..cd36155215 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/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-settings-plugins/README.md -README.md: 17ef6fda253ead749eba297519ad4b0647fc482e -README.zh.md: 9f48f673c6d145e0ff1e182d5a47a710a531ba72 +README.md: 8453bd4cb8928bbd88217716f9d505cafaf46b4f +README.zh.md: 6a9b716b843af804b1534f0fd0fa0fc8fb5686c1 diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 17ef6fda25..8453bd4cb8 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). +Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`). ### What appears here @@ -33,7 +33,9 @@ The tab reads which settings namespaces the Host serves and dispatches one slot ### Editing and saving -A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted — the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct. +A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A successful save collapses the card after the read-back confirms the writes; a failed save keeps the card open, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted. + +The Subagent card stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects. ### Secret-role fields @@ -55,7 +57,7 @@ The section declares `settings.plugins.tab`, a root list slot whose labels becom ### The write path -Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. +Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index 9f48f673c6..6a9b716b84 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 +打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 ### 这里会出现什么 @@ -33,7 +33,9 @@ kind: "package-reference" ### 编辑与保存 -卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算——卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。 +卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。保存成功后,卡片会在回读确认写入后收起;保存失败时,卡片保持展开、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算。 + +Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。 ### secret 角色字段 @@ -55,7 +57,7 @@ kind: "package-reference" ### 写入路径 -保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 +保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css new file mode 100644 index 0000000000..805ce60fb5 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -0,0 +1,87 @@ +/* Direct preference card inside the configurable Plugins list. */ + +.card { + list-style: none; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px 16px; + padding: 14px 16px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-alias-bg-layer-3); +} + +.copy { + min-width: 0; +} + +.title { + margin: 0; + font-size: 15px; + line-height: 1.4; + font-weight: 600; + color: var(--dsw-alias-label-primary); +} + +.description { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--dsw-alias-label-tertiary); +} + +.switch { + box-sizing: border-box; + position: relative; + width: 36px; + height: 20px; + padding: 2px; + border: 0; + border-radius: 10px; + background: var(--dsw-alias-border-l3); + cursor: pointer; +} + +.switchOn { + background: var(--dsw-alias-brand-primary); +} + +.switch:disabled { + cursor: default; + opacity: 0.5; +} + +.switch:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + +.thumb { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--dsw-alias-label-primary-foreground); + transition: transform 120ms ease; +} + +.switchOn .thumb { + transform: translateX(16px); +} + +.status, +.failed { + grid-column: 1 / -1; + margin: 0; + font-size: 12px; + line-height: 1.5; +} + +.status { + color: var(--dsw-alias-state-success-primary); +} + +.failed { + color: var(--dsw-alias-label-error); +} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx new file mode 100644 index 0000000000..b8858f4ae3 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -0,0 +1,47 @@ +/** User control for model-selectable subagent delegation in new sessions. */ + +import clsx from 'clsx' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SubagentModelSelectionCardFace } from './subagent-model-selection-card-controller.ts' +import type {} from './slot-contract.ts' +import css from './SubagentModelSelectionCard.module.css' + +/** Props the renderer binds for the subagent model-selection card. */ +export type SubagentModelSelectionCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.plugins'> + & InjectFace + +/** + * Render the default-off preference and persist each switch gesture. + * @param props - locale copy, the card snapshot, and its toggle action. + * @returns the preference card, or nothing when the namespace is unavailable. + */ +export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) { + const { t } = props + const state = props.useSubagentModelSelectionCard(snapshot => snapshot) + if (!state.available) return null + return ( +
  • +
    +

    + {t('subagentModelSelectionTitle')} +

    +

    {t('subagentModelSelectionDescription')}

    +
    + + {state.saved ?

    {t('subagentModelSelectionSaved')}

    : null} + {state.failed ?

    {t('subagentModelSelectionSaveFailed')}

    : null} +
  • + ) +} diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 11dc449b3e..4ded206103 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -4,7 +4,7 @@ * * The section declares `settings.plugins.tab`; its own `configurable` tab then * declares `settings.plugin.item` and renders whatever cards were registered - * into it. The three cards this package ships are the host-plane sections the + * into it. The cards this package ships are the host-plane sections the * deployment already exposes; each binds its namespace through the client * settings scope, which keeps them unaware of one another and of other tabs. */ @@ -25,10 +25,14 @@ import { BashCard } from './BashCard.tsx' import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from './PluginsSettingsSection.tsx' import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx' +import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import { WebSearchCard } from './WebSearchCard.tsx' import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' import { SHELL_NS, BashCardController } from './bash-card-controller.ts' import { ConfigurablePluginsTabController } from './tab-store.ts' +import { + SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController, +} from './subagent-model-selection-card-controller.ts' import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts' import { en, zh } from './locales.ts' @@ -44,6 +48,9 @@ export type { export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts' export type { BashCardFace, BashCardState } from './bash-card-controller.ts' export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts' +export type { + SubagentModelSelectionCardFace, SubagentModelSelectionCardState, +} from './subagent-model-selection-card-controller.ts' /** Dictionary namespace owned by this plugin. */ const NS = 'settings.plugins' @@ -63,6 +70,9 @@ export function apply(ctx: ClientContext): void { const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController( ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) + const subagentModelSelection = new SubagentModelSelectionCardController( + ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), + ) // The credential a card reports is not part of any settings section, so its // scope publishes nothing when one is written. This is the only signal that @@ -71,6 +81,7 @@ export function apply(ctx: ClientContext): void { () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }), 'ui-settings-plugins: credential invalidations', ) + ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') // The shared SettingsScope mirror updates after document commits and reconnects. const configurable = new ConfigurablePluginsTabController( @@ -130,7 +141,7 @@ export function apply(ctx: ClientContext): void { }, PluginsSettingsSection)) // The existing configuration page is one ordinary tab. It keeps ownership - // of the card slot and the three shipped card contributions below. + // of the card slot and the shipped card contributions below. ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ name: 'settings.plugins.tab', id: 'configurable', @@ -142,6 +153,12 @@ export function apply(ctx: ClientContext): void { }, ConfigurablePluginsTab)) ctx.slots.inject('settings.plugin.item', function* () { + yield ctx.slots.register({ + name: 'settings.plugin.item', + key: SUBAGENT_MODEL_SELECTION_NS, + locale: NS, + inject: () => subagentModelSelection.inject(), + }, SubagentModelSelectionCard) yield ctx.slots.register({ name: 'settings.plugin.item', key: SHELL_NS, diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 1478e39997..2debc256b3 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -11,6 +11,8 @@ export type PluginsSettingsLocaleKey = | 'webSearchTitle' | 'webSearchDescription' | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' + | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription' + | 'subagentModelSelectionToggle' | 'subagentModelSelectionSaved' | 'subagentModelSelectionSaveFailed' /** English copy. */ export const en: Record = { @@ -51,6 +53,11 @@ export const en: Record = { webSearchBaseUrlHint: 'Leave blank to use the provider default.', webSearchMaxUses: 'Max searches per request', webSearchMaxUsesHint: 'How many times one request may search before it must answer.', + subagentModelSelectionTitle: 'Subagent model selection', + subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', + subagentModelSelectionToggle: 'Allow subagents to choose models', + subagentModelSelectionSaved: 'Saved. New sessions use this setting.', + subagentModelSelectionSaveFailed: 'The setting could not be saved. Try again.', } /** Simplified Chinese copy. */ @@ -92,4 +99,9 @@ export const zh: Record = { webSearchBaseUrlHint: '留空则使用提供方默认地址。', webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', + subagentModelSelectionTitle: 'Subagent 自选模型', + subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', + subagentModelSelectionToggle: '允许 subagent 自选模型', + subagentModelSelectionSaved: '已保存,新会话将使用此设置。', + subagentModelSelectionSaveFailed: '设置保存失败,请重试。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts new file mode 100644 index 0000000000..c2ae07bbb8 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -0,0 +1,108 @@ +/** Direct preference controller for model-selectable subagent delegation. */ + +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' + +/** Namespace of the Host-owned subagent model-selection preference. */ +export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection' + +/** Settings fields stored for subagent model selection. */ +export interface SubagentModelSelectionSettings { + /** Whether new top-level Sessions may expose child model selection. */ + enabled?: boolean +} + +/** State rendered by the direct preference card. */ +export interface SubagentModelSelectionCardState { + /** Whether the Host serves this namespace. */ + available: boolean + /** Whether the settings document accepts writes. */ + writable: boolean + /** Effective preference; absent values resolve off. */ + enabled: boolean + /** Whether one switch write is crossing the wire. */ + saving: boolean + /** Whether the latest write landed. */ + saved: boolean + /** Whether the latest write settled without changing the Host value. */ + failed: boolean +} + +/** Registration-side face for the subagent model-selection card. */ +export interface SubagentModelSelectionCardFace { + hooks: { + /** Card snapshot bound by the renderer as useSubagentModelSelectionCard. */ + subagentModelSelectionCard: SnapshotStore + } + /** Flip and immediately persist the preference. */ + toggle: () => void +} + +/** Bridges the settings scope onto one immediate-save switch. */ +export class SubagentModelSelectionCardController { + private saving = false + private saved = false + private failed = false + private disposed = false + private generation = 0 + private readonly store: SnapshotStore + private readonly unsubscribe: () => void + + /** @param scope - the bound `subagent-model-selection` settings scope. */ + constructor(private readonly scope: SettingsScope) { + this.store = createSnapshotStore(this.projection()) + this.unsubscribe = scope.subscribe(() => { this.publish() }) + } + + /** Stop observing the settings scope. */ + dispose(): void { + this.disposed = true + this.generation += 1 + this.unsubscribe() + } + + /** + * Build the face injected into the card slot. + * @returns the card snapshot and its direct toggle action. + */ + inject(): SubagentModelSelectionCardFace { + return { + hooks: { subagentModelSelectionCard: this.store }, + toggle: () => { void this.toggle() }, + } + } + + private async toggle(): Promise { + const current = this.scope.getSnapshot() + if (this.disposed || current.status !== 'ready' || !current.writable || this.saving) return + const desired = current.value?.enabled !== true + const generation = this.generation + this.saving = true + this.saved = false + this.failed = false + this.publish() + await this.scope.set('enabled', desired) + if (generation !== this.generation) return + const landed = this.scope.getSnapshot().value?.enabled === desired + this.saving = false + this.saved = landed + this.failed = !landed + this.publish() + } + + private projection(): SubagentModelSelectionCardState { + const snapshot = this.scope.getSnapshot() + return { + available: snapshot.status === 'ready', + writable: snapshot.writable, + enabled: snapshot.value?.enabled === true, + saving: this.saving, + saved: this.saved, + failed: this.failed, + } + } + + private publish(): void { + this.store.set(this.projection()) + } +} 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 bba43edcbc..a764446096 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -117,7 +117,7 @@ describe('ui-settings-plugins apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)) - .toEqual(['shell', 'agent-loop', 'web-search-deepseek']) + .toEqual(['subagent-model-selection', 'shell', 'agent-loop', 'web-search-deepseek']) }) it('dispatches the served namespaces its cards claim, and no others', async () => { @@ -203,7 +203,7 @@ describe('ui-settings-plugins apply', () => { declareRoot(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(slots.entries('settings.plugin.item')).toHaveLength(3) + expect(slots.entries('settings.plugin.item')).toHaveLength(4) await fiber.dispose() diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index ad666e9093..13c69a945f 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -12,6 +12,8 @@ import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx' import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx' +import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' +import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx' import { WebSearchCard } from '../src/client/WebSearchCard.tsx' import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' @@ -19,6 +21,7 @@ import type { BashCardState } from '../src/client/bash-card-controller.ts' import type { CardFieldState, CardShell } from '../src/client/card-form.ts' import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts' import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' +import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -79,6 +82,26 @@ function renderBash(state: Partial = {}) { return actions } +function renderSubagentModelSelection(state: Partial = {}) { + const store = createSnapshotStore({ + available: true, + writable: true, + enabled: false, + saving: false, + saved: false, + failed: false, + ...state, + }) + const toggle = vi.fn() + const props = { + t, + toggle, + useSubagentModelSelectionCard: bindSnapshotSelector(store), + } as unknown as SubagentModelSelectionCardProps + render() + return toggle +} + describe('PluginsSettingsSection', () => { it('says so when no plugin contributed a tab', () => { renderSection([]) @@ -294,6 +317,40 @@ describe('BashCard', () => { }) }) +describe('SubagentModelSelectionCard', () => { + it('renders the default-off preference directly in the Plugins list', () => { + const toggle = renderSubagentModelSelection() + + const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(control.getAttribute('aria-checked')).toBe('false') + fireEvent.click(control) + + expect(toggle).toHaveBeenCalledOnce() + }) + + it('reports successful and rejected writes', () => { + renderSubagentModelSelection({ enabled: true, saved: true }) + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') + expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) + + cleanup() + renderSubagentModelSelection({ failed: true }) + expect(screen.getByRole('alert').textContent).toBe(en.subagentModelSelectionSaveFailed) + }) + + it('stays hidden when unavailable and disables writes when read-only', () => { + renderSubagentModelSelection({ available: false }) + expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull() + + cleanup() + const toggle = renderSubagentModelSelection({ writable: false }) + const control = screen.getByRole('switch') as HTMLButtonElement + expect(control.disabled).toBe(true) + fireEvent.click(control) + expect(toggle).not.toHaveBeenCalled() + }) +}) + describe('AgentLoopCard', () => { it('stages and saves the only field it owns', () => { const store = createSnapshotStore({ 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 505079be0c..a1554aa3c6 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -12,6 +12,9 @@ import { SettingsDescribeMirror, type SettingsMirrorSnapshot, } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' +import { + SubagentModelSelectionCardController, type SubagentModelSelectionSettings, +} from '../src/client/subagent-model-selection-card-controller.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' /** Make the stub behave like a Host that accepts every write. */ @@ -383,6 +386,86 @@ describe('AgentLoopCardController', () => { }) }) +describe('SubagentModelSelectionCardController', () => { + it('immediately writes a switch gesture and reports the accepted value', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const controller = new SubagentModelSelectionCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const face = controller.inject() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + face.toggle() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, + saving: false, + saved: true, + failed: false, + }) + }) + + it('keeps the Host value and reports a rejected write', async () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const face = controller.inject() + + face.toggle() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true) + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: false, + saving: false, + saved: false, + }) + }) + + it('ignores writes while read-only and scope notifications after disposal', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope) + host.publish({ status: 'ready', writable: false, value: { enabled: false }, user: {} }) + const face = controller.inject() + + face.toggle() + expect(host.set).not.toHaveBeenCalled() + + controller.dispose() + face.toggle() + host.publish({ value: { enabled: true } }) + expect(host.set).not.toHaveBeenCalled() + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + }) + + it('publishes no settlement after disposal interrupts an in-flight write', async () => { + const host = stubSettingsScope() + let settle = (): void => {} + const pending = new Promise((resolve) => { settle = () => { resolve() } }) + host.set.mockReturnValue(pending) + const controller = new SubagentModelSelectionCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const face = controller.inject() + + face.toggle() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true) + + controller.dispose() + settle() + await Promise.resolve() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: false, + saving: true, + saved: false, + failed: false, + }) + }) +}) + describe('WebSearchCardController', () => { it('reads the credential state for the reference the tab names', async () => { const host = stubSettingsScope() diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 9dc0c9f3fe..4540e2b743 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1715,6 +1715,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'settings.plugins.tab\' (client-ui-settings-plugins), so it exists while that entry is mounted', occupants: [ + 'client-ui-settings-plugins SubagentModelSelectionCard', 'client-ui-settings-plugins BashCard', 'client-ui-settings-plugins AgentLoopCard', 'client-ui-settings-plugins WebSearchCard', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3d69da2cc3..faa9422877 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -470,7 +470,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock', enableModelSelection: true }) }, note: - 'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', + 'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', From aefc083be7a02cb9dd97032a3a229c270909cb8a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 24 Aug 2026 21:49:40 +0800 Subject: [PATCH 16/38] feat(subagent): authorize selectable child models --- ...8-model-selected-subagent-routes.i18n.yaml | 4 +- ...26-08-18-model-selected-subagent-routes.md | 10 +- ...08-18-model-selected-subagent-routes.zh.md | 10 +- ...authorized-subagent-model-routes.i18n.yaml | 6 + ...4-user-authorized-subagent-model-routes.md | 42 +++ ...ser-authorized-subagent-model-routes.zh.md | 42 +++ .../plugin-config/section.expected.md | 8 +- apps/web/tests/plugin-config.e2e.ts | 14 +- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 11 +- docs/persistence-catalog.zh.md | 11 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 6 +- docs/subsystems/subagent.zh.md | 6 +- .../SubagentModelSelectionCard.module.css | 128 +++++--- .../src/client/SubagentModelSelectionCard.tsx | 95 ++++-- .../ui-settings-plugins/src/client/index.ts | 1 + .../ui-settings-plugins/src/client/locales.ts | 31 +- ...ubagent-model-selection-card-controller.ts | 277 +++++++++++++++--- .../tests/section.client.spec.tsx | 90 +++++- .../tests/stores.client.spec.ts | 227 +++++++++++--- .../core/session/src/known-event-types.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 37 ++- .../subagent/tool-subagent/src/invariant.ts | 6 +- .../subagent/tool-subagent/src/list-models.ts | 17 +- .../src/model-selection-settings.ts | 31 +- .../src/model-selection-state.ts | 32 +- .../tool-subagent/src/model-selection.ts | 72 +++++ .../tool-subagent/tests/list-models.spec.ts | 31 ++ .../tests/model-selection-settings.spec.ts | 85 ++++-- .../tests/model-selection.spec.ts | 49 ++++ scripts/gen-cordis-catalog.ts | 1 + 38 files changed, 1154 insertions(+), 258 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md create mode 100644 .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index ee76abc897..eed424a054 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: c6e4ad70571d70b6a9a7d803b2b372e79599b184 -2026-08-18-model-selected-subagent-routes.zh.md: 6fac3f60e19ce824eb07ae2df83bdd05c7e75090 +2026-08-18-model-selected-subagent-routes.md: 9c6d777a1dbed569340e4900559a2cee2a42ff82 +2026-08-18-model-selected-subagent-routes.zh.md: a6d665f9533b481edf2f91071f7561544744d202 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index c6e4ad7057..9c6d777a1d 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -12,13 +12,13 @@ The model also needs a bounded way to discover live providers and model-owned ef ## Decision -`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves an enabled Session decision, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. No route allowlist is required. Registered LLM provider routes are available for child selection; this tool does not add a second authorization policy over the deployment's LLM registry. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. +`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves a non-empty Session policy, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. Static enablement needs no route list and can select any route its adapter accepts. The shipped settings-controlled path uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md). Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. Provider and model form one route and must be supplied together. An effort may be supplied alone when configured, parent, or provider-owned route defaults provide the effective route. Static `provider.agentRouteDefaults`, when present, establish the provider/model baseline; `Config.agentOptions` and model arguments overlay it before route-aware effort clearing. Providers without static defaults use compatible fields from the parent Agent's latest logged request selection, with creation options supplying the fallback before its first request and retaining the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort only from the selected baseline; changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection. An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. -An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. The Plugins settings page exposes that namespace as a direct switch. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. +An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Static enablement exposes the live directory without another filter. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-empty Host-owned `subagent-model-selection.allowedModels` setting. The Plugins settings page stores exact provider/model routes from the adapter directory. A new top-level Session snapshots a non-empty policy as `subagent/model-selection-policy` before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. In settings-controlled Sessions, discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it. Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. @@ -28,7 +28,7 @@ The delegation definition is static across adapter registration and catalog chan ## Alternatives considered -**Keep a deployment-configured route allowlist.** Rejected because it duplicates the live LLM registry, requires configuration before the model can use an already registered route, and creates a second policy surface for clients to edit. Deployments that must restrict LLM access should control which provider routes they register. +**Require a deployment-configured route allowlist for static enablement.** Rejected because it duplicates the live LLM registry and requires configuration before a custom composition can use an already registered route. The shipped user-owned preference is a distinct authorization decision and is documented by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md). **Render the live adapter catalog in every delegation description.** Rejected because one provider can advertise hundreds of models, inflating every request, and catalog changes would rewrite an early cache-prefix definition. The on-demand directory keeps mutable data out of the fixed schema. @@ -48,8 +48,8 @@ The delegation definition is static across adapter registration and catalog chan ## Consequences -- An enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. -- The primary delegation-tool instance defaults selection off, exposes a Models-page opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable decision is enabled; its catalog rows do not restrict delegation. +- A statically enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. +- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy is non-empty; discovery and explicit selection are constrained to that policy. - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. - Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default. - Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index 6fac3f60e1..a6d665f953 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出已启用的 Session 决定,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,不要求配置路由允许列表。已注册的 LLM 提供方路由都可供子级选择;本工具不会在部署的 LLM 注册表之上增加第二套授权策略。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 +只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出非空 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。静态启用无需路由列表,并且可以选择适配器接受的任意路由。随附的 settings 控制路径使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 提供方与模型共同组成一条路由,必须一起提供。如果配置值、父级值或提供方持有的路由默认值能够提供生效路由,则可以只提供推理强度。静态的 `provider.agentRouteDefaults` 在存在时构成 provider/model 基线;`Config.agentOptions` 与模型参数会在路由相关强度清除之前覆盖它。没有静态默认值的提供方会使用父 Agent 最新记录请求中的兼容字段,首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。只有所选基线的路由不变时才会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。 显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 -启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。插件设置页将该命名空间显示为直接开关。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 +启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。静态启用会公开不带额外过滤的实时目录。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认空值的 Host 自有 `subagent-model-selection.allowedModels` 设置。Plugins 设置页从适配器目录保存精确 provider/model 路由。新的顶层 Session 会在任何模型请求之前,把非空策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。在 settings 控制的 Session 中,发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。 随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 @@ -28,7 +28,7 @@ Status: implemented ## 考虑过的替代方案 -**保留部署配置的路由允许列表。** 不采用,因为它重复实时 LLM 注册表,要求先配置才能让模型使用已经注册的路由,并为客户端增加第二套策略编辑界面。需要限制 LLM 访问的部署应控制所注册的提供方路由。 +**要求静态启用配置部署路由允许列表。** 不采用,因为它会重复实时 LLM 注册表,并要求自定义组合先配置才能使用已经注册的路由。随附的用户自有偏好属于另一项授权决定,由[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)记录。 **在每一份委派描述中渲染实时 adapter 目录。** 不采用,因为一个提供方可能公布数百个模型,从而扩大每次请求,而且目录变化会改写缓存前缀中的早期定义。按需目录让可变数据留在固定 schema 之外。 @@ -48,8 +48,8 @@ Status: implemented ## 结果 -- 启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 -- 主委派工具实例默认关闭选择,为新 Session 提供 Models 页面 opt-in,并且只在持久决定已启用的 Session 中注册 `list_subagent_models`;其目录条目不会限制委派。 +- 静态启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 +- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略非空的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 - adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml new file mode 100644 index 0000000000..b9966ef055 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md +2026-08-24-user-authorized-subagent-model-routes.md: 3a56e48b35bcd1b1801108022e85ec83ad98b437 +2026-08-24-user-authorized-subagent-model-routes.zh.md: dca788993f1eccb96a424d2bd8186749363488b3 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md new file mode 100644 index 0000000000..3a56e48b35 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -0,0 +1,42 @@ +# Agent Note: User-authorized subagent model routes + +Status: implemented + +English | [中文](2026-08-24-user-authorized-subagent-model-routes.zh.md) + +## Problem + +Registering an LLM adapter makes its routes reachable, but does not authorize an Agent to choose every reachable model for a child. A single enabled preference over the live adapter registry expands silently when another provider or model appears. The product needs an explicit, stable authorization decision without rendering a potentially large model directory into every parent request. + +## Decision + +The Host-owned `subagent-model-selection` settings section stores `allowedModels`, an array of exact `{ provider, model }` routes. An empty array disables model-facing child route selection. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage one or more exact routes, and replaces the whole array in one revision-fenced field write. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase stored authorization. + +A newly composed top-level Session snapshots a non-empty route list in `subagent/model-selection-policy` before its model-selectable definitions can reach a request. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions. + +The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. + +Static `enableModelSelection: true` remains an unrestricted deployment-owned mode for custom compositions. The shipped `modelSelectionSettings` path is user-authorized and default-off. The primary spawn tool uses that path; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse. + +## Alternatives considered + +**Render the allowed routes in the delegation description.** Rejected because a large or changing list would enlarge every request and invalidate an early prompt prefix. On-demand discovery keeps the fixed schema prefix-stable and logs directory content only when requested. + +**Filter only the settings UI or discovery result.** Rejected because a model can guess a route or retain one from an earlier transcript. Authorization is enforced in the executor that starts the child. + +**Store `enabled` and `allowedModels` as separate fields.** Rejected because two writes admit an enabled state with no completed authorization decision. A non-empty array is both the opt-in and its exact policy; an empty user-layer array can explicitly disable a deployment base list. + +**Store per-route reasoning-effort allowlists.** Rejected because the user decision concerns child models, while effort ids and compatibility belong to the exact adapter route. Every adapter-supported effort remains available after the route is authorized. + +**Read current settings on every discovery or delegation call.** Rejected because a settings edit would silently change a running Session's model-visible capabilities and execution authority. The durable Session snapshot keeps resume and child inheritance deterministic. + +## Consequences + +- New adapter registrations and newly advertised models do not expand user authorization. +- Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it. +- The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. +- Unit coverage pins settings validation, Session sampling and inheritance, discovery intersection, executor denial, stale UI candidates, staged whole-array writes, and rejected-write draft preservation. The assembled Web scenario pins the real settings document and Plugins card flow. + +## Related decisions + +The route arguments, adapter preflight, discovery tool, and fork cache restriction remain owned by [model-selected subagent routes](2026-08-18-model-selected-subagent-routes.md). diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md new file mode 100644 index 0000000000..dca788993f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -0,0 +1,42 @@ +# Agent Note: 用户授权的 subagent 模型路由 + +Status: implemented + +[English](2026-08-24-user-authorized-subagent-model-routes.md) | 中文 + +## Problem + +注册 LLM 适配器会使其路由可达,但不代表授权 Agent 为子级选择每一个可达模型。针对实时适配器注册表的单一启用偏好,会在另一个提供方或模型出现时静默扩大范围。产品需要一项显式且稳定的授权决定,同时避免把可能很大的模型目录渲染进父 Agent 的每次请求。 + +## Decision + +Host 自有的 `subagent-model-selection` 设置 section 保存 `allowedModels`,即由精确 `{ provider, model }` 路由组成的数组。空数组会关闭面向模型的子级路由选择。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存一条或多条精确路由,再用一次带 revision 限制的字段写入整体替换该数组。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权。 + +新组合的顶层 Session 会在模型可选定义进入请求之前,把非空路由列表快照记录为 `subagent/model-selection-policy`。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。 + +固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 + +静态 `enableModelSelection: true` 继续作为自定义组合中由部署方所有的无限制模式。随附的 `modelSelectionSettings` 路径由用户授权且默认关闭。主 spawn 工具使用该路径;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。 + +## Alternatives considered + +**在委派描述中渲染允许路由。** 不采用,因为很大或变化的列表会扩大每次请求,并使较早的提示词前缀失效。按需发现会保持固定 schema 的前缀稳定,且只在请求目录时记录其内容。 + +**只过滤设置 UI 或发现结果。** 不采用,因为模型可以猜测路由,或从较早的 transcript 中保留路由。授权由启动子级的执行器强制执行。 + +**把 `enabled` 与 `allowedModels` 存成两个字段。** 不采用,因为两次写入会产生已经启用但尚无完整授权决定的状态。非空数组同时表示 opt-in 与精确策略;用户层空数组可以显式关闭部署基础列表。 + +**保存每条路由的推理强度允许列表。** 不采用,因为用户决定针对子级模型,而强度 id 与兼容性属于精确适配器路由。路由获准后,仍可使用适配器支持的每种强度。 + +**每次发现或委派调用都读取当前设置。** 不采用,因为设置编辑会静默改变运行中 Session 的模型可见能力和执行权限。持久 Session 快照会让恢复与子级继承保持确定。 + +## Consequences + +- 新适配器注册和新公布模型不会扩大用户授权。 +- 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。 +- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。 +- 单元覆盖固定设置校验、Session 取样与继承、发现交集、执行器拒绝、UI 陈旧候选项、暂存后的整数组写入,以及写入被拒时保留草稿。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 + +## Related decisions + +路由参数、适配器预检、发现工具与 fork 缓存限制仍由[模型选择的 subagent 路由](2026-08-18-model-selected-subagent-routes.zh.md)负责。 diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 6c17e68db9..39cfa33d85 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -24,10 +24,10 @@ - tab "插件列表" - tabpanel "插件配置": - list: - - listitem "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" + - listitem: + - 'button "展开设置: Subagent 自选模型"': + - text: Subagent 自选模型 选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。 + - img - listitem: - 'button "展开设置: 终端"': - text: 终端 限制 agent 运行的每一条命令。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index ab8515d323..781034541b 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -78,7 +78,7 @@ describe('web e2e: plugin configuration section', () => { // Every card the shipped web composition exposes: subagent selection, the // shell executor, the agent loop, and the DeepSeek search provider. await dialog.getByText('Subagent 自选模型', { exact: true }).waitFor({ timeout: 10_000 }) - expect(await dialog.getByRole('switch', { name: '允许 subagent 自选模型' }).getAttribute('aria-checked')).toBe('false') + expect(await dialog.getByRole('button', { name: '展开设置: Subagent 自选模型' }).count()).toBe(1) await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) @@ -90,17 +90,25 @@ describe('web e2e: plugin configuration section', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('immediately persists the subagent model-selection preference', async () => { + it('persists selected adapter routes as the subagent model allowlist', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection')) const dialog = await openPlugins() + await dialog.getByText('Subagent 自选模型', { exact: true }).click() const toggle = dialog.getByRole('switch', { name: '允许 subagent 自选模型' }) await toggle.click() + const models = dialog.getByRole('group', { name: '允许的模型' }) + await models.waitFor({ timeout: 10_000 }) + const firstModel = models.getByRole('checkbox').first() + await firstModel.check() + await dialog.getByRole('button', { name: '保存', exact: true }).click() await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) .toBe(true) - expect(await settingsDocument()).toContain('enabled: true') + expect(await settingsDocument()).toContain('allowedModels:') + expect(await settingsDocument()).toContain('provider:') + expect(await settingsDocument()).toContain('model:') expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。') expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4cdad8558b..191f1c452c 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: 7f85b870bd9604ed983b0d8a251a3ee4511a52b7 +config-catalog.md: 6432f5d359027a30fd436c9becd6bb6b8c0abaf4 config-catalog.zh.md: 1ab6838e4cea77a7d98a2227aca6e8ac47d84fcd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7f85b870bd..6432f5d359 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2918,7 +2918,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 13c2a19a3c..6a61b25f17 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: b9839833308afbd0d561bc227a95b236a10238a1 -persistence-catalog.zh.md: 7b5fa938b23ea9112e370133bf7575c8d689806d +persistence-catalog.md: 6a48b9c674375c6b5fa8b296afb7658a9d508168 +persistence-catalog.zh.md: 367b1c1a11324cea057ff03d0c456d531edc0183 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b983983330..6a48b9c674 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -738,9 +738,9 @@ Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) - + -#### `subagent/model-selection-enabled` — log-only +#### `subagent/model-selection-policy` — log-only ```ts persistence-catalog /** @@ -749,10 +749,13 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ -'subagent/model-selection-enabled': Record +'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] +} ``` -Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 7b5fa938b2..367b1c1a11 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -740,9 +740,9 @@ export type SessionEvent = { 来源:[`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) - + -#### `subagent/model-selection-enabled` — log-only +#### `subagent/model-selection-policy` — 仅日志 ```ts persistence-catalog /** @@ -751,10 +751,13 @@ export type SessionEvent = { * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ -'subagent/model-selection-enabled': Record +'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] +} ``` -来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index f4414983b1..a9f6e2035a 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: f854711f1161ba1c533fdbc43d7d6c35681f2f7f -subagent.zh.md: 960d9b099d915fcf5b1e321ab74374664bb8e468 +subagent.md: 792e03aa8bc5b8533094b4fd678ef8b43383e043 +subagent.zh.md: 1b5af32dfc6405c1df09d38628332e8e6c8ae737 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index f854711f11..792e03aa8b 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -505,10 +505,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached route policy for the next eligible Agent publication. + * @returns exact allowed routes; an empty list disables model-facing selection. */ -currentEnabled(): boolean +currentAllowedModels(): AllowedModelRoute[] ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 960d9b099d..1b5af32dfc 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -509,10 +509,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached route policy for the next eligible Agent publication. + * @returns exact allowed routes; an empty list disables model-facing selection. */ -currentEnabled(): boolean +currentAllowedModels(): AllowedModelRoute[] ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index 805ce60fb5..075564224a 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -1,39 +1,16 @@ -/* Direct preference card inside the configurable Plugins list. */ - -.card { - list-style: none; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; +.toggleRow { + display: flex; align-items: center; - gap: 8px 16px; - padding: 14px 16px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsw-alias-bg-layer-3); -} - -.copy { - min-width: 0; -} - -.title { - margin: 0; - font-size: 15px; - line-height: 1.4; - font-weight: 600; - color: var(--dsw-alias-label-primary); -} - -.description { - margin: 4px 0 0; + justify-content: space-between; + gap: 16px; font-size: 13px; - line-height: 1.5; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-secondary); } .switch { box-sizing: border-box; position: relative; + flex: 0 0 auto; width: 36px; height: 20px; padding: 2px; @@ -70,18 +47,103 @@ transform: translateX(16px); } -.status, -.failed { - grid-column: 1 / -1; +.selection { + display: grid; + gap: 10px; +} + +.hint, +.notice, +.invalid, +.status { margin: 0; font-size: 12px; line-height: 1.5; } +.hint, +.notice { + color: var(--dsw-alias-label-tertiary); +} + +.invalid { + color: var(--dsw-alias-label-error); +} + .status { color: var(--dsw-alias-state-success-primary); } -.failed { +.catalogError { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; color: var(--dsw-alias-label-error); } + +.catalogError button { + border: 0; + padding: 0; + background: transparent; + color: var(--dsw-alias-brand-primary); + cursor: pointer; +} + +.models { + display: grid; + gap: 6px; + min-width: 0; + max-height: 280px; + margin: 0; + padding: 10px; + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; +} + +.models legend { + padding: 0 4px; + font-size: 12px; + color: var(--dsw-alias-label-secondary); +} + +.model { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px; + border-radius: 6px; + cursor: pointer; +} + +.model:hover { + background: var(--dsw-alias-bg-layer-4); +} + +.modelName, +.route { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelName { + font-size: 13px; + color: var(--dsw-alias-label-primary); +} + +.route { + margin-top: 2px; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); +} + +.unavailable { + font-size: 11px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index b8858f4ae3..0a8ce094fd 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SubagentModelSelectionCardFace } from './subagent-model-selection-card-controller.ts' import type {} from './slot-contract.ts' +import { PluginCard } from './PluginCard.tsx' import css from './SubagentModelSelectionCard.module.css' /** Props the renderer binds for the subagent model-selection card. */ @@ -13,35 +14,87 @@ export type SubagentModelSelectionCardProps = & InjectFace /** - * Render the default-off preference and persist each switch gesture. + * Render the default-off preference and its exact adapter-route choices. * @param props - locale copy, the card snapshot, and its toggle action. * @returns the preference card, or nothing when the namespace is unavailable. */ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) { const { t } = props const state = props.useSubagentModelSelectionCard(snapshot => snapshot) - if (!state.available) return null return ( -
  • -
    -

    - {t('subagentModelSelectionTitle')} -

    -

    {t('subagentModelSelectionDescription')}

    + +
    + {t('subagentModelSelectionToggle')} +
    - + {state.enabled + ? ( +
    +

    {t('subagentModelSelectionChoose')}

    + {state.catalogStatus === 'loading' + ?

    {t('subagentModelSelectionLoading')}

    + : null} + {state.catalogStatus === 'error' + ? ( +
    + {t('subagentModelSelectionLoadFailed')} + +
    + ) + : null} + {state.catalogFailures.length > 0 + ?

    {t('subagentModelSelectionPartial')}

    + : null} + {state.candidates.length > 0 + ? ( +
    + {t('subagentModelSelectionAllowed')} + {state.candidates.map(candidate => ( + + ))} +
    + ) + : state.catalogStatus === 'ready' + ?

    {t('subagentModelSelectionEmpty')}

    + : null} + {state.invalid ?

    {t('subagentModelSelectionRequired')}

    : null} +
    + ) + :

    {t('subagentModelSelectionOff')}

    } {state.saved ?

    {t('subagentModelSelectionSaved')}

    : null} - {state.failed ?

    {t('subagentModelSelectionSaveFailed')}

    : null} -
  • + ) } diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 4ded206103..6533d97fc0 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -72,6 +72,7 @@ export function apply(ctx: ClientContext): void { ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) const subagentModelSelection = new SubagentModelSelectionCardController( ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), + api, ) // The credential a card reports is not part of any settings section, so its diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 2debc256b3..24a3424a01 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -12,7 +12,10 @@ export type PluginsSettingsLocaleKey = | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription' - | 'subagentModelSelectionToggle' | 'subagentModelSelectionSaved' | 'subagentModelSelectionSaveFailed' + | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' + | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' + | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' | 'subagentModelSelectionEmpty' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff' | 'subagentModelSelectionSaved' /** English copy. */ export const en: Record = { @@ -54,10 +57,19 @@ export const en: Record = { webSearchMaxUses: 'Max searches per request', webSearchMaxUsesHint: 'How many times one request may search before it must answer.', subagentModelSelectionTitle: 'Subagent model selection', - subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', + subagentModelSelectionDescription: 'Choose which child models new sessions may select. Running sessions do not change.', subagentModelSelectionToggle: 'Allow subagents to choose models', + subagentModelSelectionChoose: 'Select at least one model. Only these adapter routes appear in subagent discovery.', + subagentModelSelectionAllowed: 'Allowed models', + subagentModelSelectionLoading: 'Loading adapter models…', + subagentModelSelectionLoadFailed: 'Adapter models could not be loaded.', + subagentModelSelectionRetry: 'Retry', + subagentModelSelectionPartial: 'Some providers could not list their models; stored choices remain removable.', + subagentModelSelectionUnavailable: 'Unavailable', + subagentModelSelectionEmpty: 'No adapter currently advertises a model.', + subagentModelSelectionRequired: 'Select at least one model before saving.', + subagentModelSelectionOff: 'New sessions inherit the configured or parent model without choosing another route.', subagentModelSelectionSaved: 'Saved. New sessions use this setting.', - subagentModelSelectionSaveFailed: 'The setting could not be saved. Try again.', } /** Simplified Chinese copy. */ @@ -100,8 +112,17 @@ export const zh: Record = { webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', subagentModelSelectionTitle: 'Subagent 自选模型', - subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', + subagentModelSelectionDescription: '选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。', subagentModelSelectionToggle: '允许 subagent 自选模型', + subagentModelSelectionChoose: '请至少选择一个模型。Subagent 发现工具只会列出这些 adapter 路由。', + subagentModelSelectionAllowed: '允许的模型', + subagentModelSelectionLoading: '正在加载 adapter 模型…', + subagentModelSelectionLoadFailed: '无法加载 adapter 模型。', + subagentModelSelectionRetry: '重试', + subagentModelSelectionPartial: '部分提供方无法列出模型;仍可移除已保存的选项。', + subagentModelSelectionUnavailable: '不可用', + subagentModelSelectionEmpty: '当前没有 adapter 公布模型。', + subagentModelSelectionRequired: '保存前请至少选择一个模型。', + subagentModelSelectionOff: '新会话会使用配置值或继承父 Agent 模型,不会自主选择其他路由。', subagentModelSelectionSaved: '已保存,新会话将使用此设置。', - subagentModelSelectionSaveFailed: '设置保存失败,请重试。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index c2ae07bbb8..d8cfb62f6c 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -1,31 +1,55 @@ -/** Direct preference controller for model-selectable subagent delegation. */ +/** Staged editor for the Host-owned subagent model allowlist. */ +import type { + IApiClient, + ModelCatalogFailure, + ModelProviderGroup, +} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { CardShell } from './card-form.ts' /** Namespace of the Host-owned subagent model-selection preference. */ export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection' -/** Settings fields stored for subagent model selection. */ -export interface SubagentModelSelectionSettings { - /** Whether new top-level Sessions may expose child model selection. */ - enabled?: boolean +/** One exact provider/model route stored as user authorization. */ +export interface AllowedSubagentModel { + provider: string + model: string } -/** State rendered by the direct preference card. */ -export interface SubagentModelSelectionCardState { - /** Whether the Host serves this namespace. */ +/** Settings fields stored for subagent model selection. */ +export interface SubagentModelSelectionSettings { + /** Exact child routes offered to newly composed top-level Sessions. */ + allowedModels?: AllowedSubagentModel[] +} + +/** One catalog row joined with a stored route that may no longer be advertised. */ +export interface SubagentModelCandidate extends AllowedSubagentModel { + /** Stable opaque identity used only for lookup. */ + key: string + /** Adapter-owned provider display name. */ + providerName: string + /** Adapter-owned model display name. */ + modelName: string + /** Whether the current adapter catalog advertises this exact route. */ available: boolean - /** Whether the settings document accepts writes. */ - writable: boolean - /** Effective preference; absent values resolve off. */ + /** Whether the current draft authorizes this route. */ + selected: boolean +} + +/** State rendered by the staged allowlist card. */ +export interface SubagentModelSelectionCardState extends CardShell { + /** Whether the draft enables model-facing child route selection. */ enabled: boolean - /** Whether one switch write is crossing the wire. */ - saving: boolean - /** Whether the latest write landed. */ + /** Live catalog joined with stored routes. */ + candidates: readonly SubagentModelCandidate[] + /** Adapter-directory request state. */ + catalogStatus: 'idle' | 'loading' | 'ready' | 'error' + /** Provider-local failures that did not block other candidates. */ + catalogFailures: readonly ModelCatalogFailure[] + /** Whether the latest save landed. */ saved: boolean - /** Whether the latest write settled without changing the Host value. */ - failed: boolean } /** Registration-side face for the subagent model-selection card. */ @@ -34,71 +58,248 @@ export interface SubagentModelSelectionCardFace { /** Card snapshot bound by the renderer as useSubagentModelSelectionCard. */ subagentModelSelectionCard: SnapshotStore } - /** Flip and immediately persist the preference. */ - toggle: () => void + /** Stage the enabled state; enabling also loads the adapter directory. */ + toggleEnabled: () => void + /** Stage one exact route as allowed or denied. */ + toggleModel: (key: string) => void + /** Retry the adapter directory. */ + retryCatalog: () => void + /** Persist the whole exact route list as one revision-fenced field write. */ + save: () => void + /** Drop the staged enabled state and route choices. */ + discard: () => void } -/** Bridges the settings scope onto one immediate-save switch. */ +/** + * Stable identity for one exact route; callers resolve it by lookup and never parse it. + * @param route - Provider/model route to identify. + * @returns Opaque key for lookup within the card. + */ +export function subagentModelKey(route: AllowedSubagentModel): string { + return `${route.provider}\0${route.model}` +} + +/** + * Join live adapter metadata with stored routes that remain removable after disappearance. + * @param groups - Current model directory grouped by provider. + * @param stored - Routes in the effective settings value. + * @param selected - Opaque route keys selected in the current draft. + * @returns Candidate rows for the card. + */ +export function subagentModelCandidates( + groups: readonly ModelProviderGroup[], + stored: readonly AllowedSubagentModel[], + selected: ReadonlySet, +): SubagentModelCandidate[] { + const storedByKey = new Map(stored.map(route => [subagentModelKey(route), route])) + const candidates = groups.flatMap(group => group.models.map((model): SubagentModelCandidate => { + const route = { provider: group.id, model: model.id } + const key = subagentModelKey(route) + storedByKey.delete(key) + return { + ...route, + key, + providerName: group.name, + modelName: model.name, + available: true, + selected: selected.has(key), + } + })) + for (const route of storedByKey.values()) { + const key = subagentModelKey(route) + candidates.push({ + ...route, + key, + providerName: route.provider, + modelName: route.model, + available: false, + selected: selected.has(key), + }) + } + return candidates +} + +function sameRoutes(left: readonly AllowedSubagentModel[], right: readonly AllowedSubagentModel[]): boolean { + if (left.length !== right.length) return false + const rightKeys = new Set(right.map(subagentModelKey)) + return left.every(route => rightKeys.has(subagentModelKey(route))) +} + +/** Bridges one settings scope and the live adapter directory onto a staged card. */ export class SubagentModelSelectionCardController { + private catalogGroups: readonly ModelProviderGroup[] = [] + private catalogFailures: readonly ModelCatalogFailure[] = [] + private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' + private draftEnabled: boolean | undefined + private draftSelected: Set | undefined private saving = false private saved = false private failed = false private disposed = false - private generation = 0 + private saveGeneration = 0 + private catalogGeneration = 0 private readonly store: SnapshotStore private readonly unsubscribe: () => void - /** @param scope - the bound `subagent-model-selection` settings scope. */ - constructor(private readonly scope: SettingsScope) { + /** + * @param scope - bound `subagent-model-selection` settings scope. + * @param api - Host LLM directory face. + */ + constructor( + private readonly scope: SettingsScope, + private readonly api: Pick, + ) { this.store = createSnapshotStore(this.projection()) - this.unsubscribe = scope.subscribe(() => { this.publish() }) + this.unsubscribe = scope.subscribe(() => { + if (this.currentRoutes().length > 0 && this.catalogStatus === 'idle') void this.loadCatalog() + this.publish() + }) } - /** Stop observing the settings scope. */ + /** Stop observing settings and suppress late directory/write settlements. */ dispose(): void { this.disposed = true - this.generation += 1 + this.saveGeneration += 1 + this.catalogGeneration += 1 this.unsubscribe() } /** - * Build the face injected into the card slot. - * @returns the card snapshot and its direct toggle action. + * Build the renderer face for this card. + * @returns The snapshot and staged card actions injected into the renderer. */ inject(): SubagentModelSelectionCardFace { return { hooks: { subagentModelSelectionCard: this.store }, - toggle: () => { void this.toggle() }, + toggleEnabled: () => { this.toggleEnabled() }, + toggleModel: (key) => { this.toggleModel(key) }, + retryCatalog: () => { void this.loadCatalog() }, + save: () => { void this.save() }, + discard: () => { this.discard() }, } } - private async toggle(): Promise { - const current = this.scope.getSnapshot() - if (this.disposed || current.status !== 'ready' || !current.writable || this.saving) return - const desired = current.value?.enabled !== true - const generation = this.generation + private currentRoutes(): AllowedSubagentModel[] { + return this.scope.getSnapshot().value?.allowedModels?.map(route => ({ ...route })) ?? [] + } + + private selected(): Set { + return this.draftSelected ?? new Set(this.currentRoutes().map(subagentModelKey)) + } + + private enabled(): boolean { + return this.draftEnabled ?? this.currentRoutes().length > 0 + } + + private beginDraft(): Set { + this.draftEnabled ??= this.currentRoutes().length > 0 + this.draftSelected ??= new Set(this.currentRoutes().map(subagentModelKey)) + return this.draftSelected + } + + private toggleEnabled(): void { + const snapshot = this.scope.getSnapshot() + if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving) return + this.beginDraft() + this.draftEnabled = !this.draftEnabled + this.saved = false + this.failed = false + if (this.draftEnabled && this.catalogStatus === 'idle') void this.loadCatalog() + this.publish() + } + + private toggleModel(key: string): void { + if (!this.enabled() || this.saving || !this.scope.getSnapshot().writable) return + if (!this.candidates().some(candidate => candidate.key === key)) return + const selected = this.beginDraft() + if (selected.has(key)) selected.delete(key) + else selected.add(key) + this.saved = false + this.failed = false + this.publish() + } + + private discard(): void { + if (this.saving) return + this.draftEnabled = undefined + this.draftSelected = undefined + this.saved = false + this.failed = false + this.publish() + } + + private candidates(): SubagentModelCandidate[] { + return subagentModelCandidates(this.catalogGroups, this.currentRoutes(), this.selected()) + } + + private desiredRoutes(): AllowedSubagentModel[] { + if (!this.enabled()) return [] + return this.candidates() + .filter(candidate => candidate.selected) + .map(({ provider, model }) => ({ provider, model })) + } + + private async save(): Promise { + const snapshot = this.scope.getSnapshot() + const desired = this.desiredRoutes() + if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving + || sameRoutes(this.currentRoutes(), desired) || (this.enabled() && desired.length === 0)) return + const generation = this.saveGeneration this.saving = true this.saved = false this.failed = false this.publish() - await this.scope.set('enabled', desired) - if (generation !== this.generation) return - const landed = this.scope.getSnapshot().value?.enabled === desired + await this.scope.set('allowedModels', desired) + if (generation !== this.saveGeneration) return + const landed = sameRoutes(this.currentRoutes(), desired) this.saving = false this.saved = landed this.failed = !landed + if (landed) { + this.draftEnabled = undefined + this.draftSelected = undefined + } + this.publish() + } + + private async loadCatalog(): Promise { + if (this.disposed || this.catalogStatus === 'loading') return + const generation = this.catalogGeneration + this.catalogStatus = 'loading' + this.catalogGroups = [] + this.catalogFailures = [] + this.publish() + try { + const response = await this.api.llm.models({}) + if (generation !== this.catalogGeneration) return + if (!response.result.ok) throw new Error(response.result.error.message) + this.catalogGroups = response.result.value.groups + this.catalogFailures = response.result.value.failures + this.catalogStatus = 'ready' + } catch { + if (generation !== this.catalogGeneration) return + this.catalogStatus = 'error' + } this.publish() } private projection(): SubagentModelSelectionCardState { const snapshot = this.scope.getSnapshot() + const current = this.currentRoutes() + const desired = this.desiredRoutes() + const enabled = this.enabled() return { available: snapshot.status === 'ready', writable: snapshot.writable, - enabled: snapshot.value?.enabled === true, + dirty: !sameRoutes(current, desired), + invalid: enabled && desired.length === 0, saving: this.saving, - saved: this.saved, failed: this.failed, + enabled, + candidates: this.candidates(), + catalogStatus: this.catalogStatus, + catalogFailures: this.catalogFailures, + saved: this.saved, } } diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 13c69a945f..46709d057f 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -84,22 +84,28 @@ function renderBash(state: Partial = {}) { function renderSubagentModelSelection(state: Partial = {}) { const store = createSnapshotStore({ - available: true, - writable: true, + ...settled, enabled: false, - saving: false, + candidates: [], + catalogStatus: 'idle', + catalogFailures: [], saved: false, - failed: false, ...state, }) - const toggle = vi.fn() + const actions = { + toggleEnabled: vi.fn(), + toggleModel: vi.fn(), + retryCatalog: vi.fn(), + save: vi.fn(), + discard: vi.fn(), + } const props = { + ...actions, t, - toggle, useSubagentModelSelectionCard: bindSnapshotSelector(store), } as unknown as SubagentModelSelectionCardProps render() - return toggle + return actions } describe('PluginsSettingsSection', () => { @@ -318,24 +324,75 @@ describe('BashCard', () => { }) describe('SubagentModelSelectionCard', () => { - it('renders the default-off preference directly in the Plugins list', () => { - const toggle = renderSubagentModelSelection() + it('renders the default-off preference in its staged plugin card', () => { + const actions = renderSubagentModelSelection() + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) expect(control.getAttribute('aria-checked')).toBe('false') fireEvent.click(control) - expect(toggle).toHaveBeenCalledOnce() + expect(actions.toggleEnabled).toHaveBeenCalledOnce() }) - it('reports successful and rejected writes', () => { - renderSubagentModelSelection({ enabled: true, saved: true }) + it('renders adapter candidates and reports a successful save', () => { + const actions = renderSubagentModelSelection({ + enabled: true, + saved: true, + candidates: [{ + key: 'alpha\0fast', + provider: 'alpha', + model: 'fast', + providerName: 'Alpha API', + modelName: 'Fast', + available: true, + selected: true, + }], + catalogStatus: 'ready', + }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) + fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') + }) + + it('renders directory progress, failures, unavailable routes, and validation', () => { + renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy() cleanup() - renderSubagentModelSelection({ failed: true }) - expect(screen.getByRole('alert').textContent).toBe(en.subagentModelSelectionSaveFailed) + const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry })) + expect(errorActions.retryCatalog).toHaveBeenCalledOnce() + + cleanup() + renderSubagentModelSelection({ + enabled: true, + catalogStatus: 'ready', + catalogFailures: [{ id: 'beta', name: 'Beta', message: 'offline' }], + candidates: [{ + key: 'legacy\0old', + provider: 'legacy', + model: 'old', + providerName: 'legacy', + modelName: 'old', + available: false, + selected: true, + }], + }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() + + cleanup() + renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() }) it('stays hidden when unavailable and disables writes when read-only', () => { @@ -343,11 +400,12 @@ describe('SubagentModelSelectionCard', () => { expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull() cleanup() - const toggle = renderSubagentModelSelection({ writable: false }) + const actions = renderSubagentModelSelection({ writable: false }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) const control = screen.getByRole('switch') as HTMLButtonElement expect(control.disabled).toBe(true) fireEvent.click(control) - expect(toggle).not.toHaveBeenCalled() + expect(actions.toggleEnabled).not.toHaveBeenCalled() }) }) 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 a1554aa3c6..414ce8dab9 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -13,7 +13,9 @@ import { } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' import { - SubagentModelSelectionCardController, type SubagentModelSelectionSettings, + SubagentModelSelectionCardController, + subagentModelCandidates, + type SubagentModelSelectionSettings, } from '../src/client/subagent-model-selection-card-controller.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' @@ -40,6 +42,34 @@ function credentialsApi(configured: boolean) { return { api: { describe, set } as never, describe, set } } +function modelsApi(options: { + groups?: readonly { + id: string + name: string + models: readonly { id: string; name: string }[] + }[] + failures?: readonly { id: string; name: string; message: string }[] + error?: string +} = {}) { + const models = vi.fn(() => Promise.resolve({ + rpcId: 'm-1' as never, + result: options.error === undefined + ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } } + : { ok: false as const, error: { code: 'internal_error' as never, message: options.error } }, + })) + return { api: { llm: { models } } as never, models } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + return { promise, resolve, reject } +} + describe('CardForm', () => { function form() { const host = stubSettingsScope>() @@ -387,19 +417,49 @@ describe('AgentLoopCardController', () => { }) describe('SubagentModelSelectionCardController', () => { - it('immediately writes a switch gesture and reports the accepted value', async () => { + it('joins stored routes with the live catalog without dropping unavailable choices', () => { + const candidates = subagentModelCandidates( + [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + [{ provider: 'legacy', model: 'old' }], + new Set(['legacy\0old']), + ) + + expect(candidates).toEqual([ + { + key: 'alpha\0fast', provider: 'alpha', model: 'fast', providerName: 'Alpha API', + modelName: 'Fast', available: true, selected: false, + }, + { + key: 'legacy\0old', provider: 'legacy', model: 'old', providerName: 'legacy', + modelName: 'old', available: false, selected: true, + }, + ]) + }) + + it('loads adapter models and saves one exact route as a whole field', async () => { const host = stubSettingsScope() acceptWrites(host) - const controller = new SubagentModelSelectionCardController(host.scope) - host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) const face = controller.inject() expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) - face.toggle() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) }) + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + face.save() + await vi.waitFor(() => { + expect(host.set).toHaveBeenCalledWith('allowedModels', [{ provider: 'alpha', model: 'fast' }]) + }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ enabled: true, + dirty: false, saving: false, saved: true, failed: false, @@ -408,61 +468,154 @@ describe('SubagentModelSelectionCardController', () => { it('keeps the Host value and reports a rejected write', async () => { const host = stubSettingsScope() - const controller = new SubagentModelSelectionCardController(host.scope) - host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) const face = controller.inject() - face.toggle() + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + face.save() await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ - enabled: false, + enabled: true, + dirty: true, saving: false, saved: false, }) }) - it('ignores writes while read-only and scope notifications after disposal', () => { + it('loads stored routes, stages removal and disablement, and discards both', async () => { const host = stubSettingsScope() - const controller = new SubagentModelSelectionCardController(host.scope) - host.publish({ status: 'ready', writable: false, value: { enabled: false }, user: {} }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [{ id: 'beta', name: 'Beta', message: 'offline' }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, + value: { allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + }) const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) - face.toggle() - expect(host.set).not.toHaveBeenCalled() + face.toggleModel('missing') + expect(state().dirty).toBe(false) + face.toggleModel('alpha\0fast') + expect(state()).toMatchObject({ dirty: true, invalid: true }) + face.discard() + expect(state()).toMatchObject({ dirty: false, invalid: false, enabled: true }) - controller.dispose() - face.toggle() - host.publish({ value: { enabled: true } }) - expect(host.set).not.toHaveBeenCalled() - expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + face.toggleEnabled() + expect(state()).toMatchObject({ dirty: true, enabled: false }) + face.toggleEnabled() + expect(state()).toMatchObject({ dirty: false, enabled: true }) }) - it('publishes no settlement after disposal interrupts an in-flight write', async () => { + it('reports a directory error and retries it', async () => { const host = stubSettingsScope() - let settle = (): void => {} - const pending = new Promise((resolve) => { settle = () => { resolve() } }) - host.set.mockReturnValue(pending) - const controller = new SubagentModelSelectionCardController(host.scope) - host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const models = modelsApi({ error: 'offline' }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + + face.toggleEnabled() + await vi.waitFor(() => { expect(state().catalogStatus).toBe('error') }) + face.retryCatalog() + await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) }) + }) + + it('suppresses duplicate actions and late save settlements', async () => { + const host = stubSettingsScope() + const catalog = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const write = deferred() + const set = vi.fn(async (field: string, value: unknown) => { + await write.promise + host.publish({ value: { [field]: value } }) + }) + const controller = new SubagentModelSelectionCardController({ ...host.scope, set }, catalog.api) const face = controller.inject() - face.toggle() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) }) + face.save() + face.toggleModel('alpha\0fast') + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + face.save() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') }) + face.save() + face.toggleModel('alpha\0fast') + face.save() expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true) + face.toggleEnabled() + face.toggleModel('alpha\0fast') + face.save() + face.discard() + controller.dispose() + write.resolve(undefined) + await write.promise + expect(set).toHaveBeenCalledOnce() + }) + + it('suppresses duplicate directory loads and late resolve or reject settlements', async () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + + const pending = deferred() + const models = vi.fn(() => pending.promise) + const controller = new SubagentModelSelectionCardController(host.scope, { llm: { models } } as never) + const face = controller.inject() + face.toggleEnabled() + face.retryCatalog() + expect(models).toHaveBeenCalledOnce() + controller.dispose() + pending.reject(new Error('late failure')) + await pending.promise.catch(() => undefined) + + const pendingResolve = deferred() + const resolving = new SubagentModelSelectionCardController( + host.scope, + { llm: { models: () => pendingResolve.promise } } as never, + ) + const resolvingFace = resolving.inject() + resolvingFace.toggleEnabled() + resolving.dispose() + pendingResolve.resolve({ + rpcId: 'late' as never, + result: { ok: true, value: { groups: [], failures: [] } }, + } as never) + await pendingResolve.promise + }) + + it('ignores writes while read-only and scope notifications after disposal', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + host.publish({ status: 'ready', writable: false, value: { allowedModels: [] }, user: {} }) + const face = controller.inject() + + face.toggleEnabled() + face.toggleModel('alpha\0fast') + face.save() + expect(host.set).not.toHaveBeenCalled() controller.dispose() - settle() - await Promise.resolve() - - expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ - enabled: false, - saving: true, - saved: false, - failed: false, - }) + face.toggleEnabled() + face.retryCatalog() + face.save() + host.publish({ value: { allowedModels: [{ provider: 'alpha', model: 'fast' }] } }) + expect(host.set).not.toHaveBeenCalled() + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) }) }) diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index 3c2f004aab..f97c8d4846 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -49,7 +49,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'step/end', 'step/start', 'subagent/descriptor', - 'subagent/model-selection-enabled', + 'subagent/model-selection-policy', 'team/member', 'team/message/delivered', 'team/message/queued', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5412e025cd..abb2272981 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2086,10 +2086,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Singleton settings owner read by delegation tools when an Agent is published.', methods: [ { - signature: 'currentEnabled(): boolean', - description: 'Read the preference for the next eligible Agent publication.', + signature: 'currentAllowedModels(): AllowedModelRoute[]', + description: 'Read a detached route policy for the next eligible Agent publication.', parameters: [], - returns: 'whether that Agent should receive model-selectable delegation.', + returns: 'exact allowed routes; an empty list disables model-facing selection.', }, ], }, @@ -3362,6 +3362,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\';', }, + { + name: 'AllowedModelRoute', + declaration: 'export interface AllowedModelRoute {\n readonly provider: string;\n readonly model: string;\n}', + }, { name: 'ApiKeyRecord', declaration: 'export interface ApiKeyRecord {\n readonly kind: \'api-key\';\n readonly key?: string;\n readonly env?: Readonly>;\n}', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 603b9f6679..66f31e12f5 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: 5c3f7ae095c07ae579dadf103419b49dbf4794d4 -README.zh.md: e5276c913ae78fcb05f7d23162a943135c5d1666 +README.md: 72874e94b792ef3e75e4fa0d6ed7475808f209a0 +README.zh.md: 1cab6922b93c0fd8776158f1659927d0c4aa4126 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5c3f7ae095..72874e94b7 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -146,7 +146,7 @@ Prefix-stable while provider instances and their configuration are unchanged. Ad #### What the model sees -An instance with static `enableModelSelection: true`, or a settings-controlled instance whose Session decision is enabled, exposes child LLM selection fields and `list_subagent_models`. With no arguments the discovery tool returns registered provider ids and names; with `provider` it returns advertised models; with `provider` and `model` it resolves that model and returns its advertised reasoning efforts and default. Calls reject while the optional `ctx.llm` service is unavailable. The result is read-only runtime metadata, not an authorization list. +An instance with static `enableModelSelection: true`, or a settings-controlled instance whose Session policy is non-empty, exposes the child LLM selection fields and `list_subagent_models`. Calls reject while the optional `ctx.llm` service is unavailable. Static enablement returns the live adapter directory. A settings-controlled instance returns only registered providers and advertised models in its exact route policy; an exact lookup must also be allowed before it resolves the model's reasoning efforts and default. Execution independently enforces the same policy. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index e5276c913a..1cab6922b9 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -146,7 +146,7 @@ kind: "package-reference" #### 模型看到什么 -静态设置 `enableModelSelection: true` 的实例,或其 Session 决定为启用的设置控制实例,会公开子级 LLM 选择字段与 `list_subagent_models`。不带参数时,发现工具返回已注册提供方的 id 与名称;带 `provider` 时返回其公布模型;同时带 `provider` 与 `model` 时解析该模型,并返回其公布的推理等级与默认值。可选的 `ctx.llm` 服务不可用时,调用会失败。结果是只读运行时元数据,不是授权清单。 +静态配置 `enableModelSelection: true` 的实例,或 Session 策略非空的 settings 控制实例,会公开子级 LLM 选择字段与 `list_subagent_models`。可选 `ctx.llm` 服务不可用时,调用会失败。静态启用返回实时适配器目录。settings 控制实例只返回其精确路由策略中的已注册提供方与已公布模型;精确查询也必须先获准,才会解析模型的推理强度与默认值。执行阶段会独立强制同一策略。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f4737cbb3b..5304d0bcc7 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -25,17 +25,18 @@ import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai import type { JobOutcome } from '@deepseek-ai/dsh-jobs' import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { + assertAllowedModelSelection, hasConfiguredLlmSelection, hasDelegationModelRequest, preflightChildLlmRoute, requestedAgentOptions, } from './model-selection.ts' -import type { DelegationModelRequest } from './model-selection.ts' +import type { DelegationModelRequest, ModelSelectionPolicy } from './model-selection.ts' import { registerListSubagentModels } from './list-models.ts' import type {} from './model-selection-settings.ts' import { - hasSubagentModelSelection, recordSubagentModelSelection, + subagentModelSelectionPolicy, } from './model-selection-state.ts' export const name = 'tool-subagent' @@ -357,8 +358,9 @@ export function apply(ctx: Context, config: Config): void { const initialProvider = ctx.subagents.getProvider(config.provider) if (initialProvider !== undefined) assertSubagentProviderConfiguration(initialProvider) - const install = (runtimeCtx: Context, modelSelectionEnabled: boolean): void => { - if (modelSelectionEnabled) registerListSubagentModels(runtimeCtx) + const install = (runtimeCtx: Context, modelSelectionPolicy: ModelSelectionPolicy | undefined): void => { + const modelSelectionEnabled = modelSelectionPolicy !== undefined + if (modelSelectionPolicy !== undefined) registerListSubagentModels(runtimeCtx, modelSelectionPolicy) // Load order and HMR replacement can change provider availability while // this fiber remains active. let mounted: { subagentProvider: SubagentProvider; disposeTool: () => void } | undefined @@ -487,6 +489,12 @@ export function apply(ctx: Context, config: Config): void { modelRequest, modelSelectionEnabled, ) + assertAllowedModelSelection( + modelSelectionPolicy, + parentOptions, + requestedChildAgentOptions, + modelRequest, + ) if (requiresRoutePreflight) { const llm = runtimeCtx.get('llm') if (llm === undefined) { @@ -599,7 +607,7 @@ export function apply(ctx: Context, config: Config): void { } if (config.modelSelectionSettings !== true) { - install(ctx, config.enableModelSelection === true) + install(ctx, config.enableModelSelection === true ? { kind: 'unrestricted' } : undefined) return } @@ -615,21 +623,22 @@ export function apply(ctx: Context, config: Config): void { throw new Error('tool-subagent: `modelSelectionSettings` requires an Agent or preset scope') } - const selectForAgent = (agent: NonNullable): boolean => { - let enabled = hasSubagentModelSelection(agent.session) - if (!enabled) { + const selectForAgent = (agent: NonNullable): ModelSelectionPolicy | undefined => { + let allowedModels = subagentModelSelectionPolicy(agent.session) + if (allowedModels === undefined) { const parentId = agent.session.header.origin === 'subagent' ? agent.session.header.parentSession : undefined if (parentId !== undefined) { const parent = ctx.get('agents')?.get(parentId) - enabled = parent !== undefined && hasSubagentModelSelection(parent.session) + allowedModels = parent === undefined ? undefined : subagentModelSelectionPolicy(parent.session) } else if (agent.session.firstLiveSeq === 0) { - enabled = settings.currentEnabled() + const current = settings.currentAllowedModels() + allowedModels = current.length === 0 ? undefined : current } } - if (enabled) recordSubagentModelSelection(agent.session) - return enabled + if (allowedModels !== undefined) recordSubagentModelSelection(agent.session, allowedModels) + return allowedModels === undefined ? undefined : { kind: 'allowlist', routes: allowedModels } } const agent = ctx.agent @@ -649,9 +658,9 @@ export function apply(ctx: Context, config: Config): void { // Reserve before the injected fiber runs: tool registration emits // `tools/change` synchronously, which re-enters the reconciliation below. installing.add(candidate) - const enabled = selectForAgent(candidate) + const policy = selectForAgent(candidate) const fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { - install(runtimeCtx, enabled) + install(runtimeCtx, policy) }) installing.delete(candidate) scopedInstalls.set(candidate, fiber) diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 84bd209caa..9207e01e4f 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -6,7 +6,7 @@ /* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { hasSubagentModelSelection } from './model-selection-state.ts' +import { subagentModelSelectionPolicy } from './model-selection-state.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' @@ -18,7 +18,7 @@ export const inject = ['invariants'] /** Assert that a durable opt-in is represented by both model-facing definitions. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { ctx.on('agent/pre-step', async ({ agent }, next) => { - if (hasSubagentModelSelection(agent.session)) { + if (subagentModelSelectionPolicy(agent.session) !== undefined) { const schemas = ctx.tools.schemas(agent) const selectable = schemas.some((schema) => { const properties = (schema.parameters as { properties?: Record }).properties @@ -27,7 +27,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant && properties['reasoning_effort'] !== undefined }) if (!selectable || !schemas.some(schema => schema.name === 'list_subagent_models')) { - fail('a subagent/model-selection-enabled session must expose route fields and list_subagent_models') + fail('a subagent/model-selection-policy session must expose route fields and list_subagent_models') } } return next() diff --git a/packages/subagent/tool-subagent/src/list-models.ts b/packages/subagent/tool-subagent/src/list-models.ts index 9e1ff5c24e..61158ca103 100644 --- a/packages/subagent/tool-subagent/src/list-models.ts +++ b/packages/subagent/tool-subagent/src/list-models.ts @@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import type LlmRuntime from '@deepseek-ai/dsh-llm' import type { LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ModelSelectionPolicy } from './model-selection.ts' interface ListSubagentModelsRequest { readonly provider?: string @@ -27,6 +28,7 @@ function modelLine(provider: string, model: { id: string; name: string; descript /** Read the requested provider, advertised models, or exact-model efforts. */ async function listSubagentModels( ctx: Context, + policy: ModelSelectionPolicy, request: ListSubagentModelsRequest, signal: AbortSignal, ): Promise { @@ -38,7 +40,8 @@ async function listSubagentModels( throw new Error('`model` requires `provider`') } if (request.provider === undefined) { - const providers = llm.listProviders() + const providers = llm.listProviders().filter(provider => policy.kind === 'unrestricted' + || policy.routes.some(route => route.provider === provider.id)) return providers.length === 0 ? '(no LLM providers)' : providers.map(provider => `${provider.id} — ${provider.name}`).join('\n') @@ -46,12 +49,17 @@ async function listSubagentModels( if (request.provider.length === 0) throw new Error('`provider` must be non-empty') const provider = registeredProvider(llm, request.provider) if (request.model === undefined) { - const models = await llm.listModels(provider.id) + const models = (await llm.listModels(provider.id)).filter(model => policy.kind === 'unrestricted' + || policy.routes.some(route => route.provider === provider.id && route.model === model.id)) return models.length === 0 ? `(no advertised models for ${provider.id})` : models.map(model => modelLine(provider.id, model)).join('\n') } if (request.model.length === 0) throw new Error('`model` must be non-empty') + if (policy.kind === 'allowlist' + && !policy.routes.some(route => route.provider === provider.id && route.model === request.model)) { + throw new Error(`child LLM route "${provider.id}/${request.model}" is not allowed for this Session`) + } const model = await llm.resolveModelInfo(provider.id, request.model, signal) const efforts = model.reasoning?.efforts.map(effort => ( `${effort.id}${model.reasoning?.defaultEffort === effort.id ? ' (default)' : ''} — ${effort.name}` @@ -63,8 +71,9 @@ async function listSubagentModels( /** * Register `list_subagent_models` for one owning delegation-tool instance. * @param ctx - Context whose tool registry owns the fixed discovery definition. + * @param policy - Route policy captured for this Session. */ -export function registerListSubagentModels(ctx: Context): void { +export function registerListSubagentModels(ctx: Context, policy: ModelSelectionPolicy): void { ctx.tools.register(defineTool({ name: 'list_subagent_models', description: @@ -88,7 +97,7 @@ export function registerListSubagentModels(ctx: Context): void { render: (_args, result) => [{ type: 'text', text: result }], }, execute(args, exec) { - return listSubagentModels(ctx, args, exec.signal) + return listSubagentModels(ctx, policy, args, exec.signal) }, })) } diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts index 113cd4c6f8..f12aae331c 100644 --- a/packages/subagent/tool-subagent/src/model-selection-settings.ts +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -3,6 +3,11 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + AllowedModelRouteSchema, + assertAllowedModelRoutes, + type AllowedModelRoute, +} from './model-selection.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -16,32 +21,35 @@ export const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = settingsNamespace('su /** Stored user preference; the shipped composition defaults it off. */ export interface SubagentModelSelectionSettings { - /** Whether new Agents may expose child LLM route selection to the model. */ - enabled: boolean + /** Exact child LLM routes offered to newly composed top-level Sessions. */ + allowedModels: AllowedModelRoute[] } /** Schema served to settings clients for the opt-in preference. */ export const SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA: z = z.object({ - enabled: z.boolean().default(false), + allowedModels: z.array(AllowedModelRouteSchema).default([]), }) /** Optional deployment base for the preference. */ export interface Config { - /** Initial value inherited when the user document does not override it. */ - enabled?: boolean + /** Initial route list inherited when the user document does not override it. */ + allowedModels?: AllowedModelRoute[] } /** Singleton settings owner read by delegation tools when an Agent is published. */ export class SubagentModelSelectionConfig extends Service { static Config: z = z.object({ - enabled: z.boolean().default(false), + allowedModels: z.array(AllowedModelRouteSchema).default([]), }) private source: () => SubagentModelSelectionSettings constructor(ctx: Context, config: Config = {}) { super(ctx, 'subagentModelSelection') - const entry: SubagentModelSelectionSettings = { enabled: config.enabled === true } + // Cordis supplies the schema default; the fallback also covers direct construction. + /* v8 ignore next */ + const entry: SubagentModelSelectionSettings = { allowedModels: config.allowedModels ?? [] } + assertAllowedModelRoutes(entry.allowedModels) this.source = () => entry installSettingsSection( ctx, @@ -50,6 +58,7 @@ export class SubagentModelSelectionConfig extends Service { entry, { setSource: (source) => { this.source = source }, + validate: (value) => { assertAllowedModelRoutes(value.allowedModels) }, // Consumers sample at Agent publication, so a settings update never // rebuilds the tool definitions of an Agent that is already running. onChange: () => {}, @@ -58,11 +67,11 @@ export class SubagentModelSelectionConfig extends Service { } /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached route policy for the next eligible Agent publication. + * @returns exact allowed routes; an empty list disables model-facing selection. */ - currentEnabled(): boolean { - return this.source().enabled + currentAllowedModels(): AllowedModelRoute[] { + return this.source().allowedModels.map(route => ({ ...route })) } } diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index 35345ac115..b729d6d10b 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -1,6 +1,7 @@ /** Durable per-session state for the user-controlled model-selection opt-in. */ import type { Session } from '@deepseek-ai/dsh-session' +import { assertAllowedModelRoutes, type AllowedModelRoute } from './model-selection.ts' declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { @@ -10,24 +11,35 @@ declare module '@deepseek-ai/dsh-session/types' { * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ - 'subagent/model-selection-enabled': Record + 'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] + } } } /** - * Whether a session log records the enabled model-selection definition. + * Read the exact route list captured for a model-selectable definition. * @param session - session whose durable decision is read. - * @returns whether model-selectable delegation is enabled for the session. + * @returns a detached route list, or undefined for the fixed-route definition. */ -export function hasSubagentModelSelection(session: Session): boolean { - return session.events.some(event => event.type === 'subagent/model-selection-enabled') +export function subagentModelSelectionPolicy(session: Session): AllowedModelRoute[] | undefined { + const event = session.events.find(candidate => candidate.type === 'subagent/model-selection-policy') + if (event?.type !== 'subagent/model-selection-policy') return undefined + const routes = event.data.allowedModels.map(route => ({ ...route })) + assertAllowedModelRoutes(routes) + if (routes.length === 0) throw new Error('subagent/model-selection-policy requires at least one route') + return routes } /** - * Append the enabled decision once, before its definition can reach a model request. - * @param session - session receiving the enabled decision. + * Append the route policy once, before its definition can reach a model request. + * @param session - session receiving the model-selectable definition. + * @param allowedModels - exact routes the definition may select explicitly. */ -export function recordSubagentModelSelection(session: Session): void { - if (hasSubagentModelSelection(session)) return - session.append('subagent/model-selection-enabled', {}) +export function recordSubagentModelSelection(session: Session, allowedModels: readonly AllowedModelRoute[]): void { + if (subagentModelSelectionPolicy(session) !== undefined) return + session.append('subagent/model-selection-policy', { + allowedModels: allowedModels.map(route => ({ ...route })), + }) } diff --git a/packages/subagent/tool-subagent/src/model-selection.ts b/packages/subagent/tool-subagent/src/model-selection.ts index 906eb496d5..df8251690d 100644 --- a/packages/subagent/tool-subagent/src/model-selection.ts +++ b/packages/subagent/tool-subagent/src/model-selection.ts @@ -3,6 +3,53 @@ import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { LlmRuntime } from '@deepseek-ai/dsh-llm' import type { AgentOptions } from '@deepseek-ai/dsh-agent' +import z from '@deepseek-ai/schemastery' + +/** One exact child LLM route authorized by a user setting. */ +export interface AllowedModelRoute { + /** Registered LLM provider id. */ + readonly provider: string + /** Provider-owned exact model id. */ + readonly model: string +} + +/** Schema shared by the Host setting and its deployment base. */ +export const AllowedModelRouteSchema: z = z.object({ + provider: z.string().min(1).required(), + model: z.string().min(1).required(), +}) + +/** Route-selection authority captured by one delegation definition. */ +export type ModelSelectionPolicy = + | { readonly kind: 'unrestricted' } + | { readonly kind: 'allowlist'; readonly routes: readonly AllowedModelRoute[] } + +/** + * Stable identity for one provider/model pair. + * @param route - Exact provider/model route. + * @returns Opaque key for equality checks. + */ +export function modelRouteKey(route: AllowedModelRoute): string { + return `${route.provider}\0${route.model}` +} + +/** + * Reject malformed or duplicate route policy entries at a configuration boundary. + * @param routes - Exact routes to validate. + */ +export function assertAllowedModelRoutes(routes: readonly AllowedModelRoute[]): void { + const seen = new Set() + for (const route of routes) { + if (route.provider.length === 0 || route.model.length === 0) { + throw new Error('subagent model selection requires non-empty provider and model ids') + } + const key = modelRouteKey(route) + if (seen.has(key)) { + throw new Error(`subagent model selection repeats route "${route.provider}/${route.model}"`) + } + seen.add(key) + } +} /** Model-facing child LLM route fields. */ export interface DelegationModelRequest { @@ -70,6 +117,31 @@ export function requestedAgentOptions( } } +/** + * Enforce a settings-owned route list at the operation that creates the child. + * Pure inheritance remains outside this policy because no model-facing choice + * occurred; any explicit route or effort field must resolve to an allowed route. + * @param policy - Selection authority captured for this Session. + * @param parentOptions - Current parent values that supply missing child values. + * @param requested - Effective child options after request/config merging. + * @param request - Model-facing selection fields from the tool call. + */ +export function assertAllowedModelSelection( + policy: ModelSelectionPolicy | undefined, + parentOptions: AgentOptions, + requested: AgentOptions | undefined, + request: DelegationModelRequest, +): void { + if (policy?.kind !== 'allowlist' || !hasDelegationModelRequest(request)) return + const provider = requested?.provider ?? parentOptions.provider + const model = requested?.model ?? parentOptions.model + if (provider === undefined || model === undefined) { + throw new Error('cannot select child LLM values without an effective provider and model') + } + if (policy.routes.some(route => route.provider === provider && route.model === model)) return + throw new Error(`child LLM route "${provider}/${model}" is not allowed for this Session`) +} + /** * Whether configured Agent options require route validation before delegation. * @param options - Tool-instance child defaults. diff --git a/packages/subagent/tool-subagent/tests/list-models.spec.ts b/packages/subagent/tool-subagent/tests/list-models.spec.ts index 893f11a9ff..c58c775946 100644 --- a/packages/subagent/tool-subagent/tests/list-models.spec.ts +++ b/packages/subagent/tool-subagent/tests/list-models.spec.ts @@ -15,6 +15,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as tool from '../src/index.ts' +import { registerListSubagentModels } from '../src/list-models.ts' import { testToolSignal, text } from './harness.ts' class CatalogAdapter extends LlmAdapter { @@ -66,6 +67,22 @@ async function setupListTool() { return { ctx, fiber } } +async function setupAllowedListTool() { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + registerListSubagentModels(ctx, { + kind: 'allowlist', + routes: [ + { provider: 'alpha', model: 'fast' }, + { provider: 'alpha', model: 'unlisted' }, + { provider: 'missing', model: 'hidden' }, + ], + }) + return ctx +} + let counter = 0 function call(ctx: Context, args: unknown) { @@ -135,6 +152,20 @@ describe('list_subagent_models', () => { expect(text(result)).toBe('alpha/fast — Fast: Focused work.\nalpha/plain — Plain') }) + it('intersects provider and model discovery with the Session allowlist', async () => { + const ctx = await setupAllowedListTool() + ctx.llm.registerAdapter(['alpha', 'beta'], new CatalogAdapter()) + + expect(text(await call(ctx, {}))).toBe('alpha — ALPHA API') + expect(text(await call(ctx, { provider: 'alpha' }))).toBe('alpha/fast — Fast: Focused work.') + expect(text(await call(ctx, { provider: 'alpha', model: 'unlisted' }))) + .toContain('alpha/unlisted — Fast') + + const denied = await call(ctx, { provider: 'alpha', model: 'plain' }) + expect(denied.isError).toBe(true) + expect(text(denied)).toContain('is not allowed for this Session') + }) + it('renders an empty advertised model list', async () => { const { ctx } = await setupListTool() ctx.llm.registerAdapter(['alpha'], new CatalogAdapter(true)) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 4f95db088f..7cd0926bdb 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' @@ -17,7 +18,10 @@ import * as ToolInvariant from '../src/invariant.ts' import SubagentModelSelectionConfig, { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, } from '../src/model-selection-settings.ts' -import { hasSubagentModelSelection } from '../src/model-selection-state.ts' +import { subagentModelSelectionPolicy } from '../src/model-selection-state.ts' +import { text } from './harness.ts' + +const ALLOWED_MODELS = [{ provider: 'alpha', model: 'fast-model' }] /** Writable in-memory settings provider for the package integration. */ class MemorySettings extends SettingsProvider { @@ -81,9 +85,9 @@ async function createAgent(ctx: Context, id: string, options: { describe('SubagentModelSelectionConfig', () => { it('uses the composed default without a settings provider', async () => { const ctx = new Context() - await ctx.plugin(SubagentModelSelectionConfig, { enabled: true }) + await ctx.plugin(SubagentModelSelectionConfig, { allowedModels: ALLOWED_MODELS }) - expect(ctx.subagentModelSelection.currentEnabled()).toBe(true) + expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual(ALLOWED_MODELS) await ctx.fiber.dispose() }) @@ -92,9 +96,24 @@ describe('SubagentModelSelectionConfig', () => { await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) - expect(ctx.subagentModelSelection.currentEnabled()).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) - expect(ctx.subagentModelSelection.currentEnabled()).toBe(true) + expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual([]) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual(ALLOWED_MODELS) + await ctx.fiber.dispose() + }) + + it('rejects duplicate routes and an empty durable policy', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings) + await ctx.plugin(SubagentModelSelectionConfig) + + await expect(ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS], + })).rejects.toThrow('repeats route "alpha/fast-model"') + + const invalid = Session.create(SessionId('empty-policy')) + invalid.append('subagent/model-selection-policy', { allowedModels: [] }) + expect(() => subagentModelSelectionPolicy(invalid)).toThrow('requires at least one route') await ctx.fiber.dispose() }) @@ -102,21 +121,44 @@ describe('SubagentModelSelectionConfig', () => { const ctx = await boot() const disabled = await createAgent(ctx, 'disabled') expect(selectable(ctx, disabled)).toBe(false) - expect(hasSubagentModelSelection(disabled.session)).toBe(false) + expect(subagentModelSelectionPolicy(disabled.session)).toBeUndefined() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const enabled = await createAgent(ctx, 'enabled') - expect(hasSubagentModelSelection(enabled.session)).toBe(true) + expect(subagentModelSelectionPolicy(enabled.session)).toEqual(ALLOWED_MODELS) expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) const disabledAgain = await createAgent(ctx, 'disabled-again') expect(selectable(ctx, disabledAgain)).toBe(false) expect(selectable(ctx, enabled)).toBe(true) await ctx.fiber.dispose() }) + it('rejects a forced route outside the Session policy before child creation', async () => { + const ctx = await boot() + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + const agent = await createAgent(ctx, 'enforced') + + const result = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('disallowed-session-route'), + name: 'subagent', + arguments: { + description: 'forced route', + prompt: 'do it', + provider: 'alpha', + model: 'other-model', + }, + agent, + }) + + expect(result.isError).toBe(true) + expect(text(result)).toContain('is not allowed for this Session') + await ctx.fiber.dispose() + }) + it('installs per-Agent definitions for a shared preset scope', async () => { const ctx = await boot() const preset = createScope(ctx, { preset: 'standard' }) @@ -138,7 +180,7 @@ describe('SubagentModelSelectionConfig', () => { const disabled = await createComposed('preset-disabled') expect(selectable(ctx, disabled.agent)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const enabled = await createComposed('preset-enabled') expect(selectable(ctx, enabled.agent)).toBe(true) expect(selectable(ctx, disabled.agent)).toBe(false) @@ -158,25 +200,30 @@ describe('SubagentModelSelectionConfig', () => { it('inherits the parent decision and preserves seeded decisions across composition', async () => { const ctx = await boot() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const parent = await createAgent(ctx, 'parent') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) const child = await createAgent(ctx, 'child', { meta: { parentSession: parent.id, origin: 'subagent' }, }) expect(selectable(ctx, child)).toBe(true) - expect(hasSubagentModelSelection(child.session)).toBe(true) + expect(subagentModelSelectionPolicy(child.session)).toEqual(ALLOWED_MODELS) + + const orphan = await createAgent(ctx, 'orphan', { + meta: { parentSession: SessionId('missing-parent'), origin: 'subagent' }, + }) + expect(selectable(ctx, orphan)).toBe(false) const enabledSeed = Session.create(SessionId('enabled-seed')) - enabledSeed.append('subagent/model-selection-enabled', {}) + enabledSeed.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.events }) expect(selectable(ctx, resumedEnabled)).toBe(true) const oldSeed = Session.create(SessionId('old-seed'), []) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) expect(selectable(ctx, resumedDisabled)).toBe(false) - expect(hasSubagentModelSelection(resumedDisabled.session)).toBe(false) + expect(subagentModelSelectionPolicy(resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() }) @@ -235,11 +282,11 @@ describe('SubagentModelSelectionConfig', () => { kind: 'enter', messages: [], }) - disabled.session.append('subagent/model-selection-enabled', {}) + disabled.session.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) .rejects.toThrow('must expose route fields and list_subagent_models') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const enabled = await createAgent(ctx, 'invariant-enabled') await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 008ba66da4..710747bacc 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -10,6 +10,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' +import { assertAllowedModelRoutes, assertAllowedModelSelection } from '../src/model-selection.ts' import { callSubagent, setup, text } from './harness.ts' const REASONING = { @@ -32,6 +33,54 @@ function parentWithRoute( } describe('dsh-tool-subagent model selection', () => { + it('rejects empty route ids at the configuration boundary', () => { + expect(() => { assertAllowedModelRoutes([{ provider: '', model: 'model' }]) }) + .toThrow('requires non-empty provider and model ids') + expect(() => { assertAllowedModelRoutes([{ provider: 'provider', model: '' }]) }) + .toThrow('requires non-empty provider and model ids') + }) + + it('allows pure inheritance but rejects explicit values outside a Session allowlist', () => { + const policy = { + kind: 'allowlist' as const, + routes: [{ provider: 'alpha', model: 'allowed-model' }], + } + const parent = { provider: 'alpha', model: 'parent-model' } + + expect(() => { assertAllowedModelSelection(policy, parent, undefined, {}) }).not.toThrow() + expect(() => { + assertAllowedModelSelection( + policy, + parent, + { provider: 'alpha', model: 'allowed-model' }, + { provider: 'alpha', model: 'allowed-model' }, + ) + }).not.toThrow() + expect(() => { + assertAllowedModelSelection( + policy, + parent, + { provider: 'alpha', model: 'other-model' }, + { provider: 'alpha', model: 'other-model' }, + ) + }).toThrow('is not allowed for this Session') + expect(() => { + assertAllowedModelSelection( + policy, + parent, + { reasoningEffort: ReasoningEffortId('low') }, + { reasoning_effort: 'low' }, + ) + }).toThrow('alpha/parent-model') + expect(() => { + assertAllowedModelSelection( + policy, + {}, + { reasoningEffort: ReasoningEffortId('low') }, + { reasoning_effort: 'low' }, + ) + }).toThrow('without an effective provider and model') + }) it('exposes static route fields and discovery when selection is enabled', async () => { const ctx = await setup({ provider: 'mock', enableModelSelection: true }) const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 3332b24350..7194655904 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -240,6 +240,7 @@ export const LINK_MAP: Readonly> = { AgentFactory: 'core.md', AgentHandle: 'core.md', ModelSelection: 'core.md', + AllowedModelRoute: 'subagent.md', AgentOptions: 'core.md', AgentStatus: 'core.md', ContentBlock: 'llm-streaming.md', From ebe8d4db1cf57c5640cd0045626e487c226d3ffe Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 00:42:08 +0800 Subject: [PATCH 17/38] test(web): configure subagent model allowlist --- apps/cli/tests/web-agent-presets.e2e.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 4747543ec0..bb2998d51b 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -241,13 +241,15 @@ describe('the shipped Web composition', () => { } }) - it('applies the default-off subagent model-selection preference only to new sessions', async () => { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + it('applies the default-off subagent model allowlist only to new sessions', async () => { + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) const disabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-disabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }], + }) const enabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-enabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), @@ -263,7 +265,7 @@ describe('the shipped Web composition', () => { ])) expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') } finally { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) await enabled.dispose() await disabled.dispose() } From 7c626fb5d2a5ff32542c3bee5b64642d21856b90 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 15:13:30 +0800 Subject: [PATCH 18/38] fix(subagent): gate model selection with explicit allowlist --- ...8-model-selected-subagent-routes.i18n.yaml | 4 +- ...26-08-18-model-selected-subagent-routes.md | 8 +- ...08-18-model-selected-subagent-routes.zh.md | 8 +- ...authorized-subagent-model-routes.i18n.yaml | 4 +- ...4-user-authorized-subagent-model-routes.md | 8 +- ...ser-authorized-subagent-model-routes.zh.md | 8 +- apps/cli/tests/profiles/headless/cordis.yml | 1 - apps/cli/tests/web-agent-presets.e2e.ts | 8 +- apps/web/tests/plugin-config.e2e.ts | 10 +++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 5 +- docs/config-catalog.zh.md | 5 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 6 +- docs/subsystems/subagent.zh.md | 6 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 18 +--- docs/tool-catalog.zh.md | 18 +--- packages/bundle/base/cordis.patch.yml | 1 - ...ubagent-model-selection-card-controller.ts | 33 ++++--- .../tests/stores.client.spec.ts | 80 +++++++++++++---- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 5 +- packages/client/ui-settings/README.zh.md | 5 +- .../src/client/settings-contract.ts | 12 ++- .../ui-settings/src/client/settings-scope.ts | 14 ++- .../tests/settings-scope.client.spec.ts | 29 +++++- .../extensions/tool-cordis/src/api-catalog.ts | 10 +-- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 13 ++- packages/subagent/tool-subagent/README.zh.md | 13 ++- packages/subagent/tool-subagent/src/index.ts | 19 ++-- .../subagent/tool-subagent/src/list-models.ts | 28 ++++-- .../src/model-selection-settings.ts | 34 +++++-- .../tool-subagent/src/model-selection.ts | 9 +- .../subagent/tool-subagent/tests/harness.ts | 50 ++++++++++- .../tool-subagent/tests/list-models.spec.ts | 69 ++++++++------ .../tests/model-selection-settings.spec.ts | 75 ++++++++++------ .../tests/model-selection.spec.ts | 90 +++++++++++-------- .../tool-subagent/tests/tool-subagent.spec.ts | 7 +- .../client-runtime/src/settings-scope.ts | 5 ++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 6 +- .../cancel-tool-calls/stdout.expected.jsonl | 7 -- snapshots/acp/escalation-approved/cordis.yml | 1 - .../tool-schemas.1.expected.json | 31 +------ .../tool-schemas.1.expected.json | 31 +------ .../tool-schemas.1.expected.json | 31 +------ .../tool-schemas.1.expected.json | 31 +------ .../both-mode-turn/system-prompt.expected.md | 16 +--- .../both-mode-turn/tool-schemas.expected.json | 31 +------ .../system-prompt.expected.md | 16 +--- .../code-mode-turn/system-prompt.expected.md | 16 +--- .../system-prompt.expected.md | 16 +--- .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../lsp-definition/tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../cordis.snapshot.yml | 66 -------------- .../cordis.yml | 15 ---- .../replay.override.json | 32 ------- .../session.jsonl | 41 --------- .../cordis.snapshot.yml | 1 - .../subagent-depth-two-rejection/cordis.yml | 1 - .../text-turn/tool-schemas.expected.json | 31 +------ .../web-fetch/tool-schemas.expected.json | 31 +------ 71 files changed, 498 insertions(+), 971 deletions(-) delete mode 100644 snapshots/acp/cancel-tool-calls/stdout.expected.jsonl delete mode 100644 snapshots/session/subagent-configured-effort-rejection/cordis.snapshot.yml delete mode 100644 snapshots/session/subagent-configured-effort-rejection/cordis.yml delete mode 100644 snapshots/session/subagent-configured-effort-rejection/replay.override.json delete mode 100644 snapshots/session/subagent-configured-effort-rejection/session.jsonl diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index eed424a054..878ed45e35 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: 9c6d777a1dbed569340e4900559a2cee2a42ff82 -2026-08-18-model-selected-subagent-routes.zh.md: a6d665f9533b481edf2f91071f7561544744d202 +2026-08-18-model-selected-subagent-routes.md: 768da30d26f7daede6ed68dda72efcb4207ab60f +2026-08-18-model-selected-subagent-routes.zh.md: 9cf3033af0e143589ff6806acbb4600478e83929 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index 9c6d777a1d..768da30d26 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -12,15 +12,15 @@ The model also needs a bounded way to discover live providers and model-owned ef ## Decision -`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves a non-empty Session policy, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. Static enablement needs no route list and can select any route its adapter accepts. The shipped settings-controlled path uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md). Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. +`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its Agent-scoped `modelSelectionSettings` instance resolves a Session policy and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. The policy uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md); there is no unrestricted static mode. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. A settings-enabled instance against a provider without the capability fails the plugin mount. Provider and model form one route and must be supplied together. An effort may be supplied alone when configured, parent, or provider-owned route defaults provide the effective route. Static `provider.agentRouteDefaults`, when present, establish the provider/model baseline; `Config.agentOptions` and model arguments overlay it before route-aware effort clearing. Providers without static defaults use compatible fields from the parent Agent's latest logged request selection, with creation options supplying the fallback before its first request and retaining the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort only from the selected baseline; changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection. An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. -An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Static enablement exposes the live directory without another filter. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-empty Host-owned `subagent-model-selection.allowedModels` setting. The Plugins settings page stores exact provider/model routes from the adapter directory. A new top-level Session snapshots a non-empty policy as `subagent/model-selection-policy` before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. In settings-controlled Sessions, discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it. +An enabled definition registers `list_subagent_models`. With no arguments the tool lists authorized registered providers; with `provider` it calls that adapter's advisory model catalog only after authorization; with `provider` and `model` it authorizes the exact route before resolving its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-off Host-owned `subagent-model-selection` setting with an explicit `enabled` switch and `allowedModels` list. The Plugins settings page stores both fields atomically. A new top-level Session snapshots the route policy as `subagent/model-selection-policy` when the setting is enabled, before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. Discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it. -Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. +Shipped `subagent_fork` instances do not read model-selection settings even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix. @@ -49,7 +49,7 @@ The delegation definition is static across adapter registration and catalog chan ## Consequences - A statically enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. -- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy is non-empty; discovery and explicit selection are constrained to that policy. +- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy exists; discovery and explicit selection are constrained to that policy. - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. - Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default. - Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index a6d665f953..9cf3033af0 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -12,15 +12,15 @@ Status: implemented ## 决策 -只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出非空 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。静态启用无需路由列表,并且可以选择适配器接受的任意路由。随附的 settings 控制路径使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 +只有 Agent 作用域的 `modelSelectionSettings` 实例解析出 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。该策略使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权;不存在无限制静态模式。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果 settings 已启用的实例缺少该提供方能力,插件挂载会失败。 提供方与模型共同组成一条路由,必须一起提供。如果配置值、父级值或提供方持有的路由默认值能够提供生效路由,则可以只提供推理强度。静态的 `provider.agentRouteDefaults` 在存在时构成 provider/model 基线;`Config.agentOptions` 与模型参数会在路由相关强度清除之前覆盖它。没有静态默认值的提供方会使用父 Agent 最新记录请求中的兼容字段,首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。只有所选基线的路由不变时才会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。 显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 -启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。静态启用会公开不带额外过滤的实时目录。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认空值的 Host 自有 `subagent-model-selection.allowedModels` 设置。Plugins 设置页从适配器目录保存精确 provider/model 路由。新的顶层 Session 会在任何模型请求之前,把非空策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。在 settings 控制的 Session 中,发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。 +启用的定义会注册 `list_subagent_models`。无参数调用列出已授权且已注册的提供方;提供 `provider` 时先授权,再调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时会先授权精确路由,再解析其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册 Host 自有的 `subagent-model-selection` 设置,其中包含默认关闭的显式 `enabled` 开关与 `allowedModels` 列表。Plugins 设置页会原子保存两个字段。设置启用时,新的顶层 Session 会在任何模型请求之前,把路由策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。 -随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 +随附的 `subagent_fork` 实例不会读取模型选择设置,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。 @@ -49,7 +49,7 @@ Status: implemented ## 结果 - 静态启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 -- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略非空的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 +- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略存在的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 - adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index b9966ef055..60d872ccda 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: 3a56e48b35bcd1b1801108022e85ec83ad98b437 -2026-08-24-user-authorized-subagent-model-routes.zh.md: dca788993f1eccb96a424d2bd8186749363488b3 +2026-08-24-user-authorized-subagent-model-routes.md: af293dd2e74ea801892f2b73553cc15beb1c679e +2026-08-24-user-authorized-subagent-model-routes.zh.md: 973142f0294ad2bfc10dd5729e3a4a4992d6a84b diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index 3a56e48b35..af293dd2e7 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -10,13 +10,13 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an ## Decision -The Host-owned `subagent-model-selection` settings section stores `allowedModels`, an array of exact `{ provider, model }` routes. An empty array disables model-facing child route selection. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage one or more exact routes, and replaces the whole array in one revision-fenced field write. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase stored authorization. +The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase stored authorization. -A newly composed top-level Session snapshots a non-empty route list in `subagent/model-selection-policy` before its model-selectable definitions can reach a request. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions. +A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. -Static `enableModelSelection: true` remains an unrestricted deployment-owned mode for custom compositions. The shipped `modelSelectionSettings` path is user-authorized and default-off. The primary spawn tool uses that path; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse. +Model selection has no unrestricted static mode. The default-off Host setting is the only authority, and an enabled Session always carries an exact allowlist. The primary spawn tool reads that setting; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse. ## Alternatives considered @@ -24,7 +24,7 @@ Static `enableModelSelection: true` remains an unrestricted deployment-owned mod **Filter only the settings UI or discovery result.** Rejected because a model can guess a route or retain one from an earlier transcript. Authorization is enforced in the executor that starts the child. -**Store `enabled` and `allowedModels` as separate fields.** Rejected because two writes admit an enabled state with no completed authorization decision. A non-empty array is both the opt-in and its exact policy; an empty user-layer array can explicitly disable a deployment base list. +**Infer enablement from a non-empty `allowedModels` array.** Rejected because disabling would have to discard a useful selection or preserve a non-empty array whose meaning depends on write history. The explicit switch is authoritative, and the settings scope submits both fields in one Host-validated mutation so no intermediate state is persisted. **Store per-route reasoning-effort allowlists.** Rejected because the user decision concerns child models, while effort ids and compatibility belong to the exact adapter route. Every adapter-supported effort remains available after the route is authorized. diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index dca788993f..973142f029 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -10,13 +10,13 @@ Status: implemented ## Decision -Host 自有的 `subagent-model-selection` 设置 section 保存 `allowedModels`,即由精确 `{ provider, model }` 路由组成的数组。空数组会关闭面向模型的子级路由选择。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存一条或多条精确路由,再用一次带 revision 限制的字段写入整体替换该数组。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权。 +Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权。 -新组合的顶层 Session 会在模型可选定义进入请求之前,把非空路由列表快照记录为 `subagent/model-selection-policy`。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。 +设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 -静态 `enableModelSelection: true` 继续作为自定义组合中由部署方所有的无限制模式。随附的 `modelSelectionSettings` 路径由用户授权且默认关闭。主 spawn 工具使用该路径;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。 +模型选择不再有无限制的静态模式。默认关闭的 Host 设置是唯一授权来源,启用的 Session 始终携带精确允许列表。主 spawn 工具读取该设置;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。 ## Alternatives considered @@ -24,7 +24,7 @@ Host 自有的 `subagent-model-selection` 设置 section 保存 `allowedModels` **只过滤设置 UI 或发现结果。** 不采用,因为模型可以猜测路由,或从较早的 transcript 中保留路由。授权由启动子级的执行器强制执行。 -**把 `enabled` 与 `allowedModels` 存成两个字段。** 不采用,因为两次写入会产生已经启用但尚无完整授权决定的状态。非空数组同时表示 opt-in 与精确策略;用户层空数组可以显式关闭部署基础列表。 +**从非空 `allowedModels` 数组推断是否启用。** 不采用,因为关闭功能时要么必须丢弃仍有用的选择,要么要保留一个含义取决于写入历史的非空数组。显式开关是权威依据,设置 scope 会在一次由 Host 校验的 mutation 中提交两个字段,因此不会持久化中间状态。 **保存每条路由的推理强度允许列表。** 不采用,因为用户决定针对子级模型,而强度 id 与兼容性属于精确适配器路由。路由获准后,仍可使用适配器支持的每种强度。 diff --git a/apps/cli/tests/profiles/headless/cordis.yml b/apps/cli/tests/profiles/headless/cordis.yml index 759a252b4e..ea31466a70 100644 --- a/apps/cli/tests/profiles/headless/cordis.yml +++ b/apps/cli/tests/profiles/headless/cordis.yml @@ -124,7 +124,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 1 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index bb2998d51b..a351cee4ee 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -242,12 +242,16 @@ describe('the shipped Web composition', () => { }) it('applies the default-off subagent model allowlist only to new sessions', async () => { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: false, + allowedModels: [], + }) const disabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-disabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }], }) const enabled = await ctx.agents.create({ @@ -265,7 +269,7 @@ describe('the shipped Web composition', () => { ])) expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') } finally { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) await enabled.dispose() await disabled.dispose() } diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 781034541b..edf3b1e98f 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -106,10 +106,20 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) .toBe(true) + expect(await settingsDocument()).toContain('enabled: true') expect(await settingsDocument()).toContain('allowedModels:') expect(await settingsDocument()).toContain('provider:') expect(await settingsDocument()).toContain('model:') expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。') + + await toggle.click() + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false') + await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 }) + .toBe(true) + expect(await settingsDocument()).toContain('allowedModels:') + expect(await settingsDocument()).toContain('provider:') + expect(await settingsDocument()).toContain('model:') expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 191f1c452c..2407817a57 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: 6432f5d359027a30fd436c9becd6bb6b8c0abaf4 -config-catalog.zh.md: 1ab6838e4cea77a7d98a2227aca6e8ac47d84fcd +config-catalog.md: 57b7f120ff09459e02f998806f6335f2b45d1b1b +config-catalog.zh.md: f0911b9d004d885868758351e16e3783b660eb18 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6432f5d359..57b7f120ff 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2863,12 +2863,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 1ab6838e4c..f0911b9d00 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2865,12 +2865,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index a9f6e2035a..9e82c64ceb 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: 792e03aa8bc5b8533094b4fd678ef8b43383e043 -subagent.zh.md: 1b5af32dfc6405c1df09d38628332e8e6c8ae737 +subagent.md: 9206672b89ac31e30bee176f536b3057eb37f3ee +subagent.zh.md: 30e4e09a145a4b3d51044d85c602ce81fd44c3b3 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 792e03aa8b..9206672b89 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -505,10 +505,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read a detached route policy for the next eligible Agent publication. - * @returns exact allowed routes; an empty list disables model-facing selection. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ -currentAllowedModels(): AllowedModelRoute[] +current(): SubagentModelSelectionSettings ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 1b5af32dfc..30e4e09a14 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -509,10 +509,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read a detached route policy for the next eligible Agent publication. - * @returns exact allowed routes; an empty list disables model-facing selection. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ -currentAllowedModels(): AllowedModelRoute[] +current(): SubagentModelSelectionSettings ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 46e2eebdd3..569d755993 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-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/tool-catalog.md -tool-catalog.md: be8f503ed983e68e39da1a9401ffbe70a029968c -tool-catalog.zh.md: 16fd7de1235250d91dfa7df2304d1a66ba192b0d +tool-catalog.md: 16142c2f7d98cf1037b034d2836c742b62f26594 +tool-catalog.zh.md: 533caacc7a923b5ea36f527292f420b610f1c488 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index be8f503ed9..16142c2f7d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | @@ -1561,7 +1561,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. ```json { @@ -1575,18 +1575,6 @@ Delegate a self-contained task to a subagent (a separate agent that works in its "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1601,7 +1589,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. +The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 16fd7de123..533caacc7a 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -37,7 +37,7 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | @@ -1567,7 +1567,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。子级 LLM 选择是可选的。省略 `provider`、`model` 与 `reasoning_effort` 会使用配置的子级默认值,并从父 Agent 继承兼容的缺失值。先用 `list_subagent_models` 检查公布的路由和强度,再一起提供 `provider` 与 `model`。改变生效路由但不指定强度时,会使用所选模型的默认强度。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。 ```json { @@ -1581,18 +1581,6 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1607,7 +1595,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 +注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index ee5e5ed156..eaa8457e88 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -357,7 +357,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable # Fork omits model selection so provider/model stay equal to the parent and diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index d8cfb62f6c..b2f24d4b8e 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -20,8 +20,10 @@ export interface AllowedSubagentModel { /** Settings fields stored for subagent model selection. */ export interface SubagentModelSelectionSettings { + /** Whether model-facing child route selection applies to new Sessions. */ + enabled: boolean /** Exact child routes offered to newly composed top-level Sessions. */ - allowedModels?: AllowedSubagentModel[] + allowedModels: AllowedSubagentModel[] } /** One catalog row joined with a stored route that may no longer be advertised. */ @@ -64,7 +66,7 @@ export interface SubagentModelSelectionCardFace { toggleModel: (key: string) => void /** Retry the adapter directory. */ retryCatalog: () => void - /** Persist the whole exact route list as one revision-fenced field write. */ + /** Persist the switch and exact routes as one revision-fenced mutation. */ save: () => void /** Drop the staged enabled state and route choices. */ discard: () => void @@ -151,9 +153,10 @@ export class SubagentModelSelectionCardController { ) { this.store = createSnapshotStore(this.projection()) this.unsubscribe = scope.subscribe(() => { - if (this.currentRoutes().length > 0 && this.catalogStatus === 'idle') void this.loadCatalog() + if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() this.publish() }) + if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() } /** Stop observing settings and suppress late directory/write settlements. */ @@ -180,7 +183,11 @@ export class SubagentModelSelectionCardController { } private currentRoutes(): AllowedSubagentModel[] { - return this.scope.getSnapshot().value?.allowedModels?.map(route => ({ ...route })) ?? [] + return this.scope.getSnapshot().value?.allowedModels.map(route => ({ ...route })) ?? [] + } + + private currentEnabled(): boolean { + return this.scope.getSnapshot().value?.enabled ?? false } private selected(): Set { @@ -188,11 +195,11 @@ export class SubagentModelSelectionCardController { } private enabled(): boolean { - return this.draftEnabled ?? this.currentRoutes().length > 0 + return this.draftEnabled ?? this.currentEnabled() } private beginDraft(): Set { - this.draftEnabled ??= this.currentRoutes().length > 0 + this.draftEnabled ??= this.currentEnabled() this.draftSelected ??= new Set(this.currentRoutes().map(subagentModelKey)) return this.draftSelected } @@ -233,7 +240,6 @@ export class SubagentModelSelectionCardController { } private desiredRoutes(): AllowedSubagentModel[] { - if (!this.enabled()) return [] return this.candidates() .filter(candidate => candidate.selected) .map(({ provider, model }) => ({ provider, model })) @@ -241,17 +247,22 @@ export class SubagentModelSelectionCardController { private async save(): Promise { const snapshot = this.scope.getSnapshot() + const desiredEnabled = this.enabled() const desired = this.desiredRoutes() if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving - || sameRoutes(this.currentRoutes(), desired) || (this.enabled() && desired.length === 0)) return + || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) + || (desiredEnabled && desired.length === 0)) return const generation = this.saveGeneration this.saving = true this.saved = false this.failed = false this.publish() - await this.scope.set('allowedModels', desired) + await this.scope.mutate([ + { op: 'set', path: ['enabled'], value: desiredEnabled }, + { op: 'set', path: ['allowedModels'], value: desired }, + ]) if (generation !== this.saveGeneration) return - const landed = sameRoutes(this.currentRoutes(), desired) + const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) this.saving = false this.saved = landed this.failed = !landed @@ -291,7 +302,7 @@ export class SubagentModelSelectionCardController { return { available: snapshot.status === 'ready', writable: snapshot.writable, - dirty: !sameRoutes(current, desired), + dirty: this.currentEnabled() !== enabled || !sameRoutes(current, desired), invalid: enabled && desired.length === 0, saving: this.saving, failed: this.failed, 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 414ce8dab9..853bb502c9 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -4,6 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' +import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' @@ -26,6 +27,18 @@ function acceptWrites(host: StubSettingsScope): void { host.set.mockImplementation((field: string, value: unknown) => { host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) }) + host.mutate.mockImplementation((ops: readonly SettingsPathOpView[]) => { + const value = { ...section() } + const user = { ...layer() } + for (const op of ops) { + const field = op.path[0]! + if (op.op === 'set') { + value[field] = op.value + user[field] = op.value + } + } + host.publish({ value: value as T, user }) + }) host.unset.mockImplementation((field: string) => { const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) const base = host.scope.getSnapshot().base as Record | undefined @@ -436,14 +449,14 @@ describe('SubagentModelSelectionCardController', () => { ]) }) - it('loads adapter models and saves one exact route as a whole field', async () => { + it('loads adapter models and saves the switch and routes atomically', async () => { const host = stubSettingsScope() acceptWrites(host) const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) @@ -454,7 +467,10 @@ describe('SubagentModelSelectionCardController', () => { face.toggleModel('alpha\0fast') face.save() await vi.waitFor(() => { - expect(host.set).toHaveBeenCalledWith('allowedModels', [{ provider: 'alpha', model: 'fast' }]) + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ]) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ @@ -472,7 +488,7 @@ describe('SubagentModelSelectionCardController', () => { groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() face.toggleEnabled() @@ -502,7 +518,7 @@ describe('SubagentModelSelectionCardController', () => { const controller = new SubagentModelSelectionCardController(host.scope, models.api) host.publish({ status: 'ready', writable: true, - value: { allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, }) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -521,11 +537,38 @@ describe('SubagentModelSelectionCardController', () => { expect(state()).toMatchObject({ dirty: false, enabled: true }) }) + it('retains selected routes when disabling and loads an already-ready enabled card', async () => { + const host = stubSettingsScope() + acceptWrites(host) + host.publish({ + status: 'ready', writable: true, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const face = controller.inject() + await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() }) + + face.toggleEnabled() + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: false }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ]) + }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: false, dirty: false, saved: true, + }) + }) + it('reports a directory error and retries it', async () => { const host = stubSettingsScope() const models = modelsApi({ error: 'offline' }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -541,16 +584,21 @@ describe('SubagentModelSelectionCardController', () => { groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) const write = deferred() - const set = vi.fn(async (field: string, value: unknown) => { + const mutate = vi.fn(async (ops: readonly SettingsPathOpView[]) => { await write.promise - host.publish({ value: { [field]: value } }) + const enabled = ops.find(op => op.path[0] === 'enabled') + const allowedModels = ops.find(op => op.path[0] === 'allowedModels') + host.publish({ value: { + enabled: enabled?.op === 'set' ? enabled.value as boolean : false, + allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [], + } }) }) - const controller = new SubagentModelSelectionCardController({ ...host.scope, set }, catalog.api) + const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.api) const face = controller.inject() face.save() face.toggleModel('alpha\0fast') - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) face.save() face.toggleEnabled() await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') }) @@ -565,12 +613,12 @@ describe('SubagentModelSelectionCardController', () => { controller.dispose() write.resolve(undefined) await write.promise - expect(set).toHaveBeenCalledOnce() + expect(mutate).toHaveBeenCalledOnce() }) it('suppresses duplicate directory loads and late resolve or reject settlements', async () => { const host = stubSettingsScope() - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const pending = deferred() const models = vi.fn(() => pending.promise) @@ -601,20 +649,20 @@ describe('SubagentModelSelectionCardController', () => { it('ignores writes while read-only and scope notifications after disposal', () => { const host = stubSettingsScope() const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) - host.publish({ status: 'ready', writable: false, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() face.toggleEnabled() face.toggleModel('alpha\0fast') face.save() - expect(host.set).not.toHaveBeenCalled() + expect(host.mutate).not.toHaveBeenCalled() controller.dispose() face.toggleEnabled() face.retryCatalog() face.save() - host.publish({ value: { allowedModels: [{ provider: 'alpha', model: 'fast' }] } }) - expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] } }) + expect(host.mutate).not.toHaveBeenCalled() expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) }) }) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 6184db0dff..06c0fe9604 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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-settings/README.md -README.md: a9a45d90eaa46502829ee6d2c1793b7dadb1f0a5 -README.zh.md: bd615cbb5a3236370b7bbc9fc735deeab6efd8ce +README.md: beda3aec1750da77af86cebdf658bde8e3834a46 +README.zh.md: 8d4fb0983c8a0ba627411fd3b653114625530639 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index a9a45d90ea..beda3aec17 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: one field path fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. +A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. ### Filling the settings slots @@ -55,7 +55,7 @@ The plugin injects `connection` and `remote` and owns the one `settings.describe ### Scope derivation -`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope with the namespace revision as `expectedRevision`; a committed write folds its answer in, a rejected or failed latest write triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. +`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. ### Schema service @@ -95,7 +95,6 @@ None; this package neither assembles nor sends a provider request. These limits define where the settings transport cannot reach; they are current package constraints. - **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API. -- **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions. ### Dev Note diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index bd615cbb5a..8d4fb0983c 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:单一字段路径以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。 +功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。 ### 填充设置 slot @@ -55,7 +55,7 @@ kind: "package-reference" ### Scope 派生 -`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope,以命名空间 revision 作为 `expectedRevision` 围栏;提交成功的写入把应答折回镜像,被拒绝或失败的最新写入触发一次恢复读取,被取代的写入把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 +`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 ### Schema 服务 @@ -95,7 +95,6 @@ kind: "package-reference" 这些限制说明设置传输层够不到的地方;它们是当前包约束。 - **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行仍在那里无效。 -- **每次写入仅一个字段**:`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。 ### 开发备注 diff --git a/packages/client/ui-settings/src/client/settings-contract.ts b/packages/client/ui-settings/src/client/settings-contract.ts index 38f95177a7..05b459c7a5 100644 --- a/packages/client/ui-settings/src/client/settings-contract.ts +++ b/packages/client/ui-settings/src/client/settings-contract.ts @@ -2,6 +2,8 @@ * Settings-namespace scope contracts owned beside the settings transport. */ +import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' + /** Client-side sync state of one settings namespace. */ export interface SettingsScopeSnapshot { /** @@ -46,7 +48,8 @@ export interface SettingsScopeSpec { /** * Reactive owner handle over one namespace's durable section — the browser * mirror of the Host-side `SettingsScope` owner seam. Domain services read - * and observe the snapshot and route explicit user choices through `set`. + * and observe the snapshot and route explicit user choices through its + * mutation methods. */ export interface SettingsScope { /** @returns the current sync snapshot (stable reference until the next change). */ @@ -57,6 +60,13 @@ export interface SettingsScope { * @returns the disposer removing this listener. */ subscribe(listener: () => void): () => void + /** + * Queue one atomic namespace mutation. All operations share one revision + * fence, Host validation, persistence decision, and recovery read. + * @param ops - ordered field operations copied when queued. + * @returns settlement after the mutation and any latest-write recovery read. + */ + mutate(ops: readonly SettingsPathOpView[]): Promise /** * Queue one field write. Rapid writes preserve mutation order, each carries * the latest known namespace revision, and only the latest settlement may diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index d4ddd632e5..276adebb46 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -104,7 +104,7 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the write and any latest-write recovery read. */ set(field: string, value: unknown): Promise { - return this.write({ op: 'set', path: [field], value: value as JsonValue }) + return this.mutate([{ op: 'set', path: [field], value: value as JsonValue }]) } /** @@ -114,16 +114,22 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the clear and any latest-write recovery read. */ unset(field: string): Promise { - return this.write({ op: 'unset', path: [field] }) + return this.mutate([{ op: 'unset', path: [field] }]) } - private write(op: SettingsPathOpView): Promise { + /** + * Queue one atomic namespace mutation; see {@link SettingsScope.mutate}. + * @param ops - ordered field operations copied when queued. + * @returns settlement after the mutation and any latest-write recovery read. + */ + mutate(ops: readonly SettingsPathOpView[]): Promise { + const ownedOps = structuredClone(ops) as SettingsPathOpView[] const generation = ++this.writeGeneration return this.enqueue(async () => { const revision = this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { - response = await this.api.settings.mutate(this.spec.namespace, [op], revision) + response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision) } catch (_settingsWriteFailure) { await this.recover(generation) return 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 b6788f0dba..883beee615 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -1,7 +1,9 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' -import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { + JsonValue, SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsSchemaService } from '../src/client/schema.ts' @@ -174,6 +176,31 @@ describe('SettingsScopeController', () => { ) }) + it('sends one copied multi-field mutation behind one revision fence', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 7)) + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 8))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + const ops: SettingsPathOpView[] = [ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ] + + const write = scope.mutate(ops) + ops[0] = { op: 'unset', path: ['enabled'] } + ;(ops[1] as { value: Array<{ model: string }> }).value[0]!.model = 'changed' + await write + + expect(mutate).toHaveBeenCalledWith( + 'ui-test', + [ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], + 7, + ) + }) + 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))) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index abb2272981..f6d8110ae0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2086,10 +2086,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Singleton settings owner read by delegation tools when an Agent is published.', methods: [ { - signature: 'currentAllowedModels(): AllowedModelRoute[]', - description: 'Read a detached route policy for the next eligible Agent publication.', + signature: 'current(): SubagentModelSelectionSettings', + description: 'Read a detached selection preference for the next eligible Agent publication.', parameters: [], - returns: 'exact allowed routes; an empty list disables model-facing selection.', + returns: 'the enabled state and exact allowed routes.', }, ], }, @@ -3362,10 +3362,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\';', }, - { - name: 'AllowedModelRoute', - declaration: 'export interface AllowedModelRoute {\n readonly provider: string;\n readonly model: string;\n}', - }, { name: 'ApiKeyRecord', declaration: 'export interface ApiKeyRecord {\n readonly kind: \'api-key\';\n readonly key?: string;\n readonly env?: Readonly>;\n}', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 66f31e12f5..e34f464d28 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: 72874e94b792ef3e75e4fa0d6ed7475808f209a0 -README.zh.md: 1cab6922b93c0fd8776158f1659927d0c4aa4126 +README.md: 5225aa28719e92b2158951aa145d9335a67ff1a2 +README.zh.md: 702cf46d36e15c7524c3dffc5dd46035547ff768 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 72874e94b7..5225aa2871 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -44,8 +44,7 @@ Load the subagent service, an in-process or remote backend, and this tool; then |---|---|---| | `provider` | required | Provider name on `ctx.subagents` (e.g. `spawn`, `fork`, `acp`) | | `toolName` | `subagent` | Model-facing tool name; distinct for every loaded instance | -| `enableModelSelection` | `false` | Statically expose child LLM selection fields and register `list_subagent_models`; requires provider `agentOptions` support | -| `modelSelectionSettings` | `false` | Sample the Host preference for each new top-level Session; mutually exclusive with `enableModelSelection` and valid only in Agent scope | +| `modelSelectionSettings` | `false` | Sample the Host's exact-route authorization preference for each new top-level Session; valid only in Agent scope and requires provider `agentOptions` support | | `enableRunInBackground` | `true` | Expose `run_in_background`; disabling also rejects forced background calls | | `backgroundMode` | `one-shot` | Background policy: `one-shot` defaults calls to foreground; `continuable` defaults them to background and requires the provider's `prepareContinuable` capability | | `agentOptions` | — | Configured child `provider`, `model`, adapter-owned `reasoningEffort`, and positive `maxTokens` defaults; requires provider `agentOptions` support and overlays any provider-owned route defaults | @@ -65,7 +64,7 @@ Under `continuable` policy, an omitted or `true` `run_in_background` starts a du ### Selecting a child LLM -Set `enableModelSelection: true` to expose optional `provider`, `model`, and `reasoning_effort` fields and register the shared `list_subagent_models` tool. Alternatively, set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection.enabled` preference when each top-level Session is composed. That decision is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. These modes are mutually exclusive and require a backend that advertises `agentOptions`; ACP, Codex, and Claude Code reject them, while DSH SDK supports route selection. +Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each top-level Session is composed. When enabled, its non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it. A call supplies `provider` and `model` together, or supplies only an effort when configured, parent, or provider-owned defaults provide the route. Static `provider.agentRouteDefaults`, when present, form the provider/model baseline; tool configuration and model fields overlay it before route-aware effort merging and exact-route preflight. Providers without these defaults use compatible values from the parent's latest logged request, then the parent's creation options before its first request, while retaining the configured `maxTokens`. Changing the route without an explicit effort clears the inherited route-owned effort, so the selected model resolves its default. The live LLM adapter validates the effective route before child creation. Catalog membership remains advisory, so a model can use an unlisted id when its adapter accepts it. @@ -132,7 +131,7 @@ Read these pages when the package-level contract is not enough; they move from t #### What the model sees -The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Model selection adds `provider`, `model`, and `reasoning_effort` plus inheritance and selection guidance. The tool and prompt descriptions follow whether the child inherits the conversation. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default and the settlement notice, while one-shot mode documents its `false` default and job collection. A tool restriction removes both the schema and the guidance section. +The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. An enabled Session policy adds `provider`, `model`, and `reasoning_effort` plus inheritance and selection guidance; the provider must support `agentOptions`. Provider context inheritance changes the tool and prompt descriptions. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the job id collected with `job_output` or stopped with `job_kill`. While the tool is visible in an assembly's scope, a `tool:` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result; a tool restriction removes both its schema and this guidance. #### Token effect @@ -146,7 +145,7 @@ Prefix-stable while provider instances and their configuration are unchanged. Ad #### What the model sees -An instance with static `enableModelSelection: true`, or a settings-controlled instance whose Session policy is non-empty, exposes the child LLM selection fields and `list_subagent_models`. Calls reject while the optional `ctx.llm` service is unavailable. Static enablement returns the live adapter directory. A settings-controlled instance returns only registered providers and advertised models in its exact route policy; an exact lookup must also be allowed before it resolves the model's reasoning efforts and default. Execution independently enforces the same policy. +A settings-controlled instance whose Session carries a policy exposes the child LLM selection fields and `list_subagent_models`. Calls reject while the optional `ctx.llm` service is unavailable. Discovery returns only registered providers and advertised models in the exact route policy; an unauthorized provider is rejected before its adapter catalog is called, and an exact lookup must be allowed before it resolves the model's reasoning efforts and default. Execution independently enforces the same policy. #### Token effect @@ -213,8 +212,8 @@ These limits define what this tool does not return or enforce; they are current - **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries any final assistant message, but it is not this call's return value and cannot be awaited here. - **Duplicate names across waiting one-shot instances are detected late** (`TODO(subagent-dup-toolname)`) — continuable instances reserve their prompt-section name during plugin application, but preventing provider-registration rollback for waiting one-shot instances requires a registry of intended names. -- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable selection only when route changes preserve reuse or expose a bounded recomputation cost. -- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM selection requires static enablement or an enabled per-Session preference and a provider that advertises `agentOptions`; ACP, Codex, and Claude Code reject it rather than ignore it. +- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable the fields only when route changes preserve reuse or expose a bounded recomputation cost. +- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM provider/model/reasoning-effort selection requires an enabled per-Session preference and a subagent provider that advertises `agentOptions`; out-of-process providers currently reject enabling it rather than ignore it. ### Dev Note diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 1cab6922b9..702cf46d36 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -44,8 +44,7 @@ kind: "package-reference" |---|---|---| | `provider` | 必填 | `ctx.subagents` 上的提供方名称(如 `spawn`、`fork`、`acp`) | | `toolName` | `subagent` | 面向模型的工具名称;每个已加载实例必须不同 | -| `enableModelSelection` | `false` | 静态公开子级 LLM 选择字段并注册 `list_subagent_models`;要求提供方支持 `agentOptions` | -| `modelSelectionSettings` | `false` | 为每个新顶层 Session 读取宿主偏好;与 `enableModelSelection` 互斥,且只在 Agent 作用域内有效 | +| `modelSelectionSettings` | `false` | 为每个新顶层 Session 读取宿主的精确路由授权偏好;只在 Agent 作用域内有效,并要求提供方支持 `agentOptions` | | `enableRunInBackground` | `true` | 公开 `run_in_background`;禁用时也会拒绝强制后台调用 | | `backgroundMode` | `one-shot` | 后台策略:`one-shot` 默认前台调用;`continuable` 默认后台调用,并要求提供方具备 `prepareContinuable` 能力 | | `agentOptions` | — | 配置的子级 `provider`、`model`、适配器所有的 `reasoningEffort` 与正整数 `maxTokens` 默认值;要求提供方支持 `agentOptions`,并会覆盖提供方持有的路由默认值 | @@ -65,7 +64,7 @@ kind: "package-reference" ### 选择子级 LLM -设置 `enableModelSelection: true` 可公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。也可以设置 `modelSelectionSettings: true`,在组合每个顶层 Session 时读取宿主的 `subagent-model-selection.enabled` 偏好。该决定会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。这两种模式互斥,且要求后端声明 `agentOptions`;ACP、Codex 与 Claude Code 会拒绝它们,DSH SDK 则支持路由选择。 +设置 `modelSelectionSettings: true`,即可在组合每个顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 一次调用需同时提供 `provider` 与 `model`;当配置值、父 agent 值或提供方持有的默认值能提供路由时,也可只提供推理等级。静态的 `provider.agentRouteDefaults` 在存在时构成提供方/模型基线;工具配置与模型字段会在路由相关强度合并和确切路由预检前覆盖它。没有这些默认值的提供方会使用父 agent 最新已记录请求中的兼容值,再使用父级首次请求前的创建选项,并保留配置的 `maxTokens`。更改路由但未显式提供推理等级时,会清除继承的路由自有等级,使所选模型解析自己的默认值。实时 LLM 适配器在创建子 agent 前校验有效路由。目录成员资格只提供建议,因此适配器接受时,模型可以使用未列出的 id。 @@ -132,7 +131,7 @@ kind: "package-reference" #### 模型看到什么 -当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。模型选择会添加 `provider`、`model` 与 `reasoning_effort`,以及继承和选择指引。工具与提示词描述会随子 agent 是否继承对话而调整。启用后台模式会添加 `run_in_background`:可继续模式记录其默认值为 `true` 及结算通知,一次性模式记录其默认值为 `false` 及任务收集。工具限制会同时移除 schema 与指导 section。 +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。启用的 Session 策略会添加 `provider`、`model` 与 `reasoning_effort`,以及继承和选择指引;提供方必须支持 `agentOptions`。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `job_output` 收集或用 `job_kill` 停止的 job id。当工具在本次组装的作用域中可见时,一个 `tool:` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。 #### Token 影响 @@ -146,7 +145,7 @@ kind: "package-reference" #### 模型看到什么 -静态配置 `enableModelSelection: true` 的实例,或 Session 策略非空的 settings 控制实例,会公开子级 LLM 选择字段与 `list_subagent_models`。可选 `ctx.llm` 服务不可用时,调用会失败。静态启用返回实时适配器目录。settings 控制实例只返回其精确路由策略中的已注册提供方与已公布模型;精确查询也必须先获准,才会解析模型的推理强度与默认值。执行阶段会独立强制同一策略。 +Session 携带策略的 settings 控制实例会公开子级 LLM 选择字段与 `list_subagent_models`。可选 `ctx.llm` 服务不可用时,调用会失败。发现只返回精确路由策略中的已注册提供方与已公布模型;未授权提供方会在调用其适配器目录前被拒绝,精确查询也必须先获准,才会解析模型的推理强度与默认值。执行阶段会独立强制同一策略。 #### Token 影响 @@ -213,8 +212,8 @@ Use subagent in the background by default. Start independent delegations togethe - **后台运行不通过本工具公开结果**——一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束,并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。 - **等待中的一次性实例较晚才发现重复名称**(`TODO(subagent-dup-toolname)`)——可继续实例会在插件应用期间预留提示词 section 名称,但若要阻止等待中的一次性实例回滚提供方注册,仍需要一份预期名称注册表。 -- **随附 fork 工具不能选择子级 LLM 路由**——它们继承父级提供方与模型,使复制的对话前缀仍有资格复用 KV Cache。仅当路由变更能保留复用或公开有界重算成本时,才重新启用选择。 -- **非路由子 agent 策略按实例固定**——另一个 persona、工具过滤器或深度上限需要另一个名称不同的工具。LLM 选择要求静态启用或已启用的逐 Session 偏好,且提供方必须声明 `agentOptions`;ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 +- **随附 fork 工具无法选择子级 LLM 路由**:它们会继承父级的提供方与模型,使复制的对话前缀仍可供 KV Cache 复用。只有在路由变化仍能保留复用,或接口能公开一项有界的重算成本时,才重新启用这些字段。 +- **每个实例的非路由子 agent 策略固定**:其他 persona、工具过滤器或深度上限都需要另一个名称不同的工具。LLM 提供方/模型/推理强度选择要求每 Session 偏好已启用,并要求 subagent 提供方声明 `agentOptions`;进程外提供方目前会拒绝启用它,而不是忽略它。 ### 开发备注 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 5304d0bcc7..30a5f148a1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -54,12 +54,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** @@ -109,7 +106,6 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), - enableModelSelection: z.boolean().default(false), modelSelectionSettings: z.boolean().default(false), enableRunInBackground: z.boolean().default(true), backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'), @@ -317,14 +313,11 @@ export function apply(ctx: Context, config: Config): void { if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) { throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter') } - if (config.enableModelSelection === true && config.modelSelectionSettings === true) { - throw new Error('tool-subagent: `enableModelSelection` and `modelSelectionSettings` are mutually exclusive') - } const backgroundEnabled = config.enableRunInBackground !== false const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' const toolName = config.toolName ?? 'subagent' - const modelSelectionCapable = config.enableModelSelection === true || config.modelSelectionSettings === true + const modelSelectionCapable = config.modelSelectionSettings === true const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { if (typeof config.maxDepth === 'number' && !subagentProvider.capabilities.depthLimit) { @@ -607,7 +600,7 @@ export function apply(ctx: Context, config: Config): void { } if (config.modelSelectionSettings !== true) { - install(ctx, config.enableModelSelection === true ? { kind: 'unrestricted' } : undefined) + install(ctx, undefined) return } @@ -633,12 +626,12 @@ export function apply(ctx: Context, config: Config): void { const parent = ctx.get('agents')?.get(parentId) allowedModels = parent === undefined ? undefined : subagentModelSelectionPolicy(parent.session) } else if (agent.session.firstLiveSeq === 0) { - const current = settings.currentAllowedModels() - allowedModels = current.length === 0 ? undefined : current + const current = settings.current() + allowedModels = current.enabled ? current.allowedModels : undefined } } if (allowedModels !== undefined) recordSubagentModelSelection(agent.session, allowedModels) - return allowedModels === undefined ? undefined : { kind: 'allowlist', routes: allowedModels } + return allowedModels === undefined ? undefined : { routes: allowedModels } } const agent = ctx.agent diff --git a/packages/subagent/tool-subagent/src/list-models.ts b/packages/subagent/tool-subagent/src/list-models.ts index 61158ca103..a1265934d3 100644 --- a/packages/subagent/tool-subagent/src/list-models.ts +++ b/packages/subagent/tool-subagent/src/list-models.ts @@ -12,11 +12,18 @@ interface ListSubagentModelsRequest { } /** Resolve one registered provider with a model-correctable diagnostic. */ -function registeredProvider(llm: LlmRuntime, providerId: string): LlmProviderInfo { +function registeredProvider( + llm: LlmRuntime, + policy: ModelSelectionPolicy, + providerId: string, +): LlmProviderInfo { const providers = llm.listProviders() const provider = providers.find(candidate => candidate.id === providerId) if (provider !== undefined) return provider - const available = providers.map(candidate => candidate.id).join(', ') || '(none)' + const available = providers + .filter(candidate => policy.routes.some(route => route.provider === candidate.id)) + .map(candidate => candidate.id) + .join(', ') || '(none)' throw new Error(`LLM provider "${providerId}" is not registered; available providers: ${available}`) } @@ -40,24 +47,27 @@ async function listSubagentModels( throw new Error('`model` requires `provider`') } if (request.provider === undefined) { - const providers = llm.listProviders().filter(provider => policy.kind === 'unrestricted' - || policy.routes.some(route => route.provider === provider.id)) + const providers = llm.listProviders() + .filter(provider => policy.routes.some(route => route.provider === provider.id)) return providers.length === 0 ? '(no LLM providers)' : providers.map(provider => `${provider.id} — ${provider.name}`).join('\n') } if (request.provider.length === 0) throw new Error('`provider` must be non-empty') - const provider = registeredProvider(llm, request.provider) + const allowedRoutes = policy.routes.filter(route => route.provider === request.provider) + if (allowedRoutes.length === 0) { + throw new Error(`LLM provider "${request.provider}" is not allowed for this Session`) + } + const provider = registeredProvider(llm, policy, request.provider) if (request.model === undefined) { - const models = (await llm.listModels(provider.id)).filter(model => policy.kind === 'unrestricted' - || policy.routes.some(route => route.provider === provider.id && route.model === model.id)) + const models = (await llm.listModels(provider.id)) + .filter(model => allowedRoutes.some(route => route.model === model.id)) return models.length === 0 ? `(no advertised models for ${provider.id})` : models.map(model => modelLine(provider.id, model)).join('\n') } if (request.model.length === 0) throw new Error('`model` must be non-empty') - if (policy.kind === 'allowlist' - && !policy.routes.some(route => route.provider === provider.id && route.model === request.model)) { + if (!allowedRoutes.some(route => route.model === request.model)) { throw new Error(`child LLM route "${provider.id}/${request.model}" is not allowed for this Session`) } const model = await llm.resolveModelInfo(provider.id, request.model, signal) diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts index f12aae331c..74f59a32b4 100644 --- a/packages/subagent/tool-subagent/src/model-selection-settings.ts +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -21,17 +21,22 @@ export const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = settingsNamespace('su /** Stored user preference; the shipped composition defaults it off. */ export interface SubagentModelSelectionSettings { + /** Whether newly composed top-level Sessions receive model selection. */ + enabled: boolean /** Exact child LLM routes offered to newly composed top-level Sessions. */ allowedModels: AllowedModelRoute[] } /** Schema served to settings clients for the opt-in preference. */ export const SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA: z = z.object({ + enabled: z.boolean().default(false), allowedModels: z.array(AllowedModelRouteSchema).default([]), }) /** Optional deployment base for the preference. */ export interface Config { + /** Initial enabled state inherited when the user document does not override it. */ + enabled?: boolean /** Initial route list inherited when the user document does not override it. */ allowedModels?: AllowedModelRoute[] } @@ -39,6 +44,7 @@ export interface Config { /** Singleton settings owner read by delegation tools when an Agent is published. */ export class SubagentModelSelectionConfig extends Service { static Config: z = z.object({ + enabled: z.boolean().default(false), allowedModels: z.array(AllowedModelRouteSchema).default([]), }) @@ -48,8 +54,11 @@ export class SubagentModelSelectionConfig extends Service { super(ctx, 'subagentModelSelection') // Cordis supplies the schema default; the fallback also covers direct construction. /* v8 ignore next */ - const entry: SubagentModelSelectionSettings = { allowedModels: config.allowedModels ?? [] } - assertAllowedModelRoutes(entry.allowedModels) + const entry: SubagentModelSelectionSettings = { + enabled: config.enabled ?? false, + allowedModels: config.allowedModels ?? [], + } + this.validate(entry) this.source = () => entry installSettingsSection( ctx, @@ -58,7 +67,7 @@ export class SubagentModelSelectionConfig extends Service { entry, { setSource: (source) => { this.source = source }, - validate: (value) => { assertAllowedModelRoutes(value.allowedModels) }, + validate: (value) => { this.validate(value) }, // Consumers sample at Agent publication, so a settings update never // rebuilds the tool definitions of an Agent that is already running. onChange: () => {}, @@ -67,11 +76,22 @@ export class SubagentModelSelectionConfig extends Service { } /** - * Read a detached route policy for the next eligible Agent publication. - * @returns exact allowed routes; an empty list disables model-facing selection. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ - currentAllowedModels(): AllowedModelRoute[] { - return this.source().allowedModels.map(route => ({ ...route })) + current(): SubagentModelSelectionSettings { + const current = this.source() + return { + enabled: current.enabled, + allowedModels: current.allowedModels.map(route => ({ ...route })), + } + } + + private validate(value: SubagentModelSelectionSettings): void { + assertAllowedModelRoutes(value.allowedModels) + if (value.enabled && value.allowedModels.length === 0) { + throw new Error('enabled subagent model selection requires at least one allowed model') + } } } diff --git a/packages/subagent/tool-subagent/src/model-selection.ts b/packages/subagent/tool-subagent/src/model-selection.ts index df8251690d..42ed6d3b7c 100644 --- a/packages/subagent/tool-subagent/src/model-selection.ts +++ b/packages/subagent/tool-subagent/src/model-selection.ts @@ -20,9 +20,10 @@ export const AllowedModelRouteSchema: z = z.object({ }) /** Route-selection authority captured by one delegation definition. */ -export type ModelSelectionPolicy = - | { readonly kind: 'unrestricted' } - | { readonly kind: 'allowlist'; readonly routes: readonly AllowedModelRoute[] } +export interface ModelSelectionPolicy { + /** Exact provider/model routes authorized for explicit selection. */ + readonly routes: readonly AllowedModelRoute[] +} /** * Stable identity for one provider/model pair. @@ -132,7 +133,7 @@ export function assertAllowedModelSelection( requested: AgentOptions | undefined, request: DelegationModelRequest, ): void { - if (policy?.kind !== 'allowlist' || !hasDelegationModelRequest(request)) return + if (policy === undefined || !hasDelegationModelRequest(request)) return const provider = requested?.provider ?? parentOptions.provider const model = requested?.model ?? parentOptions.model if (provider === undefined || model === undefined) { diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 918e3a38f6..6fe8b866e4 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -3,10 +3,13 @@ import LlmRuntime, { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' +import SubagentModelSelectionConfig from '../src/model-selection-settings.ts' /** Shared non-aborted tool signal for package-local integration tests. */ export const testToolSignal = new AbortController().signal @@ -18,17 +21,58 @@ export function fakeAgent(id = 'parent-1'): Agent { } /** Mount the real tool and service stack around one scripted subagent provider. */ -export async function setup(toolConfig: tool.Config, mockConfig: Partial = {}): Promise { +const setupAgents = new WeakMap() +let setupAgentCounter = 0 + +/** Test-only opt-in translated to the real Host setting and Session path. */ +type SetupConfig = tool.Config & { withModelSelection?: boolean } + +const TEST_ALLOWED_MODELS = [ + 'allowed-model', 'configured-model', 'current-model', 'fast-model', 'other-model', + 'parent-model', 'unlisted-model', +].flatMap(model => [ + { provider: 'alpha', model }, + { provider: 'current-provider', model }, + { provider: 'missing', model }, +]) + +export async function setup(toolConfig: SetupConfig, mockConfig: Partial = {}): Promise { const ctx = new Context() + const { withModelSelection, ...config } = toolConfig + if (withModelSelection === true) { + await ctx.plugin(SubagentModelSelectionConfig, { + enabled: true, + allowedModels: TEST_ALLOWED_MODELS, + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentRuntime) + await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) + const handle = await ctx.agents.create({ + sessionId: SessionId(`model-selection-setup-${++setupAgentCounter}`), + setup: async (agentCtx) => { + await agentCtx.plugin(tool, { ...config, modelSelectionSettings: true }) + }, + }) + setupAgents.set(ctx, handle.agent) + return ctx + } await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) - await ctx.plugin(tool, toolConfig) + await ctx.plugin(tool, config) return ctx } +/** Return the real Agent created for a settings-controlled setup. */ +export function modelSelectionSetupAgent(ctx: Context): Agent { + const agent = setupAgents.get(ctx) + if (agent === undefined) throw new Error('context has no model-selection setup Agent') + return agent +} + let callCounter = 0 /** Execute the registered subagent tool through the real ToolRuntime pipeline. */ @@ -40,7 +84,7 @@ export function callSubagent( // Distinguish "no override" (use a default agent) from an explicit // `{ agent: undefined }` (test the no-agent path). Under // exactOptionalPropertyTypes the key is omitted rather than set to undefined. - const agent = 'agent' in over ? over.agent : fakeAgent() + const agent = 'agent' in over ? over.agent : setupAgents.get(ctx) ?? fakeAgent() return ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), diff --git a/packages/subagent/tool-subagent/tests/list-models.spec.ts b/packages/subagent/tool-subagent/tests/list-models.spec.ts index c58c775946..d1739dcce8 100644 --- a/packages/subagent/tool-subagent/tests/list-models.spec.ts +++ b/packages/subagent/tool-subagent/tests/list-models.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { ToolCallId, @@ -57,14 +57,18 @@ class CatalogAdapter extends LlmAdapter { } } -async function setupListTool() { +async function setupListTool(routes = [ + { provider: 'alpha', model: 'fast' }, + { provider: 'alpha', model: 'plain' }, + { provider: 'beta', model: 'fast' }, + { provider: 'beta', model: 'plain' }, +]) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - const fiber = await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true }) - return { ctx, fiber } + registerListSubagentModels(ctx, { routes }) + return ctx } async function setupAllowedListTool() { @@ -73,7 +77,6 @@ async function setupAllowedListTool() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) registerListSubagentModels(ctx, { - kind: 'allowlist', routes: [ { provider: 'alpha', model: 'fast' }, { provider: 'alpha', model: 'unlisted' }, @@ -110,23 +113,21 @@ describe('list_subagent_models', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) - await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true }) + registerListSubagentModels(ctx, { routes: [{ provider: 'alpha', model: 'fast' }] }) const result = await call(ctx, {}) expect(result.isError).toBe(true) expect(text(result)).toContain('`llm` service is unavailable') }) it('rejects two discovery-owning instances in one tool scope', async () => { - const { ctx } = await setupListTool() - await expect(ctx.plugin(tool, { - provider: 'another-unused', - toolName: 'subagent_other', - enableModelSelection: true, - }).then(() => undefined)).rejects.toThrow('tool "list_subagent_models" is already registered') + const ctx = await setupListTool() + expect(() => { + registerListSubagentModels(ctx, { routes: [{ provider: 'alpha', model: 'fast' }] }) + }).toThrow('tool "list_subagent_models" is already registered') }) it('lists registered providers and follows live registration changes', async () => { - const { ctx, fiber } = await setupListTool() + const ctx = await setupListTool() const empty = await call(ctx, {}) expect(empty.isError).toBe(false) expect(text(empty)).toBe('(no LLM providers)') @@ -140,12 +141,13 @@ describe('list_subagent_models', () => { const changed = await call(ctx, {}) expect(text(changed)).toBe('beta — BETA API') - await fiber.dispose() - expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + const tools = ctx.tools + await ctx.fiber.dispose() + expect(tools.get('list_subagent_models')).toBeUndefined() }) it('lists one provider\'s advertised models without treating the catalog as a whitelist', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool() ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) const result = await call(ctx, { provider: 'alpha' }) expect(result.isError).toBe(false) @@ -166,8 +168,21 @@ describe('list_subagent_models', () => { expect(text(denied)).toContain('is not allowed for this Session') }) + it('rejects an unauthorized provider before calling its adapter catalog', async () => { + const ctx = await setupListTool([{ provider: 'alpha', model: 'fast' }]) + const adapter = new CatalogAdapter() + const listModels = vi.spyOn(adapter, 'listModels') + ctx.llm.registerAdapter(['alpha', 'secret'], adapter) + + const result = await call(ctx, { provider: 'secret' }) + + expect(result.isError).toBe(true) + expect(text(result)).toContain('provider "secret" is not allowed for this Session') + expect(listModels).not.toHaveBeenCalled() + }) + it('renders an empty advertised model list', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool([{ provider: 'alpha', model: 'fast' }]) ctx.llm.registerAdapter(['alpha'], new CatalogAdapter(true)) const result = await call(ctx, { provider: 'alpha' }) expect(result.isError).toBe(false) @@ -175,8 +190,8 @@ describe('list_subagent_models', () => { }) it('inspects exact-model efforts, descriptions, and defaults', async () => { - const { ctx } = await setupListTool() - ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const ctx = await setupListTool() + ctx.llm.registerAdapter(['alpha', 'secret'], new CatalogAdapter()) const result = await call(ctx, { provider: 'alpha', model: 'fast' }) expect(result.isError).toBe(false) expect(text(result)).toBe( @@ -186,7 +201,7 @@ describe('list_subagent_models', () => { }) it('renders exact models without reasoning metadata', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool() ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) const result = await call(ctx, { provider: 'alpha', model: 'plain' }) expect(result.isError).toBe(false) @@ -196,16 +211,16 @@ describe('list_subagent_models', () => { it.each([ { args: { model: 'fast' }, expected: '`model` requires `provider`' }, { args: { provider: '' }, expected: '`provider` must be non-empty' }, - { args: { provider: 'missing' }, expected: 'available providers: (none)' }, + { args: { provider: 'missing' }, expected: 'is not allowed for this Session' }, ])('rejects incomplete or unavailable provider requests', async ({ args, expected }) => { - const { ctx } = await setupListTool() + const ctx = await setupListTool() const result = await call(ctx, args) expect(result.isError).toBe(true) expect(text(result)).toContain(expected) }) it('rejects an empty exact model after resolving the provider', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool() ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) const result = await call(ctx, { provider: 'alpha', model: '' }) expect(result.isError).toBe(true) @@ -213,10 +228,14 @@ describe('list_subagent_models', () => { }) it('reports registered alternatives for an unavailable provider', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool([ + { provider: 'alpha', model: 'fast' }, + { provider: 'missing', model: 'fast' }, + ]) ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) const result = await call(ctx, { provider: 'missing' }) expect(result.isError).toBe(true) expect(text(result)).toContain('available providers: alpha') + expect(text(result)).not.toContain('secret') }) }) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 7cd0926bdb..b5ecac11b8 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -85,9 +85,9 @@ async function createAgent(ctx: Context, id: string, options: { describe('SubagentModelSelectionConfig', () => { it('uses the composed default without a settings provider', async () => { const ctx = new Context() - await ctx.plugin(SubagentModelSelectionConfig, { allowedModels: ALLOWED_MODELS }) + await ctx.plugin(SubagentModelSelectionConfig, { enabled: true, allowedModels: ALLOWED_MODELS }) - expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual(ALLOWED_MODELS) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: true, allowedModels: ALLOWED_MODELS }) await ctx.fiber.dispose() }) @@ -96,13 +96,16 @@ describe('SubagentModelSelectionConfig', () => { await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) - expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual([]) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) - expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual(ALLOWED_MODELS) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: true, allowedModels: ALLOWED_MODELS }) await ctx.fiber.dispose() }) - it('rejects duplicate routes and an empty durable policy', async () => { + it('rejects duplicate routes, enabled empty settings, and an empty durable policy', async () => { const ctx = new Context() await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) @@ -111,6 +114,18 @@ describe('SubagentModelSelectionConfig', () => { allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS], })).rejects.toThrow('repeats route "alpha/fast-model"') + await expect(ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: [], + })).rejects.toThrow('enabled subagent model selection requires at least one allowed model') + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: false, + allowedModels: ALLOWED_MODELS, + }) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: [] }) + const invalid = Session.create(SessionId('empty-policy')) invalid.append('subagent/model-selection-policy', { allowedModels: [] }) expect(() => subagentModelSelectionPolicy(invalid)).toThrow('requires at least one route') @@ -123,13 +138,16 @@ describe('SubagentModelSelectionConfig', () => { expect(selectable(ctx, disabled)).toBe(false) expect(subagentModelSelectionPolicy(disabled.session)).toBeUndefined() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const enabled = await createAgent(ctx, 'enabled') expect(subagentModelSelectionPolicy(enabled.session)).toEqual(ALLOWED_MODELS) expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) const disabledAgain = await createAgent(ctx, 'disabled-again') expect(selectable(ctx, disabledAgain)).toBe(false) expect(selectable(ctx, enabled)).toBe(true) @@ -138,7 +156,10 @@ describe('SubagentModelSelectionConfig', () => { it('rejects a forced route outside the Session policy before child creation', async () => { const ctx = await boot() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const agent = await createAgent(ctx, 'enforced') const result = await ctx.tools.execute({ @@ -180,7 +201,10 @@ describe('SubagentModelSelectionConfig', () => { const disabled = await createComposed('preset-disabled') expect(selectable(ctx, disabled.agent)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const enabled = await createComposed('preset-enabled') expect(selectable(ctx, enabled.agent)).toBe(true) expect(selectable(ctx, disabled.agent)).toBe(false) @@ -200,9 +224,12 @@ describe('SubagentModelSelectionConfig', () => { it('inherits the parent decision and preserves seeded decisions across composition', async () => { const ctx = await boot() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const parent = await createAgent(ctx, 'parent') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) const child = await createAgent(ctx, 'child', { meta: { parentSession: parent.id, origin: 'subagent' }, }) @@ -220,27 +247,16 @@ describe('SubagentModelSelectionConfig', () => { expect(selectable(ctx, resumedEnabled)).toBe(true) const oldSeed = Session.create(SessionId('old-seed'), []) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) expect(selectable(ctx, resumedDisabled)).toBe(false) expect(subagentModelSelectionPolicy(resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() }) - it('rejects ambiguous static and settings-controlled configuration', async () => { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SubagentRuntime) - expect(() => { - tool.apply(ctx, { - provider: 'missing', - enableModelSelection: true, - modelSelectionSettings: true, - }) - }).toThrow('mutually exclusive') - await ctx.fiber.dispose() - }) - it('requires both the Host setting owner and a composition scope', async () => { const withoutSettings = new Context() await mountAgentLoopTestDependencies(withoutSettings) @@ -286,7 +302,10 @@ describe('SubagentModelSelectionConfig', () => { await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) .rejects.toThrow('must expose route fields and list_subagent_models') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const enabled = await createAgent(ctx, 'invariant-enabled') await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 710747bacc..32072573fa 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -11,7 +11,7 @@ import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' import { assertAllowedModelRoutes, assertAllowedModelSelection } from '../src/model-selection.ts' -import { callSubagent, setup, text } from './harness.ts' +import { callSubagent, modelSelectionSetupAgent, setup, text } from './harness.ts' const REASONING = { efforts: [ @@ -42,7 +42,6 @@ describe('dsh-tool-subagent model selection', () => { it('allows pure inheritance but rejects explicit values outside a Session allowlist', () => { const policy = { - kind: 'allowlist' as const, routes: [{ provider: 'alpha', model: 'allowed-model' }], } const parent = { provider: 'alpha', model: 'parent-model' } @@ -81,9 +80,28 @@ describe('dsh-tool-subagent model selection', () => { ) }).toThrow('without an effective provider and model') }) - it('exposes static route fields and discovery when selection is enabled', async () => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }) - const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + + it('leaves deployment or parent defaults outside the allowlist usable when the call selects nothing', async () => { + let starts = 0 + const ctx = await setup( + { provider: 'mock', withModelSelection: true }, + { onStart: () => { starts += 1 } }, + ) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = { + provider: 'deployment-provider', + model: 'deployment-model', + } + + const result = await callSubagent(ctx, { description: 'default route', prompt: 'do it' }) + + expect(result.isError).toBe(false) + expect(starts).toBe(1) + }) + it('exposes Session-authorized route fields and discovery when selection is enabled', async () => { + const ctx = await setup({ provider: 'mock', withModelSelection: true }) + const agent = modelSelectionSetupAgent(ctx) + const schema = ctx.tools.schemas(agent).find(entry => entry.name === 'subagent')! const props = (schema.parameters as { properties?: Record }).properties ?? {} expect(Object.keys(props).sort()).toEqual([ 'description', @@ -94,13 +112,13 @@ describe('dsh-tool-subagent model selection', () => { 'run_in_background', ]) expect(schema.description).toContain('list_subagent_models') - expect(ctx.tools.get('list_subagent_models')).toBeDefined() + expect(ctx.tools.get('list_subagent_models', agent)).toBeDefined() expect(schema.description).not.toContain('alpha') const registration = ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) - const definition = ctx.tools.get('subagent') + const definition = ctx.tools.get('subagent', agent) registration.replace(['beta']) - expect(ctx.tools.get('subagent')).toBe(definition) + expect(ctx.tools.get('subagent', agent)).toBe(definition) expect(definition?.description).not.toContain('beta') }) @@ -124,7 +142,7 @@ describe('dsh-tool-subagent model selection', () => { it('rejects enabled model selection when the provider cannot apply Agent options', async () => { await expect(setup( - { provider: 'mock', enableModelSelection: true, maxDepth: 'provider-managed' }, + { provider: 'mock', withModelSelection: true, maxDepth: 'provider-managed' }, { capabilities: { agentOptions: false } }, )).rejects.toThrow('provider "mock" does not support child model selection') }) @@ -133,7 +151,7 @@ describe('dsh-tool-subagent model selection', () => { const requests: SubagentStartRequest[] = [] const ctx = await setup({ provider: 'mock', - enableModelSelection: true, + withModelSelection: true, agentOptions: { provider: 'alpha', model: 'configured-model', @@ -142,6 +160,8 @@ describe('dsh-tool-subagent model selection', () => { }, }, { onStart: (request) => { requests.push(request) } }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options const selected = await callSubagent(ctx, { description: 'route work', @@ -176,38 +196,44 @@ describe('dsh-tool-subagent model selection', () => { const requests: SubagentStartRequest[] = [] const ctx = await setup({ provider: 'mock', - enableModelSelection: true, + withModelSelection: true, agentOptions: { provider: 'alpha' }, }, { onStart: (request) => { requests.push(request) } }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options const result = await callSubagent(ctx, { description: 'effort work', prompt: 'do it', reasoning_effort: 'low', - }, { agent: parentWithRoute() }) + }) expect(result.isError).toBe(false) expect(requests[0]?.agentOptions).toEqual({ provider: 'alpha', reasoningEffort: 'low' }) - const inherited = await setup({ provider: 'mock', enableModelSelection: true }) + const inherited = await setup({ provider: 'mock', withModelSelection: true }) inherited.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const inheritedParent = modelSelectionSetupAgent(inherited) + ;(inheritedParent as unknown as { options: Agent['options'] }).options = parentWithRoute().options const inheritedResult = await callSubagent(inherited, { description: 'parent effort work', prompt: 'do it', reasoning_effort: 'low', - }, { agent: parentWithRoute() }) + }) expect(inheritedResult.isError).toBe(false) }) it('inherits a parent effort only when an explicit route stays unchanged', async () => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options const result = await callSubagent(ctx, { description: 'same route work', prompt: 'do it', provider: 'alpha', model: 'parent-model', - }, { agent: parentWithRoute() }) + }) expect(result.isError).toBe(false) }) @@ -215,11 +241,14 @@ describe('dsh-tool-subagent model selection', () => { const requests: SubagentStartRequest[] = [] const ctx = await setup({ provider: 'mock', - enableModelSelection: true, + withModelSelection: true, agentOptions: { reasoningEffort: ReasoningEffortId('high') }, }, { onStart: (request) => { requests.push(request) } }) ctx.llm.registerAdapter(['current-provider'], new MockAdapter([], REASONING)) - const parent = parentWithRoute({ provider: 'created-provider', model: 'created-model' }) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = { + provider: 'created-provider', model: 'created-model', + } parent.session.append('request/header', { header: { config: { provider: 'current-provider', model: 'current-model' } }, reason: 'initial', @@ -230,7 +259,7 @@ describe('dsh-tool-subagent model selection', () => { prompt: 'do it', provider: 'current-provider', model: 'current-model', - }, { agent: parent }) + }) expect(result.isError).toBe(false) expect(requests[0]?.agentOptions).toEqual({ @@ -241,7 +270,7 @@ describe('dsh-tool-subagent model selection', () => { }) it('rejects an effort without any effective route', async () => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }) const result = await callSubagent(ctx, { description: 'missing route', prompt: 'do it', @@ -256,7 +285,7 @@ describe('dsh-tool-subagent model selection', () => { { model: 'fast-model' }, ])('rejects a partial model-facing route before child creation', async (route) => { let starts = 0 - const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } }) const result = await callSubagent(ctx, { description: 'partial route', prompt: 'do it', ...route }) expect(result.isError).toBe(true) expect(text(result)).toContain('`provider` and `model` must be supplied together') @@ -268,7 +297,7 @@ describe('dsh-tool-subagent model selection', () => { { provider: 'alpha', model: '', expected: '`model` must be non-empty' }, { reasoning_effort: '', expected: '`reasoning_effort` must be non-empty' }, ])('rejects empty model-facing values', async ({ expected, ...selection }) => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }) const result = await callSubagent(ctx, { description: 'empty route', prompt: 'do it', ...selection }) expect(result.isError).toBe(true) expect(text(result)).toContain(expected) @@ -276,7 +305,7 @@ describe('dsh-tool-subagent model selection', () => { it('uses the LLM runtime for provider and reasoning-effort validation before child creation', async () => { let starts = 0 - const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) const unsupported = await callSubagent(ctx, { @@ -351,7 +380,6 @@ describe('dsh-tool-subagent model selection', () => { await mock.mountScriptedProvider(ctx, { name: 'mock' }) await ctx.plugin(tool, { provider: 'mock', - enableModelSelection: true, agentOptions: { provider: 'alpha', model: 'fast-model', @@ -363,14 +391,6 @@ describe('dsh-tool-subagent model selection', () => { expect(configured.isError).toBe(true) expect(text(configured)).toContain('`llm` service is unavailable') - const selected = await callSubagent(ctx, { - description: 'selected route', - prompt: 'do it', - provider: 'alpha', - model: 'other-model', - }) - expect(selected.isError).toBe(true) - expect(text(selected)).toContain('`llm` service is unavailable') }) it('keeps pure inherited routing usable without an LLM service lookup', async () => { @@ -388,15 +408,15 @@ describe('dsh-tool-subagent model selection', () => { }) it('warns that changing a fork route can lose inherited-prefix reuse', async () => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { inheritsParentContext: true }) - const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + const ctx = await setup({ provider: 'mock', withModelSelection: true }, { inheritsParentContext: true }) + const schema = ctx.tools.schemas(modelSelectionSetupAgent(ctx)).find(entry => entry.name === 'subagent')! expect(schema.description).toContain('inherits this conversation') expect(schema.description).toContain('can prevent provider-side reuse of the inherited conversation prefix') }) it('propagates an exact-route resolver failure before child creation', async () => { let starts = 0 - const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } }) const adapter = new MockAdapter([]) vi.spyOn(adapter, 'resolveModel').mockRejectedValue(new Error('selected route unavailable')) ctx.llm.registerAdapter(['alpha'], adapter) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 4243581e23..cff7140a25 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -964,7 +964,10 @@ describe('dsh-tool-subagent background mode', () => { }) it('skips background startup when cancellation wins asynchronous route preflight', async () => { - const ctx = await backgroundSetup({ provider: 'mock', enableModelSelection: true }) + const ctx = await backgroundSetup({ + provider: 'mock', + agentOptions: { provider: 'alpha', model: 'selected-model' }, + }) const parent = ownerAgent(ctx, 'sess-parent') const adapter = new MockAdapter([]) let releasePreflight!: () => void @@ -979,8 +982,6 @@ describe('dsh-tool-subagent background mode', () => { const resultPromise = callSubagent(ctx, { description: 'cancelled selection', prompt: 'do it', - provider: 'alpha', - model: 'selected-model', run_in_background: true, }, { agent: parent, signal: controller.signal }) await vi.waitFor(() => { expect(resolveModel).toHaveBeenCalledOnce() }) diff --git a/packages/test-support/client-runtime/src/settings-scope.ts b/packages/test-support/client-runtime/src/settings-scope.ts index 86566bb33a..0f325a54b0 100644 --- a/packages/test-support/client-runtime/src/settings-scope.ts +++ b/packages/test-support/client-runtime/src/settings-scope.ts @@ -10,6 +10,8 @@ export interface StubSettingsScope { scope: SettingsScope /** Spy behind `scope.set`; resolves immediately. */ set: ReturnType + /** Spy behind `scope.mutate`; resolves immediately. */ + mutate: ReturnType /** Spy behind `scope.unset`; resolves immediately. */ unset: ReturnType /** @returns how many listeners are currently subscribed (disposal assertions). */ @@ -34,6 +36,7 @@ export function stubSettingsScope(): StubSettingsScope { } const listeners = new Set<() => void>() const set = vi.fn(() => Promise.resolve()) + const mutate = vi.fn(() => Promise.resolve()) const unset = vi.fn(() => Promise.resolve()) return { scope: { @@ -42,10 +45,12 @@ export function stubSettingsScope(): StubSettingsScope { listeners.add(listener) return () => { listeners.delete(listener) } }, + mutate, set, unset, }, set, + mutate, unset, listenerCount: () => listeners.size, publish: (next) => { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7194655904..2392962be1 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -241,6 +241,7 @@ export const LINK_MAP: Readonly> = { AgentHandle: 'core.md', ModelSelection: 'core.md', AllowedModelRoute: 'subagent.md', + SubagentModelSelectionSettings: 'subagent.md', AgentOptions: 'core.md', AgentStatus: 'core.md', ContentBlock: 'llm-streaming.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index faa9422877..6cfbc4d618 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -63,6 +63,7 @@ import type TeamService from '@deepseek-ai/dsh-experimental-agent-team' import * as ToolTeam from '@deepseek-ai/dsh-experimental-tool-agent-team' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' +import { registerListSubagentModels } from '../packages/subagent/tool-subagent/src/list-models.ts' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' @@ -467,10 +468,11 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(SubagentRuntime) await ctx.plugin(LlmRuntime) registerCatalogSubagentProvider(ctx, 'mock') - await ctx.plugin(ToolSubagent, { provider: 'mock', enableModelSelection: true }) + await ctx.plugin(ToolSubagent, { provider: 'mock' }) + registerListSubagentModels(ctx, { routes: [{ provider: 'mock', model: 'mock' }] }) }, note: - 'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', + 'The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', diff --git a/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl b/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl deleted file mode 100644 index 0b49c17e82..0000000000 --- a/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"node -e \"const fs=require('node:fs'); fs.writeFileSync('started.tmp', 'started'); fs.renameSync('started.tmp', 'started.txt'); setInterval(() => {}, 1000)\"","description":"Wait until cancellation"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf skipped > skipped.txt","description":"Write skipped marker"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skipped","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted before dispatch"}}]}}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/snapshots/acp/escalation-approved/cordis.yml b/snapshots/acp/escalation-approved/cordis.yml index 0f07b6fec5..e08f3005c3 100644 --- a/snapshots/acp/escalation-approved/cordis.yml +++ b/snapshots/acp/escalation-approved/cordis.yml @@ -54,7 +54,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 1 diff --git a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json index 6273e6b106..a3c5b2e531 100644 --- a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -477,7 +460,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -489,18 +472,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json index 6273e6b106..a3c5b2e531 100644 --- a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -477,7 +460,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -489,18 +472,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json index 6273e6b106..a3c5b2e531 100644 --- a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -477,7 +460,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -489,18 +472,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/sdk/subagent-report/tool-schemas.1.expected.json b/snapshots/sdk/subagent-report/tool-schemas.1.expected.json index 6273e6b106..a3c5b2e531 100644 --- a/snapshots/sdk/subagent-report/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-report/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -477,7 +460,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -489,18 +472,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/both-mode-turn/system-prompt.expected.md b/snapshots/session/both-mode-turn/system-prompt.expected.md index ba32b06baf..9efdde3cf9 100644 --- a/snapshots/session/both-mode-turn/system-prompt.expected.md +++ b/snapshots/session/both-mode-turn/system-prompt.expected.md @@ -134,13 +134,6 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ - list_subagent_models: { - /** Registered LLM provider id. Omit to list providers. */ - provider?: string; - /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ - model?: string; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -191,18 +184,12 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[] | null; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ - provider?: string; - /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ - model?: string; - /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ - reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -414,7 +401,6 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; - list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/snapshots/session/both-mode-turn/tool-schemas.expected.json b/snapshots/session/both-mode-turn/tool-schemas.expected.json index 5668ee9294..9893105262 100644 --- a/snapshots/session/both-mode-turn/tool-schemas.expected.json +++ b/snapshots/session/both-mode-turn/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -482,7 +465,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -494,18 +477,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/code-mode-read-image/system-prompt.expected.md b/snapshots/session/code-mode-read-image/system-prompt.expected.md index 1690867b36..3e09ce8079 100644 --- a/snapshots/session/code-mode-read-image/system-prompt.expected.md +++ b/snapshots/session/code-mode-read-image/system-prompt.expected.md @@ -136,13 +136,6 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ - list_subagent_models: { - /** Registered LLM provider id. Omit to list providers. */ - provider?: string; - /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ - model?: string; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -193,18 +186,12 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[] | null; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ - provider?: string; - /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ - model?: string; - /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ - reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -416,7 +403,6 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; - list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/snapshots/session/code-mode-turn/system-prompt.expected.md b/snapshots/session/code-mode-turn/system-prompt.expected.md index ddfe1c8024..f297328c55 100644 --- a/snapshots/session/code-mode-turn/system-prompt.expected.md +++ b/snapshots/session/code-mode-turn/system-prompt.expected.md @@ -136,13 +136,6 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ - list_subagent_models: { - /** Registered LLM provider id. Omit to list providers. */ - provider?: string; - /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ - model?: string; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -193,18 +186,12 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[] | null; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ - provider?: string; - /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ - model?: string; - /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ - reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -416,7 +403,6 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; - list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md index a0dfdc2277..843da9da21 100644 --- a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md +++ b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md @@ -301,13 +301,6 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ - list_subagent_models: { - /** Registered LLM provider id. Omit to list providers. */ - provider?: string; - /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ - model?: string; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -358,18 +351,12 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[] | null; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ - provider?: string; - /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ - model?: string; - /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ - reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -600,7 +587,6 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; - list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json index 9faf8c3d89..3a0073f42a 100644 --- a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json +++ b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json @@ -457,23 +457,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -679,7 +662,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -691,18 +674,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json index 4f943e54bf..47769041bd 100644 --- a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json +++ b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json @@ -180,23 +180,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -365,7 +348,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -377,18 +360,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/lsp-definition/tool-schemas.expected.json b/snapshots/session/lsp-definition/tool-schemas.expected.json index 818a268882..05543b4012 100644 --- a/snapshots/session/lsp-definition/tool-schemas.expected.json +++ b/snapshots/session/lsp-definition/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "lsp", "description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.", @@ -498,7 +481,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -510,18 +493,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/product-subagent-both/tool-schemas.expected.json b/snapshots/session/product-subagent-both/tool-schemas.expected.json index fe34e29475..e457070e01 100644 --- a/snapshots/session/product-subagent-both/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-both/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/product-subagent-codex/tool-schemas.expected.json b/snapshots/session/product-subagent-codex/tool-schemas.expected.json index 5efa018df1..bc41f13b88 100644 --- a/snapshots/session/product-subagent-codex/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-codex/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json index 6752716683..2072fb3d15 100644 --- a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json index 7714ecf3a5..144d2309e9 100644 --- a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json +++ b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/session-query-spill/tool-schemas.expected.json b/snapshots/session/session-query-spill/tool-schemas.expected.json index 62b603d80c..3e28d4cfcb 100644 --- a/snapshots/session/session-query-spill/tool-schemas.expected.json +++ b/snapshots/session/session-query-spill/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -665,7 +648,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -677,18 +660,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json index 76f213f7c8..7e4dfe696c 100644 --- a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json +++ b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json @@ -323,23 +323,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -524,7 +507,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -536,18 +519,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/subagent-configured-effort-rejection/cordis.snapshot.yml b/snapshots/session/subagent-configured-effort-rejection/cordis.snapshot.yml deleted file mode 100644 index 81eb6dfa49..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/cordis.snapshot.yml +++ /dev/null @@ -1,66 +0,0 @@ -# Keyless counterpart to subagent-configured-effort.cordis.yml: disable the -# live adapter, insert replay, and apply the configured-effort rejection patch. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - config: - runnerCommand: - - bash - - -c - - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" - - passthrough-runner - runnerFailureSignatures: - - 'passthrough-runner: profile rejected' - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - enableModelSelection: true - backgroundMode: continuable - maxDepth: 1 - agentOptions: - provider: deepseek-official - model: deepseek-v4-flash - reasoningEffort: unsupported - -# Select the recorded flash model for this composition. -- id: agent-default-model - name: '@deepseek-ai/dsh-agent-default-model' - config: - provider: deepseek-official - model: deepseek-v4-flash - -- id: session-persistence-jsonl - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js dshHomePath('sessions') - compression: none - -- id: agent-instructions - name: '@deepseek-ai/dsh-agent-instructions' - config: - maxBytes: 65536 - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - persona: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - - Verify your work by running the code or tests. Keep answers brief and factual. - -- 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 diff --git a/snapshots/session/subagent-configured-effort-rejection/cordis.yml b/snapshots/session/subagent-configured-effort-rejection/cordis.yml deleted file mode 100644 index 9042fd3d91..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/cordis.yml +++ /dev/null @@ -1,15 +0,0 @@ -# Configured-effort rejection snapshot overlay: keep one invalid configured -# effort so the tool rejects before starting a child instead of deferring the -# failure to the child agent loop. -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - enableModelSelection: true - backgroundMode: continuable - maxDepth: 1 - agentOptions: - provider: deepseek-official - model: deepseek-v4-flash - reasoningEffort: unsupported diff --git a/snapshots/session/subagent-configured-effort-rejection/replay.override.json b/snapshots/session/subagent-configured-effort-rejection/replay.override.json deleted file mode 100644 index 0ac208bb71..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/replay.override.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "kind": "chunks", - "chunks": [ - { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_list_child_model", "name": "list_subagent_models", "argumentsDelta": "{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_list_child_model", "name": "list_subagent_models", "arguments": "{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}" } }, - { "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_configured_effort", "name": "subagent", "argumentsDelta": "{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_configured_effort", "name": "subagent", "arguments": "{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"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": "text" }, - { "type": "text-delta", "index": 0, "text": "CONFIGURED_EFFORT_REJECTED" }, - { "type": "block-end", "index": 0, "block": { "type": "text", "text": "CONFIGURED_EFFORT_REJECTED" } }, - { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, - { "type": "finish", "reason": { "kind": "stop" } } - ] - } -] diff --git a/snapshots/session/subagent-configured-effort-rejection/session.jsonl b/snapshots/session/subagent-configured-effort-rejection/session.jsonl deleted file mode 100644 index 4038864900..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/session.jsonl +++ /dev/null @@ -1,41 +0,0 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} -{"type":"approval/policy","data":{"policy":"never"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} -{"type":"turn/start","data":{"turn":1}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} -{"type":"user/message","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":"{{message:2}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Inspect the configured child model,","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_list_child_model","name":"list_subagent_models","argumentsDelta":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_list_child_model"},"content":[{"type":"tool-result","toolCallId":"call_list_child_model","content":[{"type":"text","text":"deepseek-official/deepseek-v4-flash — deepseek-v4-flash\nReasoning efforts:\n(no advertised reasoning efforts)"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":1}} -{"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_configured_effort","name":"subagent","argumentsDelta":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_configured_effort"},"content":[{"type":"tool-result","toolCallId":"call_configured_effort","content":[{"type":"text","text":"Error: provider \"deepseek-official\" model \"deepseek-v4-flash\" does not support reasoning effort \"unsupported\""}],"isError":true}],"role":"user","id":"{{message:6}}"},"error":{"name":"LlmError","code":"UNSUPPORTED_REASONING_EFFORT"}},"sourceEventSeqs":[28],"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":2}} -{"type":"step/start","data":{"turn":1,"step":3}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"CONFIGURED_EFFORT_REJECTED"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":3}} -{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml b/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml index 9e03d56d57..e46f117103 100644 --- a/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml +++ b/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml @@ -20,7 +20,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 2 # Select the recorded flash model for this composition. diff --git a/snapshots/session/subagent-depth-two-rejection/cordis.yml b/snapshots/session/subagent-depth-two-rejection/cordis.yml index d25b48fb60..b2d4dbe039 100644 --- a/snapshots/session/subagent-depth-two-rejection/cordis.yml +++ b/snapshots/session/subagent-depth-two-rejection/cordis.yml @@ -5,6 +5,5 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 2 diff --git a/snapshots/session/text-turn/tool-schemas.expected.json b/snapshots/session/text-turn/tool-schemas.expected.json index 4922f06fdf..c93fb6d65f 100644 --- a/snapshots/session/text-turn/tool-schemas.expected.json +++ b/snapshots/session/text-turn/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/web-fetch/tool-schemas.expected.json b/snapshots/session/web-fetch/tool-schemas.expected.json index 85c8e3bf4a..ee18836349 100644 --- a/snapshots/session/web-fetch/tool-schemas.expected.json +++ b/snapshots/session/web-fetch/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." From 1ea72339fdde2ba64318131e0352fb221fecee25 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 12:00:15 +0800 Subject: [PATCH 19/38] fix(web): close model switch review gaps --- .../client/ui-settings-models/src/client/store.ts | 13 ++----------- .../ui-settings-models/tests/store.client.spec.ts | 10 +--------- .../client/ui-settings-plugins/src/client/index.ts | 3 --- 3 files changed, 3 insertions(+), 23 deletions(-) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index d3b71ec6ea..6f664bd068 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -137,24 +137,15 @@ export class ModelsSettingsStore { private generation = 0 /** - * @param api - the page's wire faces (credentials Remote, llm reads, settings writes). + * @param api - the page's credentials Remote and LLM wire faces. * @param describeFace - the shared mirror's describe face (namespace views and writability). */ constructor( - private readonly api: ModelsWire, + private readonly api: Pick, private readonly schema: SettingsSchemaOperations, private readonly describeFace: SettingsDescribeFace, ) {} - /** - * Fold one successful settings write into the shared mirror before rejoining - * this page's rows. - * @param view - namespace view returned by the settings wire method. - */ - acceptNamespace(view: SettingsNamespaceView): void { - this.describeFace.acceptView(view) - } - /** * Refresh the whole page snapshot: the provider directory and the mirror's * settings answer in parallel, then one batched credential describe over 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 c9745db2b7..677ca14418 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -1,5 +1,5 @@ /** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -84,14 +84,6 @@ function api(overrides: { } describe('ModelsSettingsStore', () => { - it('forwards accepted writes into the shared settings mirror', () => { - const { face } = api() - const acceptView = vi.fn() - const store = new ModelsSettingsStore(face, settingsSchema, { acceptView } as never) - store.acceptNamespace(NAMESPACES[0]!) - expect(acceptView).toHaveBeenCalledWith(NAMESPACES[0]) - }) - it('joins rows with configured, removable, and credential state', async () => { const { face, mirror, seenRefs } = api() const store = new ModelsSettingsStore(face, settingsSchema, mirror) diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 6533d97fc0..a041ed1669 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -48,9 +48,6 @@ export type { export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts' export type { BashCardFace, BashCardState } from './bash-card-controller.ts' export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts' -export type { - SubagentModelSelectionCardFace, SubagentModelSelectionCardState, -} from './subagent-model-selection-card-controller.ts' /** Dictionary namespace owned by this plugin. */ const NS = 'settings.plugins' From f2bb5cef05e7dcb582581421c67e60f997c8dce3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 16:09:22 +0800 Subject: [PATCH 20/38] fix(snapshot): stabilize workflow prompt order --- packages/workflow/tool-workflow/tests/tool-workflow.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 959a3a3c8c..5026248c40 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -379,6 +379,10 @@ describe('dsh-tool-workflow', () => { const section = sections.find(s => s.name === 'tool:orchestrate') expect(section?.text).toContain('orchestrate') expect(sections.some(s => s.name === 'tool:workflow')).toBe(false) + ctx.systemPrompt.section({ name: 'tool:cordis-order-probe', order: 115.5, text: 'Cordis' }) + expect((await ctx.systemPrompt.assemble()).sections + .filter(s => s.name === 'tool:cordis-order-probe' || s.name === 'tool:orchestrate') + .map(s => s.name)).toEqual(['tool:cordis-order-probe', 'tool:orchestrate']) await fiber.dispose() expect(ctx.tools.get('orchestrate')).toBeUndefined() // …and gone with the fiber — a reload must not leak a stale section. From 9fae988691af140d09bc7cacf48e24bc053c50bf Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 16:18:52 +0800 Subject: [PATCH 21/38] test(sdk): expect model discovery off by default --- apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 7829b67adc..7ef63f67a5 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -153,7 +153,7 @@ describe('Python SDK dsh profile keyless smoke', () => { const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] expect(modelRequests[0]?.reasoning_effort).toBe('max') expect(modelRequests[0]?.max_tokens).toBe(1234) - expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models') + expect(tools.map(tool => tool.function?.name)).not.toContain('list_subagent_models') child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) From 3a146064a4bdab9bba2ae36d7f8742053a1adc54 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 20:05:12 +0800 Subject: [PATCH 22/38] fix: address subagent model selection review --- ...8-model-selected-subagent-routes.i18n.yaml | 4 +- ...26-08-18-model-selected-subagent-routes.md | 2 +- ...08-18-model-selected-subagent-routes.zh.md | 2 +- ...authorized-subagent-model-routes.i18n.yaml | 4 +- ...4-user-authorized-subagent-model-routes.md | 3 +- ...ser-authorized-subagent-model-routes.zh.md | 3 +- .../ui-settings-plugins/src/client/index.ts | 16 +++- ...ubagent-model-selection-card-controller.ts | 41 +++++++- .../tests/apply.client.spec.ts | 32 ++++++- .../tests/stores.client.spec.ts | 93 ++++++++++++++++++- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../src/client/settings-contract.ts | 7 +- .../ui-settings/src/client/settings-scope.ts | 5 +- .../tests/settings-scope.client.spec.ts | 24 +++++ packages/subagent/tool-subagent/src/index.ts | 14 ++- .../src/model-selection-state.ts | 5 +- .../tool-subagent/src/model-selection.ts | 19 +++- .../tests/model-selection-settings.spec.ts | 41 ++++++++ .../tests/model-selection.spec.ts | 4 + 21 files changed, 287 insertions(+), 40 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index 878ed45e35..5e0f82335f 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: 768da30d26f7daede6ed68dda72efcb4207ab60f -2026-08-18-model-selected-subagent-routes.zh.md: 9cf3033af0e143589ff6806acbb4600478e83929 +2026-08-18-model-selected-subagent-routes.md: 9cdbdbec3b79a93043fa6ae95a6a6dedf6072086 +2026-08-18-model-selected-subagent-routes.zh.md: 3b81f5417339325dd60884049a853d1094a9f1eb diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index 768da30d26..9cdbdbec3b 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -48,7 +48,7 @@ The delegation definition is static across adapter registration and catalog chan ## Consequences -- A statically enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. +- A settings-enabled Session can select only its recorded exact child LLM routes; disabled Sessions omit and reject model-facing route fields. - The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy exists; discovery and explicit selection are constrained to that policy. - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. - Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index 9cf3033af0..3b81f54173 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -48,7 +48,7 @@ Status: implemented ## 结果 -- 静态启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 +- settings 已启用的 Session 只能选择其记录的精确子级 LLM 路由;禁用的 Session 会省略并拒绝面向模型的路由字段。 - 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略存在的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index 60d872ccda..803fcddb1f 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: af293dd2e74ea801892f2b73553cc15beb1c679e -2026-08-24-user-authorized-subagent-model-routes.zh.md: 973142f0294ad2bfc10dd5729e3a4a4992d6a84b +2026-08-24-user-authorized-subagent-model-routes.md: 3bb76eb8941dd5a7f86e95cfafe14516a4f948d7 +2026-08-24-user-authorized-subagent-model-routes.zh.md: 5defbd0ee0921666a119eaac914580dad747868c diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index af293dd2e7..3bb76eb894 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -35,7 +35,8 @@ Model selection has no unrestricted static mode. The default-off Host setting is - New adapter registrations and newly advertised models do not expand user authorization. - Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it. - The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. -- Unit coverage pins settings validation, Session sampling and inheritance, discovery intersection, executor denial, stale UI candidates, staged whole-array writes, and rejected-write draft preservation. The assembled Web scenario pins the real settings document and Plugins card flow. +- The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it. +- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow. ## Related decisions diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index 973142f029..5defbd0ee0 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -35,7 +35,8 @@ Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` - 新适配器注册和新公布模型不会扩大用户授权。 - 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。 - 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。 -- 单元覆盖固定设置校验、Session 取样与继承、发现交集、执行器拒绝、UI 陈旧候选项、暂存后的整数组写入,以及写入被拒时保留草稿。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 +- 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。 +- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存后的整数组写入、过期 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 ## Related decisions diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index a041ed1669..4b25ae2871 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -9,6 +9,7 @@ * settings scope, which keeps them unaware of one another and of other tabs. */ +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the settings shell's SlotMap merge (the 'settings.section' entry) @@ -53,13 +54,14 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope'] /** * Mount the plugin configuration section and the cards this package ships. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { + const { api } = ctx.get('connection') as ConnectionHandle const t = ctx.locale.bind(NS) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries') @@ -79,6 +81,18 @@ export function apply(ctx: ClientContext): void { () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }), 'ui-settings-plugins: credential invalidations', ) + ctx.effect( + () => ctx.remote.$on('llm/adapters-updated', () => { subagentModelSelection.refreshCatalog() }), + 'ui-settings-plugins: subagent adapter invalidations', + ) + ctx.effect( + () => ctx.remote.$on('settings/document-updated', () => { subagentModelSelection.refreshCatalog() }), + 'ui-settings-plugins: subagent settings invalidations', + ) + ctx.effect( + () => ctx.on('connection/reset', () => { subagentModelSelection.resetCatalog() }), + 'ui-settings-plugins: subagent connection generation', + ) ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') // The shared SettingsScope mirror updates after document commits and reconnects. diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index b2f24d4b8e..f9a86396a8 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -134,6 +134,7 @@ export class SubagentModelSelectionCardController { private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' private draftEnabled: boolean | undefined private draftSelected: Set | undefined + private draftRevision: number | undefined private saving = false private saved = false private failed = false @@ -153,6 +154,11 @@ export class SubagentModelSelectionCardController { ) { this.store = createSnapshotStore(this.projection()) this.unsubscribe = scope.subscribe(() => { + if (!this.saving && this.draftSelected !== undefined + && this.scope.getSnapshot().revision !== this.draftRevision) { + this.saved = false + this.failed = true + } if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() this.publish() }) @@ -199,8 +205,12 @@ export class SubagentModelSelectionCardController { } private beginDraft(): Set { - this.draftEnabled ??= this.currentEnabled() - this.draftSelected ??= new Set(this.currentRoutes().map(subagentModelKey)) + if (this.draftSelected === undefined) { + const snapshot = this.scope.getSnapshot() + this.draftEnabled = snapshot.value?.enabled ?? false + this.draftSelected = new Set(snapshot.value?.allowedModels.map(subagentModelKey) ?? []) + this.draftRevision = snapshot.revision + } return this.draftSelected } @@ -230,6 +240,7 @@ export class SubagentModelSelectionCardController { if (this.saving) return this.draftEnabled = undefined this.draftSelected = undefined + this.draftRevision = undefined this.saved = false this.failed = false this.publish() @@ -252,6 +263,12 @@ export class SubagentModelSelectionCardController { if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) || (desiredEnabled && desired.length === 0)) return + if (this.draftSelected !== undefined && snapshot.revision !== this.draftRevision) { + this.saved = false + this.failed = true + this.publish() + return + } const generation = this.saveGeneration this.saving = true this.saved = false @@ -260,7 +277,7 @@ export class SubagentModelSelectionCardController { await this.scope.mutate([ { op: 'set', path: ['enabled'], value: desiredEnabled }, { op: 'set', path: ['allowedModels'], value: desired }, - ]) + ], this.draftRevision) if (generation !== this.saveGeneration) return const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) this.saving = false @@ -269,10 +286,28 @@ export class SubagentModelSelectionCardController { if (landed) { this.draftEnabled = undefined this.draftSelected = undefined + this.draftRevision = undefined } this.publish() } + /** Invalidate and reload model candidates after a Host model input changes. */ + refreshCatalog(): void { + if (this.disposed) return + this.catalogGeneration += 1 + this.catalogStatus = 'idle' + this.catalogFailures = [] + if (this.enabled()) void this.loadCatalog() + else this.publish() + } + + /** Clear Host-specific candidates and reload after reconnecting. */ + resetCatalog(): void { + if (this.disposed) return + this.catalogGroups = [] + this.refreshCatalog() + } + private async loadCatalog(): Promise { if (this.disposed || this.catalogStatus === 'loading') return const generation = this.catalogGeneration 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 a764446096..cf237667fc 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -11,6 +11,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/clien import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -27,6 +28,9 @@ async function bench(served?: string[]) { locale.setLocale('zh') ctx.provide('locale', locale) const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } })) + const models = vi.fn(() => Promise.resolve({ + rpcId: 'm', result: { ok: true, value: { groups: [], failures: [] } }, + })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined ? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } } : { @@ -43,9 +47,14 @@ async function bench(served?: string[]) { credentials: { describe: describeCredentials, set: vi.fn() }, settings: { describe: describeSettings }, }) - ctx.provide('connection', { isLoopback: true, api: {} } as never) + ctx.provide('connection', { + isLoopback: true, + api: { llm: { models } }, + } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } + return { + ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote, + } } function declareRoot(slots: SlotRegistry): () => void { @@ -57,7 +66,7 @@ function declareRoot(slots: SlotRegistry): () => void { describe('ui-settings-plugins apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope']) }) it('registers one Plugins section and declares the tab and card slots', async () => { @@ -176,6 +185,23 @@ describe('ui-settings-plugins apply', () => { await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) + it('refreshes the subagent catalog after model inputs change or the connection resets', async () => { + const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') + const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetCatalog') + const { ctx, slots, remote } = await bench(['subagent-model-selection']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + refresh.mockClear() + reset.mockClear() + + remote.emit('llm/adapters-updated', []) + expect(refresh).toHaveBeenCalledTimes(1) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) + expect(refresh).toHaveBeenCalledTimes(2) + ctx.emit('connection/reset') + expect(reset).toHaveBeenCalledTimes(1) + }) + it('ignores a credential change for a reference no card watches', async () => { const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) 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 853bb502c9..4b1a8317e8 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -456,7 +456,10 @@ describe('SubagentModelSelectionCardController', () => { groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) - host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + host.publish({ + status: 'ready', writable: true, revision: 3, + value: { enabled: false, allowedModels: [] }, user: {}, + }) const face = controller.inject() expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) @@ -470,7 +473,7 @@ describe('SubagentModelSelectionCardController', () => { expect(host.mutate).toHaveBeenCalledWith([ { op: 'set', path: ['enabled'], value: true }, { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, - ]) + ], 3) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ @@ -482,6 +485,19 @@ describe('SubagentModelSelectionCardController', () => { }) }) + it('starts an empty draft when a ready test scope has no decoded value', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + host.publish({ status: 'ready', writable: true, revision: 0, value: undefined }) + const face = controller.inject() + + face.toggleEnabled() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, dirty: true, invalid: true, + }) + }) + it('keeps the Host value and reports a rejected write', async () => { const host = stubSettingsScope() const models = modelsApi({ @@ -517,7 +533,7 @@ describe('SubagentModelSelectionCardController', () => { }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) host.publish({ - status: 'ready', writable: true, + status: 'ready', writable: true, revision: 5, value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, }) const face = controller.inject() @@ -541,7 +557,7 @@ describe('SubagentModelSelectionCardController', () => { const host = stubSettingsScope() acceptWrites(host) host.publish({ - status: 'ready', writable: true, + status: 'ready', writable: true, revision: 5, value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, }) const models = modelsApi({ @@ -557,7 +573,7 @@ describe('SubagentModelSelectionCardController', () => { expect(host.mutate).toHaveBeenCalledWith([ { op: 'set', path: ['enabled'], value: false }, { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, - ]) + ], 5) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ enabled: false, dirty: false, saved: true, @@ -578,6 +594,71 @@ describe('SubagentModelSelectionCardController', () => { await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) }) }) + it('rejects a draft after the Host revision changes', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + + host.publish({ + revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, + }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ failed: true, dirty: true }) + face.save() + await Promise.resolve() + + expect(host.mutate).not.toHaveBeenCalled() + face.discard() + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + failed: false, dirty: false, enabled: true, + }) + }) + + it('reloads the model catalog after invalidation', async () => { + const host = stubSettingsScope() + host.publish({ + status: 'ready', writable: true, revision: 1, + value: { enabled: true, allowedModels: [] }, user: {}, + }) + const models = vi.fn() + .mockResolvedValueOnce({ + rpcId: 'catalog-1', + result: { ok: true, value: { + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [], + } }, + }) + .mockResolvedValueOnce({ + rpcId: 'catalog-2', + result: { ok: true, value: { + groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }], + failures: [], + } }, + }) + const controller = new SubagentModelSelectionCardController( + host.scope, { llm: { models } } as never, + ) + const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot() + await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') }) + + controller.refreshCatalog() + + await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') }) + expect(models).toHaveBeenCalledTimes(2) + }) + it('suppresses duplicate actions and late save settlements', async () => { const host = stubSettingsScope() const catalog = modelsApi({ @@ -658,6 +739,8 @@ describe('SubagentModelSelectionCardController', () => { expect(host.mutate).not.toHaveBeenCalled() controller.dispose() + controller.refreshCatalog() + controller.resetCatalog() face.toggleEnabled() face.retryCatalog() face.save() diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 06c0fe9604..9a230d0026 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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-settings/README.md -README.md: beda3aec1750da77af86cebdf658bde8e3834a46 -README.zh.md: 8d4fb0983c8a0ba627411fd3b653114625530639 +README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5 +README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index beda3aec17..3e4970bff9 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. +A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. ### Filling the settings slots diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 8d4fb0983c..a527dfe21a 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。 +功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 ### 填充设置 slot diff --git a/packages/client/ui-settings/src/client/settings-contract.ts b/packages/client/ui-settings/src/client/settings-contract.ts index 05b459c7a5..dd06600781 100644 --- a/packages/client/ui-settings/src/client/settings-contract.ts +++ b/packages/client/ui-settings/src/client/settings-contract.ts @@ -62,11 +62,14 @@ export interface SettingsScope { subscribe(listener: () => void): () => void /** * Queue one atomic namespace mutation. All operations share one revision - * fence, Host validation, persistence decision, and recovery read. + * fence, Host validation, persistence decision, and recovery read. Supplying + * `expectedRevision` preserves an earlier read as the fence instead of using + * the latest queued or mirrored revision. * @param ops - ordered field operations copied when queued. + * @param expectedRevision - optional fixed revision read by the domain editor. * @returns settlement after the mutation and any latest-write recovery read. */ - mutate(ops: readonly SettingsPathOpView[]): Promise + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise /** * Queue one field write. Rapid writes preserve mutation order, each carries * the latest known namespace revision, and only the latest settlement may diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 276adebb46..2d728e5154 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -120,13 +120,14 @@ export class SettingsScopeController implements SettingsScope { /** * Queue one atomic namespace mutation; see {@link SettingsScope.mutate}. * @param ops - ordered field operations copied when queued. + * @param expectedRevision - optional fixed revision read by the domain editor. * @returns settlement after the mutation and any latest-write recovery read. */ - mutate(ops: readonly SettingsPathOpView[]): Promise { + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise { const ownedOps = structuredClone(ops) as SettingsPathOpView[] const generation = ++this.writeGeneration return this.enqueue(async () => { - const revision = this.pendingRevision ?? this.getSnapshot().revision + const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision) 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 883beee615..fe0d4013ee 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -201,6 +201,30 @@ describe('SettingsScopeController', () => { ) }) + it('preserves an editor-owned revision fence behind earlier queued writes', async () => { + const first = deferred>() + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 7)) + .mockResolvedValueOnce(described({ preference: 'dark' }, 8)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(rejected()) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + + const earlier = scope.set('preference', 'dark') + const fenced = scope.mutate([{ op: 'set', path: ['preference'], value: 'light' }], 7) + first.resolve(ok(view({ preference: 'dark' }, 8))) + await Promise.all([earlier, fenced]) + + expect(mutate).toHaveBeenNthCalledWith(2, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'light' }], + expectedRevision: 7, + }) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 8 }) + }) + 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))) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 30a5f148a1..3460ac3529 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -651,11 +651,15 @@ export function apply(ctx: Context, config: Config): void { // Reserve before the injected fiber runs: tool registration emits // `tools/change` synchronously, which re-enters the reconciliation below. installing.add(candidate) - const policy = selectForAgent(candidate) - const fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { - install(runtimeCtx, policy) - }) - installing.delete(candidate) + let fiber: ReturnType + try { + const policy = selectForAgent(candidate) + fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { + install(runtimeCtx, policy) + }) + } finally { + installing.delete(candidate) + } scopedInstalls.set(candidate, fiber) } const removeScoped = (candidate: Agent): void => { diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index b729d6d10b..5fc6342709 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -26,8 +26,9 @@ declare module '@deepseek-ai/dsh-session/types' { export function subagentModelSelectionPolicy(session: Session): AllowedModelRoute[] | undefined { const event = session.events.find(candidate => candidate.type === 'subagent/model-selection-policy') if (event?.type !== 'subagent/model-selection-policy') return undefined - const routes = event.data.allowedModels.map(route => ({ ...route })) - assertAllowedModelRoutes(routes) + const { allowedModels } = event.data + assertAllowedModelRoutes(allowedModels) + const routes = allowedModels.map(route => ({ ...route })) if (routes.length === 0) throw new Error('subagent/model-selection-policy requires at least one route') return routes } diff --git a/packages/subagent/tool-subagent/src/model-selection.ts b/packages/subagent/tool-subagent/src/model-selection.ts index 42ed6d3b7c..5f30b71742 100644 --- a/packages/subagent/tool-subagent/src/model-selection.ts +++ b/packages/subagent/tool-subagent/src/model-selection.ts @@ -35,15 +35,24 @@ export function modelRouteKey(route: AllowedModelRoute): string { } /** - * Reject malformed or duplicate route policy entries at a configuration boundary. - * @param routes - Exact routes to validate. + * Reject malformed or duplicate route policy entries at a durable or configuration boundary. + * @param routes - Candidate exact routes to validate. + * @returns an assertion that the candidate is a validated exact-route array. */ -export function assertAllowedModelRoutes(routes: readonly AllowedModelRoute[]): void { +export function assertAllowedModelRoutes(routes: unknown): asserts routes is readonly AllowedModelRoute[] { + if (!Array.isArray(routes)) { + throw new Error('subagent model selection requires an array of routes') + } const seen = new Set() - for (const route of routes) { - if (route.provider.length === 0 || route.model.length === 0) { + const candidates: readonly unknown[] = routes + for (const candidate of candidates) { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate) + || !('provider' in candidate) || typeof candidate.provider !== 'string' + || !('model' in candidate) || typeof candidate.model !== 'string' + || candidate.provider.length === 0 || candidate.model.length === 0) { throw new Error('subagent model selection requires non-empty provider and model ids') } + const route = { provider: candidate.provider, model: candidate.model } const key = modelRouteKey(route) if (seen.has(key)) { throw new Error(`subagent model selection repeats route "${route.provider}/${route.model}"`) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index b5ecac11b8..4078a828b7 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -129,6 +129,13 @@ describe('SubagentModelSelectionConfig', () => { const invalid = Session.create(SessionId('empty-policy')) invalid.append('subagent/model-selection-policy', { allowedModels: [] }) expect(() => subagentModelSelectionPolicy(invalid)).toThrow('requires at least one route') + + const malformed = Session.create(SessionId('malformed-policy')) + malformed.append('subagent/model-selection-policy', { + allowedModels: [{ provider: 1, model: 'fast-model' }], + } as never) + expect(() => subagentModelSelectionPolicy(malformed)) + .toThrow('requires non-empty provider and model ids') await ctx.fiber.dispose() }) @@ -222,6 +229,40 @@ describe('SubagentModelSelectionConfig', () => { await ctx.fiber.dispose() }) + it('releases a shared-preset installation reservation after policy selection fails', async () => { + const ctx = await boot() + const preset = createScope(ctx, { preset: 'standard' }) + const other = createScope(ctx, { preset: 'minimal' }) + await preset.ctx.plugin(tool, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + let binding: ReturnType | undefined + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-policy-retry'), + setup: (agentCtx) => { + binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!) + }, + }) + expect(selectable(ctx, handle.agent)).toBe(false) + + binding!.rebind(scopeOf(other.ctx)!) + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + binding!.rebind(scopeOf(preset.ctx)!) + vi.spyOn(ctx.subagentModelSelection, 'current') + .mockImplementationOnce(() => { throw new Error('transient settings read') }) + .mockReturnValue({ enabled: true, allowedModels: ALLOWED_MODELS }) + + expect(() => { ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') }) + .toThrow('transient settings read') + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + await vi.waitFor(() => { expect(selectable(ctx, handle.agent)).toBe(true) }) + + await handle.dispose() + await ctx.fiber.dispose() + }) + it('inherits the parent decision and preserves seeded decisions across composition', async () => { const ctx = await boot() await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 32072573fa..9137e2a9dd 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -38,6 +38,10 @@ describe('dsh-tool-subagent model selection', () => { .toThrow('requires non-empty provider and model ids') expect(() => { assertAllowedModelRoutes([{ provider: 'provider', model: '' }]) }) .toThrow('requires non-empty provider and model ids') + expect(() => { assertAllowedModelRoutes({ provider: 'provider', model: 'model' }) }) + .toThrow('requires an array of routes') + expect(() => { assertAllowedModelRoutes([{ provider: 1, model: 'model' }]) }) + .toThrow('requires non-empty provider and model ids') }) it('allows pure inheritance but rejects explicit values outside a Session allowlist', () => { From a130273434c5b992bd070819996df09efb173768 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 11:44:54 +0800 Subject: [PATCH 23/38] test(subagent): type provider route defaults fixture --- packages/subagent/tool-subagent/tests/scripted-provider.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index a946c6f6fe..012ef669c6 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -34,6 +34,8 @@ export interface Config { capabilities?: Partial /** Whether tool descriptions say the child inherits completed turns. */ inheritsParentContext?: boolean + /** Provider-owned child route defaults. */ + agentRouteDefaults?: Readonly<{ provider: string; model: string }> /** Structured value returned when the request asks for one. */ structured?: unknown /** Observes each start; the child's result additionally waits for the returned promise. */ @@ -110,7 +112,10 @@ export function mountScriptedProvider(ctx: Context, config: Config) { name: 'scripted-subagent-provider', inject: ['subagents'], apply(pluginCtx: Context): void { - pluginCtx.subagents.registerProvider(new ScriptedSubagentProvider(config.name, config)) + const provider = new ScriptedSubagentProvider(config.name, config) + pluginCtx.subagents.registerProvider(config.agentRouteDefaults === undefined + ? provider + : Object.assign(provider, { agentRouteDefaults: config.agentRouteDefaults })) }, }) } From 0b2f476071ebeddd8836884e63af299152d7064b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 11:45:15 +0800 Subject: [PATCH 24/38] fix(ui-settings-plugins): align Subagent configuration card --- .../plugin-config/section.expected.md | 4 +- apps/web/tests/plugin-config.e2e.ts | 13 ++-- .../SubagentModelSelectionCard.module.css | 25 +++++-- .../src/client/SubagentModelSelectionCard.tsx | 74 ++++++++++++++----- .../ui-settings-plugins/src/client/locales.ts | 53 ++++++------- ...ubagent-model-selection-card-controller.ts | 26 ++----- .../tests/section.client.spec.tsx | 11 ++- .../tests/stores.client.spec.ts | 5 +- 8 files changed, 124 insertions(+), 87 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 39cfa33d85..1a95e7032d 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -25,8 +25,8 @@ - tabpanel "插件配置": - list: - listitem: - - 'button "展开设置: Subagent 自选模型"': - - text: Subagent 自选模型 选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。 + - 'button "展开设置: Subagent"': + - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。 - img - listitem: - 'button "展开设置: 终端"': diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index edf3b1e98f..e3b34776bb 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -77,8 +77,8 @@ describe('web e2e: plugin configuration section', () => { // Every card the shipped web composition exposes: subagent selection, the // shell executor, the agent loop, and the DeepSeek search provider. - await dialog.getByText('Subagent 自选模型', { exact: true }).waitFor({ timeout: 10_000 }) - expect(await dialog.getByRole('button', { name: '展开设置: Subagent 自选模型' }).count()).toBe(1) + await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1) await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) @@ -93,11 +93,11 @@ describe('web e2e: plugin configuration section', () => { it('persists selected adapter routes as the subagent model allowlist', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection')) const dialog = await openPlugins() - await dialog.getByText('Subagent 自选模型', { exact: true }).click() - const toggle = dialog.getByRole('switch', { name: '允许 subagent 自选模型' }) + await dialog.getByText('Subagent', { exact: true }).click() + const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' }) await toggle.click() - const models = dialog.getByRole('group', { name: '允许的模型' }) + const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' }) await models.waitFor({ timeout: 10_000 }) const firstModel = models.getByRole('checkbox').first() await firstModel.check() @@ -110,7 +110,8 @@ describe('web e2e: plugin configuration section', () => { expect(await settingsDocument()).toContain('allowedModels:') expect(await settingsDocument()).toContain('provider:') expect(await settingsDocument()).toContain('model:') - expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。') + await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true) + expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0) await toggle.click() await dialog.getByRole('button', { name: '保存', exact: true }).click() diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index 075564224a..c71952a0bd 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -54,8 +54,7 @@ .hint, .notice, -.invalid, -.status { +.invalid { margin: 0; font-size: 12px; line-height: 1.5; @@ -70,10 +69,6 @@ color: var(--dsw-alias-label-error); } -.status { - color: var(--dsw-alias-state-success-primary); -} - .catalogError { display: flex; align-items: center; @@ -109,6 +104,24 @@ color: var(--dsw-alias-label-secondary); } +.modelGroup { + display: grid; + gap: 6px; +} + +.modelGroup + .modelGroup { + margin-top: 4px; + padding-top: 10px; + border-top: 1px solid var(--dsw-alias-border-l3); +} + +.providerName { + padding: 0 6px; + font-size: 11px; + font-weight: 500; + color: var(--dsw-alias-label-tertiary); +} + .model { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index 0a8ce094fd..962dfdb41c 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -2,7 +2,10 @@ import clsx from 'clsx' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { SubagentModelSelectionCardFace } from './subagent-model-selection-card-controller.ts' +import type { + SubagentModelCandidate, + SubagentModelSelectionCardFace, +} from './subagent-model-selection-card-controller.ts' import type {} from './slot-contract.ts' import { PluginCard } from './PluginCard.tsx' import css from './SubagentModelSelectionCard.module.css' @@ -21,6 +24,43 @@ export type SubagentModelSelectionCardProps = export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) { const { t } = props const state = props.useSubagentModelSelectionCard(snapshot => snapshot) + const availableGroups = new Map() + const unavailable: SubagentModelCandidate[] = [] + for (const candidate of state.candidates) { + if (!candidate.available) { + unavailable.push(candidate) + continue + } + const group = availableGroups.get(candidate.provider) + if (group === undefined) { + availableGroups.set(candidate.provider, { + providerName: candidate.providerName, + candidates: [candidate], + }) + } else { + group.candidates.push(candidate) + } + } + const renderCandidate = (candidate: SubagentModelCandidate) => ( + + ) return ( ) : null} - {state.catalogFailures.length > 0 + {state.catalogPartial ?

    {t('subagentModelSelectionPartial')}

    : null} {state.candidates.length > 0 ? (
    {t('subagentModelSelectionAllowed')} - {state.candidates.map(candidate => ( - + {[...availableGroups].map(([provider, group]) => ( +
    +
    {group.providerName}
    + {group.candidates.map(renderCandidate)} +
    ))} + {unavailable.length > 0 + ? ( +
    +
    {t('subagentModelSelectionUnavailableGroup')}
    + {unavailable.map(renderCandidate)} +
    + ) + : null}
    ) : state.catalogStatus === 'ready' @@ -94,7 +131,6 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
    ) :

    {t('subagentModelSelectionOff')}

    } - {state.saved ?

    {t('subagentModelSelectionSaved')}

    : null} ) } diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 24a3424a01..018d87d47d 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -14,8 +14,9 @@ export type PluginsSettingsLocaleKey = | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription' | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' - | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' | 'subagentModelSelectionEmpty' - | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff' | 'subagentModelSelectionSaved' + | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' + | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff' /** English copy. */ export const en: Record = { @@ -56,20 +57,20 @@ export const en: Record = { webSearchBaseUrlHint: 'Leave blank to use the provider default.', webSearchMaxUses: 'Max searches per request', webSearchMaxUsesHint: 'How many times one request may search before it must answer.', - subagentModelSelectionTitle: 'Subagent model selection', - subagentModelSelectionDescription: 'Choose which child models new sessions may select. Running sessions do not change.', - subagentModelSelectionToggle: 'Allow subagents to choose models', - subagentModelSelectionChoose: 'Select at least one model. Only these adapter routes appear in subagent discovery.', - subagentModelSelectionAllowed: 'Allowed models', - subagentModelSelectionLoading: 'Loading adapter models…', - subagentModelSelectionLoadFailed: 'Adapter models could not be loaded.', + subagentModelSelectionTitle: 'Subagent', + subagentModelSelectionDescription: 'Control which models agents may choose for subagents.', + subagentModelSelectionToggle: 'Allow agents to choose models for subagents', + subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each subagent from the authorized models below. Applies only to new sessions.', + subagentModelSelectionAllowed: 'Models agents may choose', + subagentModelSelectionLoading: 'Loading models…', + subagentModelSelectionLoadFailed: 'Models could not be loaded.', subagentModelSelectionRetry: 'Retry', - subagentModelSelectionPartial: 'Some providers could not list their models; stored choices remain removable.', - subagentModelSelectionUnavailable: 'Unavailable', - subagentModelSelectionEmpty: 'No adapter currently advertises a model.', + subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.', + subagentModelSelectionUnavailable: 'Currently unavailable', + subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', + subagentModelSelectionEmpty: 'No model provider currently advertises a model.', subagentModelSelectionRequired: 'Select at least one model before saving.', - subagentModelSelectionOff: 'New sessions inherit the configured or parent model without choosing another route.', - subagentModelSelectionSaved: 'Saved. New sessions use this setting.', + subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', } /** Simplified Chinese copy. */ @@ -111,18 +112,18 @@ export const zh: Record = { webSearchBaseUrlHint: '留空则使用提供方默认地址。', webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', - subagentModelSelectionTitle: 'Subagent 自选模型', - subagentModelSelectionDescription: '选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。', - subagentModelSelectionToggle: '允许 subagent 自选模型', - subagentModelSelectionChoose: '请至少选择一个模型。Subagent 发现工具只会列出这些 adapter 路由。', - subagentModelSelectionAllowed: '允许的模型', - subagentModelSelectionLoading: '正在加载 adapter 模型…', - subagentModelSelectionLoadFailed: '无法加载 adapter 模型。', + subagentModelSelectionTitle: 'Subagent', + subagentModelSelectionDescription: '控制 Agent 为 Subagent 选择模型的权限。', + subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型', + subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。', + subagentModelSelectionAllowed: 'Agent 可选择的模型', + subagentModelSelectionLoading: '正在加载模型…', + subagentModelSelectionLoadFailed: '无法加载模型。', subagentModelSelectionRetry: '重试', - subagentModelSelectionPartial: '部分提供方无法列出模型;仍可移除已保存的选项。', - subagentModelSelectionUnavailable: '不可用', - subagentModelSelectionEmpty: '当前没有 adapter 公布模型。', + subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。', + subagentModelSelectionUnavailable: '当前不可用', + subagentModelSelectionUnavailableGroup: '已保存但当前不可用', + subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', subagentModelSelectionRequired: '保存前请至少选择一个模型。', - subagentModelSelectionOff: '新会话会使用配置值或继承父 Agent 模型,不会自主选择其他路由。', - subagentModelSelectionSaved: '已保存,新会话将使用此设置。', + subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index f9a86396a8..8af7361f13 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -2,7 +2,6 @@ import type { IApiClient, - ModelCatalogFailure, ModelProviderGroup, } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -48,10 +47,8 @@ export interface SubagentModelSelectionCardState extends CardShell { candidates: readonly SubagentModelCandidate[] /** Adapter-directory request state. */ catalogStatus: 'idle' | 'loading' | 'ready' | 'error' - /** Provider-local failures that did not block other candidates. */ - catalogFailures: readonly ModelCatalogFailure[] - /** Whether the latest save landed. */ - saved: boolean + /** Whether any provider-local catalog request failed. */ + catalogPartial: boolean } /** Registration-side face for the subagent model-selection card. */ @@ -130,13 +127,12 @@ function sameRoutes(left: readonly AllowedSubagentModel[], right: readonly Allow /** Bridges one settings scope and the live adapter directory onto a staged card. */ export class SubagentModelSelectionCardController { private catalogGroups: readonly ModelProviderGroup[] = [] - private catalogFailures: readonly ModelCatalogFailure[] = [] + private catalogPartial = false private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' private draftEnabled: boolean | undefined private draftSelected: Set | undefined private draftRevision: number | undefined private saving = false - private saved = false private failed = false private disposed = false private saveGeneration = 0 @@ -156,7 +152,6 @@ export class SubagentModelSelectionCardController { this.unsubscribe = scope.subscribe(() => { if (!this.saving && this.draftSelected !== undefined && this.scope.getSnapshot().revision !== this.draftRevision) { - this.saved = false this.failed = true } if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() @@ -219,7 +214,6 @@ export class SubagentModelSelectionCardController { if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving) return this.beginDraft() this.draftEnabled = !this.draftEnabled - this.saved = false this.failed = false if (this.draftEnabled && this.catalogStatus === 'idle') void this.loadCatalog() this.publish() @@ -231,7 +225,6 @@ export class SubagentModelSelectionCardController { const selected = this.beginDraft() if (selected.has(key)) selected.delete(key) else selected.add(key) - this.saved = false this.failed = false this.publish() } @@ -241,7 +234,6 @@ export class SubagentModelSelectionCardController { this.draftEnabled = undefined this.draftSelected = undefined this.draftRevision = undefined - this.saved = false this.failed = false this.publish() } @@ -264,14 +256,12 @@ export class SubagentModelSelectionCardController { || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) || (desiredEnabled && desired.length === 0)) return if (this.draftSelected !== undefined && snapshot.revision !== this.draftRevision) { - this.saved = false this.failed = true this.publish() return } const generation = this.saveGeneration this.saving = true - this.saved = false this.failed = false this.publish() await this.scope.mutate([ @@ -281,7 +271,6 @@ export class SubagentModelSelectionCardController { if (generation !== this.saveGeneration) return const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) this.saving = false - this.saved = landed this.failed = !landed if (landed) { this.draftEnabled = undefined @@ -296,7 +285,7 @@ export class SubagentModelSelectionCardController { if (this.disposed) return this.catalogGeneration += 1 this.catalogStatus = 'idle' - this.catalogFailures = [] + this.catalogPartial = false if (this.enabled()) void this.loadCatalog() else this.publish() } @@ -313,14 +302,14 @@ export class SubagentModelSelectionCardController { const generation = this.catalogGeneration this.catalogStatus = 'loading' this.catalogGroups = [] - this.catalogFailures = [] + this.catalogPartial = false this.publish() try { const response = await this.api.llm.models({}) if (generation !== this.catalogGeneration) return if (!response.result.ok) throw new Error(response.result.error.message) this.catalogGroups = response.result.value.groups - this.catalogFailures = response.result.value.failures + this.catalogPartial = response.result.value.failures.length > 0 this.catalogStatus = 'ready' } catch { if (generation !== this.catalogGeneration) return @@ -344,8 +333,7 @@ export class SubagentModelSelectionCardController { enabled, candidates: this.candidates(), catalogStatus: this.catalogStatus, - catalogFailures: this.catalogFailures, - saved: this.saved, + catalogPartial: this.catalogPartial, } } diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 46709d057f..7effc16dee 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -88,8 +88,7 @@ function renderSubagentModelSelection(state: Partial { expect(actions.toggleEnabled).toHaveBeenCalledOnce() }) - it('renders adapter candidates and reports a successful save', () => { + it('groups available adapter candidates by provider', () => { const actions = renderSubagentModelSelection({ enabled: true, - saved: true, candidates: [{ key: 'alpha\0fast', provider: 'alpha', @@ -353,7 +351,7 @@ describe('SubagentModelSelectionCard', () => { fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') - expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) + expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') }) @@ -374,7 +372,7 @@ describe('SubagentModelSelectionCard', () => { renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready', - catalogFailures: [{ id: 'beta', name: 'Beta', message: 'offline' }], + catalogPartial: true, candidates: [{ key: 'legacy\0old', provider: 'legacy', @@ -388,6 +386,7 @@ describe('SubagentModelSelectionCard', () => { fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy() cleanup() renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) 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 4b1a8317e8..3e5002954d 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -480,7 +480,6 @@ describe('SubagentModelSelectionCardController', () => { enabled: true, dirty: false, saving: false, - saved: true, failed: false, }) }) @@ -521,7 +520,6 @@ describe('SubagentModelSelectionCardController', () => { enabled: true, dirty: true, saving: false, - saved: false, }) }) @@ -539,6 +537,7 @@ describe('SubagentModelSelectionCardController', () => { const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) + expect(state().catalogPartial).toBe(true) face.toggleModel('missing') expect(state().dirty).toBe(false) @@ -576,7 +575,7 @@ describe('SubagentModelSelectionCardController', () => { ], 5) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ - enabled: false, dirty: false, saved: true, + enabled: false, dirty: false, }) }) From 5a5e1b73733546a5880e1564bce60a43ac0e06b6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 11:58:23 +0800 Subject: [PATCH 25/38] test(ui-settings-plugins): cover provider model grouping --- .../tests/section.client.spec.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 7effc16dee..d5207b5fe7 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -337,15 +337,26 @@ describe('SubagentModelSelectionCard', () => { it('groups available adapter candidates by provider', () => { const actions = renderSubagentModelSelection({ enabled: true, - candidates: [{ - key: 'alpha\0fast', - provider: 'alpha', - model: 'fast', - providerName: 'Alpha API', - modelName: 'Fast', - available: true, - selected: true, - }], + candidates: [ + { + key: 'alpha\0fast', + provider: 'alpha', + model: 'fast', + providerName: 'Alpha API', + modelName: 'Fast', + available: true, + selected: true, + }, + { + key: 'alpha\0deep', + provider: 'alpha', + model: 'deep', + providerName: 'Alpha API', + modelName: 'Deep', + available: true, + selected: false, + }, + ], catalogStatus: 'ready', }) fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) @@ -353,7 +364,9 @@ describe('SubagentModelSelectionCard', () => { expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) + fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ })) expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep') }) it('renders directory progress, failures, unavailable routes, and validation', () => { From a5cc8a2186fb9ab76f2538430e596102a586e770 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 13:06:30 +0800 Subject: [PATCH 26/38] fix(notices): resolve current installed dependency versions --- THIRD_PARTY_NOTICES.md | 2 +- scripts/gen-third-party-notices.spec.ts | 18 ++++++++ scripts/gen-third-party-notices.ts | 60 +++++++++++++++++++------ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 08c7f13f0f..616dcd1909 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -165,7 +165,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | | [`dayjs`](https://github.com/iamkun/dayjs) | MIT | -| [`debug`](https://github.com/visionmedia/debug) | MIT | +| [`debug`](https://github.com/debug-js/debug) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 68812143c3..33d8661aed 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -114,6 +114,24 @@ describe('virtualManifest', () => { } }) + it('selects the requested version when the store retains historical copies', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-version-')) + try { + const name = '@scope/pkg' + const store = join(root, 'store') + for (const version of ['1.0.0', '2.0.0']) { + const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name) + mkdirSync(manifestDir, { recursive: true }) + writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' })) + } + + expect(virtualManifest(store, name, '2.0.0')).toMatchObject({ name, version: '2.0.0' }) + expect(virtualManifest(store, name, '3.0.0')).toBeUndefined() + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('returns undefined when neither the prefix nor the content scan finds the package', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-')) try { diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 4f56031c1e..e076677055 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -9,7 +9,7 @@ */ import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' import parseSpdx from 'spdx-expression-parse' @@ -248,38 +248,72 @@ export function claudeDistributionFromManifest( * * @param virtual - the `.pnpm` virtual store directory to scan. * @param name - the external package name, exactly as `node_modules` spells it. + * @param expectedVersion - exact version required when the store retains more than one. * @returns the parsed manifest, or `undefined` when neither the prefix match - * nor the content scan finds the package's `package.json`. + * nor the content scan finds the requested package version. */ -export function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { +export function virtualManifest( + virtual: string, + name: string, + expectedVersion?: string, +): VirtualManifest | undefined { const prefix = `${name.replace('/', '+')}@` - const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) - if (entry !== undefined) { - return JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest + const entries = readdirSync(virtual) + for (const entry of entries.filter(dir => dir.startsWith(prefix))) { + const manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest + if (expectedVersion === undefined || manifest.version === expectedVersion) return manifest } - for (const dir of readdirSync(virtual)) { + for (const dir of entries) { const candidate = resolve(virtual, dir, 'node_modules', name, 'package.json') if (existsSync(candidate)) { - return JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest + const manifest = JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest + if (expectedVersion === undefined || manifest.version === expectedVersion) return manifest } } return undefined } +const workspaceLinkedManifestCache = new Map() + +/** + * Resolve the package version selected for a declaring workspace instead of an + * unrelated historical version that still occupies the shared virtual store. + * @param name - external package identity. + * @returns the first current workspace link for that package, when installed. + */ +function workspaceLinkedManifest(name: string): VirtualManifest | undefined { + if (workspaceLinkedManifestCache.has(name)) return workspaceLinkedManifestCache.get(name) + for (const [path, manifest] of loadWorkspaceManifests().manifests) { + if (!ALL_KINDS.some(kind => name in (manifest[kind] ?? {}))) continue + const linked = resolve(root, dirname(path), 'node_modules', name, 'package.json') + if (!existsSync(linked)) continue + const found = JSON.parse(readFileSync(linked, 'utf8')) as VirtualManifest + workspaceLinkedManifestCache.set(name, found) + return found + } + workspaceLinkedManifestCache.set(name, undefined) + return undefined +} + /** Resolve one installed external package manifest from either pnpm store. */ -function installedManifest(name: string): VirtualManifest | undefined { +function installedManifest(name: string, expectedVersion?: string): VirtualManifest | undefined { + const linked = workspaceLinkedManifest(name) + if (linked !== undefined && (expectedVersion === undefined || linked.version === expectedVersion)) return linked let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined // Workspace-local link farms can expose a dependency that is not linked at // the repository root; both are backed by the root workspace's lockfile. for (const store of ['node_modules', 'native/landlock-run/node_modules']) { const direct = resolve(root, store, name, 'package.json') if (existsSync(direct)) { - manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest - break + const candidate = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest + if (expectedVersion === undefined || candidate?.version === expectedVersion) { + manifest = candidate + break + } } const virtual = resolve(root, store, '.pnpm') if (!existsSync(virtual)) continue - manifest = virtualManifest(virtual, name) + manifest = virtualManifest(virtual, name, expectedVersion) if (manifest !== undefined) break } return manifest @@ -308,7 +342,7 @@ function collectClaudeDistribution(): ClaudeDistribution { const distribution = claudeDistributionFromManifest(manifest) let installedPayloads = 0 for (const payload of distribution.payloads) { - const installed = installedManifest(payload.name) + const installed = installedManifest(payload.name, payload.version) if (installed === undefined) continue installedPayloads += 1 if ( From bf7020ade24b7490ef4801cac59f6160f353aab5 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 13:06:38 +0800 Subject: [PATCH 27/38] test(subagent): migrate model selection fixtures --- .../tests/fixtures/loader/cordis.yml | 11 +++- .../fixtures/loader/scoped-tool-subagent.ts | 19 ++++++ .../tool-schemas.expected.json | 31 +--------- .../subagent-dsh-sdk-dynamic-route/cordis.yml | 11 +++- .../notifications.expected.jsonl | 54 ++++++++-------- .../session.jsonl | 9 +-- .../tool-schemas.1.expected.json | 31 +--------- .../tool-schemas.expected.json | 62 +------------------ .../tool-schemas.expected.json | 62 +------------------ .../tool-schemas.expected.json | 31 +--------- snapshots/session/text-turn/cordis.yml | 1 - 11 files changed, 76 insertions(+), 246 deletions(-) create mode 100644 packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml index a72b5fd79e..f0234ffdf7 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml @@ -25,12 +25,19 @@ DSH_TELEMETRY_DISABLED: '1' DSH_TEST_CHILD_FAILURE: !!js String(process.env.DSH_TEST_CHILD_FAILURE ?? '') +- id: subagent-model-selection-settings + name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' + config: + enabled: true + allowedModels: + - provider: mock + model: mock-routed + - id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' + name: './scoped-tool-subagent.ts' config: provider: dsh-sdk toolName: subagent - enableModelSelection: true agentOptions: maxTokens: 777 # The SDK backend advertises no depthLimit: the child harness owns its own diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts new file mode 100644 index 0000000000..5083694807 --- /dev/null +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts @@ -0,0 +1,19 @@ +/** Mount the SDK delegation tool in each fixture Agent's scope. */ + +import type { Context } from '@deepseek-ai/cordis' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' +import type { Config } from '@deepseek-ai/dsh-tool-subagent' + +export const name = 'scoped-tool-subagent' +export const inject = ['agents', 'subagentModelSelection'] + +/** + * Install the configured delegation tool before a published Agent starts its loop. + * @param ctx - fixture Host context carrying Agent lifecycle events. + * @param config - delegation-tool configuration forwarded into each Agent scope. + */ +export function apply(ctx: Context, config: Config): void { + ctx.on('agent/created', ({ agent }) => { + agent.ctx.plugin(ToolSubagent, { ...config, modelSelectionSettings: true }) + }) +} diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json index 2e0fc2b44b..b3a1813e8b 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml index 100c7c20aa..4b6e415081 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml @@ -35,12 +35,19 @@ env: DSH_TELEMETRY_DISABLED: '1' + - id: subagent-model-selection-settings + name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' + config: + enabled: true + allowedModels: + - provider: mock + model: mock-routed + - id: tool-subagent-dsh-sdk - name: '@deepseek-ai/dsh-tool-subagent' + name: '../../../packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts' config: provider: dsh-sdk toolName: subagent - enableModelSelection: true enableRunInBackground: false agentOptions: maxTokens: 777 diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl index 9dee99a052..315ff76482 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl @@ -1,29 +1,29 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"Delegate once using the requested","messageSeqs":[7],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"mock-delegate"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-delegate","name":"subagent","argumentsDelta":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":18,"time":0,"data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":19,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":5,"time":0,"data":{"turn":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":6,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":7,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":10,"time":0,"data":{"title":"Delegate once using the requested","messageSeqs":[8],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":11,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":12,"time":0,"data":{"provider":"deepseek-official","model":"mock-delegate"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-delegate","name":"subagent","argumentsDelta":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":19,"time":0,"data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":20,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.jsonl b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.jsonl index cc800a84cb..9402e19672 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.jsonl +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.jsonl @@ -2,13 +2,14 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} +{"type":"subagent/model-selection-policy","data":{"allowedModels":[{"provider":"mock","model":"mock-routed"}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Delegate once using the requested","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"session/title","data":{"title":"Delegate once using the requested","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"mock-delegate"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -16,9 +17,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -26,6 +27,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json index ba1d2e415d..fe0882fe53 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/agent-instructions/tool-schemas.expected.json b/snapshots/session/agent-instructions/tool-schemas.expected.json index 0d475b2d80..36df7a0481 100644 --- a/snapshots/session/agent-instructions/tool-schemas.expected.json +++ b/snapshots/session/agent-instructions/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." @@ -990,23 +961,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -1191,7 +1145,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -1203,18 +1157,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/compaction-recovery/tool-schemas.expected.json b/snapshots/session/compaction-recovery/tool-schemas.expected.json index 0d475b2d80..36df7a0481 100644 --- a/snapshots/session/compaction-recovery/tool-schemas.expected.json +++ b/snapshots/session/compaction-recovery/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." @@ -990,23 +961,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -1191,7 +1145,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -1203,18 +1157,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json index 2f46955ebd..796bac1779 100644 --- a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/text-turn/cordis.yml b/snapshots/session/text-turn/cordis.yml index 2c8ffef06f..ea97cc0616 100644 --- a/snapshots/session/text-turn/cordis.yml +++ b/snapshots/session/text-turn/cordis.yml @@ -51,7 +51,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 1 From 1c0e46870ce28b3389f62d1ee55c731a60f795ae Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 13:51:45 +0800 Subject: [PATCH 28/38] test(subagent): keep scoped fixture config explicit --- knip.json | 3 +-- .../tests/fixtures/loader/scoped-tool-subagent.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/knip.json b/knip.json index b08fe22a25..6fa4cac826 100644 --- a/knip.json +++ b/knip.json @@ -696,8 +696,7 @@ "@deepseek-ai/dsh-llm-deepseek", "@deepseek-ai/dsh-session-checkpoint-policy", "@deepseek-ai/dsh-session-persistence-jsonl", - "@deepseek-ai/dsh-skill-filesystem", - "@deepseek-ai/dsh-tool-subagent" + "@deepseek-ai/dsh-skill-filesystem" ] }, "packages/shell/tool-pwsh": { diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts index 5083694807..d333821d5c 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts @@ -14,6 +14,16 @@ export const inject = ['agents', 'subagentModelSelection'] */ export function apply(ctx: Context, config: Config): void { ctx.on('agent/created', ({ agent }) => { - agent.ctx.plugin(ToolSubagent, { ...config, modelSelectionSettings: true }) + agent.ctx.plugin(ToolSubagent, { + provider: config.provider, + toolName: config.toolName, + modelSelectionSettings: true, + enableRunInBackground: config.enableRunInBackground, + backgroundMode: config.backgroundMode, + agentOptions: config.agentOptions, + persona: config.persona, + toolFilter: config.toolFilter, + maxDepth: config.maxDepth, + }) }) } From a7614f971e6e33abdcbd3bb348c66c20dc59d49a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 15:00:27 +0800 Subject: [PATCH 29/38] fix(subagent): omit undefined scoped fixture options --- .../fixtures/loader/scoped-tool-subagent.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts index d333821d5c..79e53d1a35 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts @@ -16,14 +16,16 @@ export function apply(ctx: Context, config: Config): void { ctx.on('agent/created', ({ agent }) => { agent.ctx.plugin(ToolSubagent, { provider: config.provider, - toolName: config.toolName, modelSelectionSettings: true, - enableRunInBackground: config.enableRunInBackground, - backgroundMode: config.backgroundMode, - agentOptions: config.agentOptions, - persona: config.persona, - toolFilter: config.toolFilter, - maxDepth: config.maxDepth, + ...(config.toolName === undefined ? {} : { toolName: config.toolName }), + ...(config.enableRunInBackground === undefined + ? {} + : { enableRunInBackground: config.enableRunInBackground }), + ...(config.backgroundMode === undefined ? {} : { backgroundMode: config.backgroundMode }), + ...(config.agentOptions === undefined ? {} : { agentOptions: config.agentOptions }), + ...(config.persona === undefined ? {} : { persona: config.persona }), + ...(config.toolFilter === undefined ? {} : { toolFilter: config.toolFilter }), + ...(config.maxDepth === undefined ? {} : { maxDepth: config.maxDepth }), }) }) } From 4cc1f5e0ffb8b68de7ca53ac0839f179de5e6cf9 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 15:00:34 +0800 Subject: [PATCH 30/38] fix(ui-settings-plugins): relax Subagent card layout --- .../src/client/PluginCard.tsx | 14 +++++++- .../SubagentModelSelectionCard.module.css | 14 +++++++- .../src/client/SubagentModelSelectionCard.tsx | 34 +++++++++++-------- .../tests/section.client.spec.tsx | 34 +++++++++++++++++-- 4 files changed, 76 insertions(+), 20 deletions(-) diff --git a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx index 6965939561..472a9f0e85 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx @@ -14,7 +14,7 @@ * disabled card the user cannot act on. */ -import { useState, type ReactNode } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { CardShell } from './card-form.ts' @@ -46,7 +46,19 @@ export interface PluginCardProps { */ export function PluginCard(props: PluginCardProps) { const [open, setOpen] = useState(false) + const saveStarted = useRef(false) const { state } = props + // Collapse only after Host-confirmed settlement; a rejected write keeps its + // diagnostics and retained drafts visible for correction. + useEffect(() => { + if (state.saving) { + saveStarted.current = true + return + } + if (!saveStarted.current) return + saveStarted.current = false + if (!state.dirty && !state.failed) setOpen(false) + }, [state.dirty, state.failed, state.saving]) if (!state.available) return null const title = props.t(props.titleKey) const blocked = !state.dirty || state.invalid || state.saving diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index c71952a0bd..00105fbf24 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -1,12 +1,24 @@ +.permission { + display: grid; + gap: 6px; + padding: 12px 0; +} + .toggleRow { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 16px; font-size: 13px; + line-height: 1.5; color: var(--dsw-alias-label-secondary); } +.toggleLabel { + flex: 1; + min-width: 0; +} + .switch { box-sizing: border-box; position: relative; diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index 962dfdb41c..e82721f908 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -70,24 +70,28 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp onSave={props.save} onDiscard={props.discard} > -
    - {t('subagentModelSelectionToggle')} - +
    +
    + {t('subagentModelSelectionToggle')} + +
    +

    + {t(state.enabled ? 'subagentModelSelectionChoose' : 'subagentModelSelectionOff')} +

    {state.enabled ? (
    -

    {t('subagentModelSelectionChoose')}

    {state.catalogStatus === 'loading' ?

    {t('subagentModelSelectionLoading')}

    : null} @@ -130,7 +134,7 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp {state.invalid ?

    {t('subagentModelSelectionRequired')}

    : null}
    ) - :

    {t('subagentModelSelectionOff')}

    } + : null} ) } diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index d5207b5fe7..cf37eb05b5 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +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-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -69,7 +69,7 @@ function renderConfigurable(namespaces: string[], cards: Record render() } -function renderBash(state: Partial = {}) { +function renderBashCard(state: Partial = {}) { const store = createSnapshotStore({ ...settled, timeoutMs: field('60000'), @@ -79,7 +79,11 @@ function renderBash(state: Partial = {}) { const actions = cardActions() const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps render() - return actions + return { actions, store } +} + +function renderBash(state: Partial = {}) { + return renderBashCard(state).actions } function renderSubagentModelSelection(state: Partial = {}) { @@ -320,6 +324,30 @@ describe('BashCard', () => { expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() }) + + it('collapses after a successful save settles', () => { + const { actions, store } = renderBashCard({ dirty: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + fireEvent.click(screen.getByRole('button', { name: en.save })) + expect(actions.save).toHaveBeenCalledOnce() + + act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) + act(() => { store.set({ ...store.getSnapshot(), dirty: false, saving: false }) }) + + expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() + }) + + it('keeps a failed save open', () => { + const { store } = renderBashCard({ dirty: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + fireEvent.click(screen.getByRole('button', { name: en.save })) + + act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) + act(() => { store.set({ ...store.getSnapshot(), failed: true, saving: false }) }) + + expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() + expect(screen.getByText(en.saveFailed)).toBeTruthy() + }) }) describe('SubagentModelSelectionCard', () => { From d5787b18475ed53493c189edbee2184f4f6a397f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 15:09:27 +0800 Subject: [PATCH 31/38] test(web): expect plugin cards to collapse after save --- apps/web/tests/plugin-config.e2e.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index e3b34776bb..ec0d42c40d 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -103,24 +103,29 @@ describe('web e2e: plugin configuration section', () => { await firstModel.check() await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') + const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' }) + await expandSubagent.waitFor({ timeout: 5_000 }) await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) .toBe(true) expect(await settingsDocument()).toContain('enabled: true') expect(await settingsDocument()).toContain('allowedModels:') expect(await settingsDocument()).toContain('provider:') expect(await settingsDocument()).toContain('model:') + await expandSubagent.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true) expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0) await toggle.click() await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false') + await expandSubagent.waitFor({ timeout: 5_000 }) await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 }) .toBe(true) expect(await settingsDocument()).toContain('allowedModels:') expect(await settingsDocument()).toContain('provider:') expect(await settingsDocument()).toContain('model:') + await expandSubagent.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false') expect(tripwire.pageErrors).toEqual([]) }, 60_000) @@ -145,6 +150,9 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 }) .toBe(true) + const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) + await expandTerminal.waitFor({ timeout: 5_000 }) + await expandTerminal.click() // Presence in the user layer is what the badge reports, and the reset is // offered only for a field that has one. await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1) @@ -203,6 +211,9 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 }) .toBe(false) + const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) + await expandTerminal.waitFor({ timeout: 5_000 }) + await expandTerminal.click() expect(await timeout.inputValue()).toBe('60000') expect(await dialog.getByText('已覆盖').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) From cbacceca4b409a1ae9bb6ff63e317c51ecf8b6cc Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 16:04:56 +0800 Subject: [PATCH 32/38] fix(ui-settings-plugins): place Subagent after Agent loop --- .../tests/expected/plugin-config/section.expected.md | 8 ++++---- apps/web/tests/plugin-config.e2e.ts | 4 ++-- .../client/ui-settings-plugins/src/client/index.ts | 12 ++++++------ .../ui-settings-plugins/tests/apply.client.spec.ts | 2 +- .../cordis-client-runner/src/client/slot-catalog.ts | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 1a95e7032d..08e89d18f8 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -24,10 +24,6 @@ - tab "插件列表" - tabpanel "插件配置": - list: - - listitem: - - 'button "展开设置: Subagent"': - - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。 - - img - listitem: - 'button "展开设置: 终端"': - text: 终端 限制 agent 运行的每一条命令。 @@ -36,6 +32,10 @@ - 'button "展开设置: Agent 循环"': - text: Agent 循环 Agent 如何派发工具调用。 - img + - listitem: + - 'button "展开设置: Subagent"': + - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。 + - img - listitem: - 'button "展开设置: 网页搜索"': - text: 网页搜索 DeepSeek 搜索提供方。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index ec0d42c40d..9d7982d7d8 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -75,8 +75,8 @@ describe('web e2e: plugin configuration section', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards')) const dialog = await openPlugins() - // Every card the shipped web composition exposes: subagent selection, the - // shell executor, the agent loop, and the DeepSeek search provider. + // Every card the shipped web composition exposes: the shell executor, the + // agent loop, subagent selection, and the DeepSeek search provider. await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1) await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 4b25ae2871..0c8761882c 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -165,12 +165,6 @@ export function apply(ctx: ClientContext): void { }, ConfigurablePluginsTab)) ctx.slots.inject('settings.plugin.item', function* () { - yield ctx.slots.register({ - name: 'settings.plugin.item', - key: SUBAGENT_MODEL_SELECTION_NS, - locale: NS, - inject: () => subagentModelSelection.inject(), - }, SubagentModelSelectionCard) yield ctx.slots.register({ name: 'settings.plugin.item', key: SHELL_NS, @@ -183,6 +177,12 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: () => agentLoop.inject(), }, AgentLoopCard) + yield ctx.slots.register({ + name: 'settings.plugin.item', + key: SUBAGENT_MODEL_SELECTION_NS, + locale: NS, + inject: () => subagentModelSelection.inject(), + }, SubagentModelSelectionCard) yield ctx.slots.register({ name: 'settings.plugin.item', key: WEB_SEARCH_NS, 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 cf237667fc..9f0f00e93b 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -126,7 +126,7 @@ describe('ui-settings-plugins apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)) - .toEqual(['subagent-model-selection', 'shell', 'agent-loop', 'web-search-deepseek']) + .toEqual(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek']) }) it('dispatches the served namespaces its cards claim, and no others', async () => { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4540e2b743..35caa57d51 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1715,9 +1715,9 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'settings.plugins.tab\' (client-ui-settings-plugins), so it exists while that entry is mounted', occupants: [ - 'client-ui-settings-plugins SubagentModelSelectionCard', 'client-ui-settings-plugins BashCard', 'client-ui-settings-plugins AgentLoopCard', + 'client-ui-settings-plugins SubagentModelSelectionCard', 'client-ui-settings-plugins WebSearchCard', ], replaceRisk: 'none', From aad90d5cf3f6a3b98626b0869a070557d3448dab Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 16:40:58 +0800 Subject: [PATCH 33/38] fix(ui-settings-plugins): preserve model selection drafts --- ...authorized-subagent-model-routes.i18n.yaml | 4 +- ...4-user-authorized-subagent-model-routes.md | 4 +- ...ser-authorized-subagent-model-routes.zh.md | 4 +- .../SubagentModelSelectionCard.module.css | 6 +- .../src/client/SubagentModelSelectionCard.tsx | 3 + .../ui-settings-plugins/src/client/index.ts | 2 +- .../ui-settings-plugins/src/client/locales.ts | 4 +- ...ubagent-model-selection-card-controller.ts | 73 +++++++----- .../tests/apply.client.spec.ts | 2 +- .../tests/section.client.spec.tsx | 9 ++ .../tests/stores.client.spec.ts | 111 +++++++++++++++++- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 4 +- packages/subagent/tool-subagent/README.zh.md | 4 +- 14 files changed, 184 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index 803fcddb1f..4ba77e05b1 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: 3bb76eb8941dd5a7f86e95cfafe14516a4f948d7 -2026-08-24-user-authorized-subagent-model-routes.zh.md: 5defbd0ee0921666a119eaac914580dad747868c +2026-08-24-user-authorized-subagent-model-routes.md: 0f9816ae89562545c267d87713c13faecd9efc66 +2026-08-24-user-authorized-subagent-model-routes.zh.md: ca63215a5ec7ae56ae2f077a7fb19fc1e204527c diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index 3bb76eb894..0f9816ae89 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -10,7 +10,7 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an ## Decision -The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase stored authorization. +The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. @@ -36,7 +36,7 @@ Model selection has no unrestricted static mode. The default-off Host setting is - Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it. - The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. - The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it. -- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow. +- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged-route retention, connection-generation invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow. ## Related decisions diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index 5defbd0ee0..ca63215a5e 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权。 +Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 @@ -36,7 +36,7 @@ Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` - 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。 - 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。 - 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。 -- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存后的整数组写入、过期 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 +- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存路由保留、连接换代失效、暂存后的整数组写入、陈旧 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 ## Related decisions diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index 00105fbf24..3508ec8e48 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -66,7 +66,8 @@ .hint, .notice, -.invalid { +.invalid, +.conflict { margin: 0; font-size: 12px; line-height: 1.5; @@ -77,7 +78,8 @@ color: var(--dsw-alias-label-tertiary); } -.invalid { +.invalid, +.conflict { color: var(--dsw-alias-label-error); } diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index e82721f908..e5ba1ffc11 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -135,6 +135,9 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
    ) : null} + {state.conflicted + ?

    {t('subagentModelSelectionConflict')}

    + : null} ) } diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 0c8761882c..8dd09f1d03 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -90,7 +90,7 @@ export function apply(ctx: ClientContext): void { 'ui-settings-plugins: subagent settings invalidations', ) ctx.effect( - () => ctx.on('connection/reset', () => { subagentModelSelection.resetCatalog() }), + () => ctx.on('connection/reset', () => { subagentModelSelection.resetConnection() }), 'ui-settings-plugins: subagent connection generation', ) ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 018d87d47d..b68ab80beb 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -16,7 +16,7 @@ export type PluginsSettingsLocaleKey = | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' - | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff' /** English copy. */ export const en: Record = { @@ -70,6 +70,7 @@ export const en: Record = { subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', subagentModelSelectionEmpty: 'No model provider currently advertises a model.', subagentModelSelectionRequired: 'Select at least one model before saving.', + subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.', subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', } @@ -125,5 +126,6 @@ export const zh: Record = { subagentModelSelectionUnavailableGroup: '已保存但当前不可用', subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', subagentModelSelectionRequired: '保存前请至少选择一个模型。', + subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。', subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index 8af7361f13..74917edc56 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -49,6 +49,8 @@ export interface SubagentModelSelectionCardState extends CardShell { catalogStatus: 'idle' | 'loading' | 'ready' | 'error' /** Whether any provider-local catalog request failed. */ catalogPartial: boolean + /** Whether a newer Host revision invalidated the current draft. */ + conflicted: boolean } /** Registration-side face for the subagent model-selection card. */ @@ -130,10 +132,11 @@ export class SubagentModelSelectionCardController { private catalogPartial = false private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' private draftEnabled: boolean | undefined - private draftSelected: Set | undefined + private draftRoutes: Map | undefined private draftRevision: number | undefined private saving = false private failed = false + private conflicted = false private disposed = false private saveGeneration = 0 private catalogGeneration = 0 @@ -150,9 +153,11 @@ export class SubagentModelSelectionCardController { ) { this.store = createSnapshotStore(this.projection()) this.unsubscribe = scope.subscribe(() => { - if (!this.saving && this.draftSelected !== undefined + if (!this.saving && this.draftRoutes !== undefined && this.scope.getSnapshot().revision !== this.draftRevision) { - this.failed = true + if (this.currentEnabled() === this.enabled() + && sameRoutes(this.currentRoutes(), this.desiredRoutes())) this.clearDraft() + else this.conflicted = true } if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() this.publish() @@ -192,21 +197,23 @@ export class SubagentModelSelectionCardController { } private selected(): Set { - return this.draftSelected ?? new Set(this.currentRoutes().map(subagentModelKey)) + return new Set(this.draftRoutes?.keys() ?? this.currentRoutes().map(subagentModelKey)) } private enabled(): boolean { return this.draftEnabled ?? this.currentEnabled() } - private beginDraft(): Set { - if (this.draftSelected === undefined) { + private beginDraft(): Map { + if (this.draftRoutes === undefined) { const snapshot = this.scope.getSnapshot() this.draftEnabled = snapshot.value?.enabled ?? false - this.draftSelected = new Set(snapshot.value?.allowedModels.map(subagentModelKey) ?? []) + this.draftRoutes = new Map( + snapshot.value?.allowedModels.map(route => [subagentModelKey(route), { ...route }]) ?? [], + ) this.draftRevision = snapshot.revision } - return this.draftSelected + return this.draftRoutes } private toggleEnabled(): void { @@ -221,31 +228,37 @@ export class SubagentModelSelectionCardController { private toggleModel(key: string): void { if (!this.enabled() || this.saving || !this.scope.getSnapshot().writable) return - if (!this.candidates().some(candidate => candidate.key === key)) return - const selected = this.beginDraft() - if (selected.has(key)) selected.delete(key) - else selected.add(key) + const candidate = this.candidates().find(candidate => candidate.key === key) + if (candidate === undefined) return + const routes = this.beginDraft() + if (routes.has(key)) routes.delete(key) + else routes.set(key, { provider: candidate.provider, model: candidate.model }) this.failed = false this.publish() } + private clearDraft(): void { + this.draftEnabled = undefined + this.draftRoutes = undefined + this.draftRevision = undefined + this.failed = false + this.conflicted = false + } + private discard(): void { if (this.saving) return - this.draftEnabled = undefined - this.draftSelected = undefined - this.draftRevision = undefined - this.failed = false + this.clearDraft() this.publish() } private candidates(): SubagentModelCandidate[] { - return subagentModelCandidates(this.catalogGroups, this.currentRoutes(), this.selected()) + const retained = new Map(this.currentRoutes().map(route => [subagentModelKey(route), route])) + for (const [key, route] of this.draftRoutes ?? []) retained.set(key, route) + return subagentModelCandidates(this.catalogGroups, [...retained.values()], this.selected()) } private desiredRoutes(): AllowedSubagentModel[] { - return this.candidates() - .filter(candidate => candidate.selected) - .map(({ provider, model }) => ({ provider, model })) + return [...this.draftRoutes?.values() ?? this.currentRoutes()].map(route => ({ ...route })) } private async save(): Promise { @@ -255,14 +268,15 @@ export class SubagentModelSelectionCardController { if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) || (desiredEnabled && desired.length === 0)) return - if (this.draftSelected !== undefined && snapshot.revision !== this.draftRevision) { - this.failed = true + if (this.draftRoutes !== undefined && snapshot.revision !== this.draftRevision) { + this.conflicted = true this.publish() return } const generation = this.saveGeneration this.saving = true this.failed = false + this.conflicted = false this.publish() await this.scope.mutate([ { op: 'set', path: ['enabled'], value: desiredEnabled }, @@ -272,11 +286,7 @@ export class SubagentModelSelectionCardController { const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) this.saving = false this.failed = !landed - if (landed) { - this.draftEnabled = undefined - this.draftSelected = undefined - this.draftRevision = undefined - } + if (landed) this.clearDraft() this.publish() } @@ -290,9 +300,12 @@ export class SubagentModelSelectionCardController { else this.publish() } - /** Clear Host-specific candidates and reload after reconnecting. */ - resetCatalog(): void { + /** Drop Host-specific candidates and drafts, then reload after reconnecting. */ + resetConnection(): void { if (this.disposed) return + this.saveGeneration += 1 + this.saving = false + this.clearDraft() this.catalogGroups = [] this.refreshCatalog() } @@ -301,7 +314,6 @@ export class SubagentModelSelectionCardController { if (this.disposed || this.catalogStatus === 'loading') return const generation = this.catalogGeneration this.catalogStatus = 'loading' - this.catalogGroups = [] this.catalogPartial = false this.publish() try { @@ -334,6 +346,7 @@ export class SubagentModelSelectionCardController { candidates: this.candidates(), catalogStatus: this.catalogStatus, catalogPartial: this.catalogPartial, + conflicted: this.conflicted, } } 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 9f0f00e93b..6d118856ef 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -187,7 +187,7 @@ describe('ui-settings-plugins apply', () => { it('refreshes the subagent catalog after model inputs change or the connection resets', async () => { const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') - const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetCatalog') + const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection') const { ctx, slots, remote } = await bench(['subagent-model-selection']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index cf37eb05b5..e84a00cf49 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -93,6 +93,7 @@ function renderSubagentModelSelection(state: Partial { expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() }) + it('distinguishes a stale draft from a rejected save', () => { + renderSubagentModelSelection({ dirty: true, conflicted: true }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + + expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy() + expect(screen.queryByText(en.saveFailed)).toBeNull() + }) + it('stays hidden when unavailable and disables writes when read-only', () => { renderSubagentModelSelection({ available: false }) expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull() 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 3e5002954d..28c26e73cd 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -614,17 +614,122 @@ describe('SubagentModelSelectionCardController', () => { revision: 5, value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, }) - expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ failed: true, dirty: true }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: true, failed: false, dirty: true, + }) face.save() await Promise.resolve() expect(host.mutate).not.toHaveBeenCalled() face.discard() expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ - failed: false, dirty: false, enabled: true, + conflicted: false, failed: false, dirty: false, enabled: true, }) }) + it('settles a draft when a newer Host revision already contains it', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + host.publish({ + revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, dirty: false, enabled: true, + }) + }) + + it('retains unsaved routes across a catalog refresh', async () => { + const host = stubSettingsScope() + acceptWrites(host) + host.publish({ + status: 'ready', writable: true, revision: 2, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const refreshed = deferred() + const models = vi.fn() + .mockResolvedValueOnce({ + rpcId: 'catalog-1', + result: { ok: true, value: { + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [], + } }, + }) + .mockImplementationOnce(() => refreshed.promise) + const controller = new SubagentModelSelectionCardController( + host.scope, { llm: { models } } as never, + ) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + face.toggleEnabled() + await vi.waitFor(() => { expect(state().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + controller.refreshCatalog() + expect(state()).toMatchObject({ + catalogStatus: 'loading', + candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })], + }) + refreshed.resolve({ + rpcId: 'catalog-2', + result: { ok: true, value: { groups: [], failures: [] } }, + } as never) + await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) + expect(state().candidates).toEqual([ + expect.objectContaining({ key: 'alpha\0fast', available: false, selected: true }), + ]) + + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], 2) + }) + }) + + it('drops a draft when the connection generation changes', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + }) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + controller.resetConnection() + host.publish({ + revision: 4, + value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, dirty: false, enabled: true, + }) + face.save() + await Promise.resolve() + expect(host.mutate).not.toHaveBeenCalled() + }) + it('reloads the model catalog after invalidation', async () => { const host = stubSettingsScope() host.publish({ @@ -739,7 +844,7 @@ describe('SubagentModelSelectionCardController', () => { controller.dispose() controller.refreshCatalog() - controller.resetCatalog() + controller.resetConnection() face.toggleEnabled() face.retryCatalog() face.save() diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index e34f464d28..660bf0ba4d 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: 5225aa28719e92b2158951aa145d9335a67ff1a2 -README.zh.md: 702cf46d36e15c7524c3dffc5dd46035547ff768 +README.md: 5520e98ddcdc4532a74cf7a8a812b9e60751eaf3 +README.zh.md: 069eca4f8c9dd0bf8fd939be2569c63f88c6be0b diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5225aa2871..5520e98ddc 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -212,8 +212,8 @@ These limits define what this tool does not return or enforce; they are current - **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries any final assistant message, but it is not this call's return value and cannot be awaited here. - **Duplicate names across waiting one-shot instances are detected late** (`TODO(subagent-dup-toolname)`) — continuable instances reserve their prompt-section name during plugin application, but preventing provider-registration rollback for waiting one-shot instances requires a registry of intended names. -- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable the fields only when route changes preserve reuse or expose a bounded recomputation cost. -- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM provider/model/reasoning-effort selection requires an enabled per-Session preference and a subagent provider that advertises `agentOptions`; out-of-process providers currently reject enabling it rather than ignore it. +- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable selection only when route changes preserve reuse or expose a bounded recomputation cost. +- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM selection requires an enabled per-Session preference and a provider that advertises `agentOptions`; both in-process providers and DSH SDK advertise it, while ACP, Codex, and Claude Code reject it rather than ignore it. ### Dev Note diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 702cf46d36..069eca4f8c 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -212,8 +212,8 @@ Use subagent in the background by default. Start independent delegations togethe - **后台运行不通过本工具公开结果**——一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束,并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。 - **等待中的一次性实例较晚才发现重复名称**(`TODO(subagent-dup-toolname)`)——可继续实例会在插件应用期间预留提示词 section 名称,但若要阻止等待中的一次性实例回滚提供方注册,仍需要一份预期名称注册表。 -- **随附 fork 工具无法选择子级 LLM 路由**:它们会继承父级的提供方与模型,使复制的对话前缀仍可供 KV Cache 复用。只有在路由变化仍能保留复用,或接口能公开一项有界的重算成本时,才重新启用这些字段。 -- **每个实例的非路由子 agent 策略固定**:其他 persona、工具过滤器或深度上限都需要另一个名称不同的工具。LLM 提供方/模型/推理强度选择要求每 Session 偏好已启用,并要求 subagent 提供方声明 `agentOptions`;进程外提供方目前会拒绝启用它,而不是忽略它。 +- **随附 fork 工具不能选择子级 LLM 路由**——它们继承父级提供方与模型,使复制的对话前缀仍有资格复用 KV Cache。仅当路由变更能保留复用或公开有界重算成本时,才重新启用选择。 +- **非路由子 agent 策略按实例固定**——另一个 persona、工具过滤器或深度上限需要另一个名称不同的工具。LLM 选择要求启用逐 Session 偏好,且提供方必须声明 `agentOptions`;两个进程内提供方和 DSH SDK 会声明该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 ### 开发备注 From e49e7202c17ba11dc9516aa64c3a06ba03b178dd Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 27 Aug 2026 12:18:05 +0800 Subject: [PATCH 34/38] fix: align model selection with current settings remotes --- .../client/ui-settings-plugins/package.json | 3 + ...ubagent-model-selection-card-controller.ts | 6 +- .../tests/settings-scope.client.spec.ts | 15 +-- .../subagent/tool-subagent/tests/harness.ts | 29 +++-- .../tool-subagent/tests/list-models.spec.ts | 7 ++ .../tests/model-selection-settings.spec.ts | 4 +- .../tests/model-selection.spec.ts | 12 +- .../tool-subagent/tests/tool-subagent.spec.ts | 116 ++++++------------ pnpm-lock.yaml | 3 + .../cancel-tool-calls/stdout.expected.jsonl | 7 ++ .../ralph-loop/tool-schemas.1.expected.json | 31 +---- .../ralph-loop/tool-schemas.2.expected.json | 31 +---- .../snapshot.yml | 12 -- 13 files changed, 110 insertions(+), 166 deletions(-) create mode 100644 snapshots/acp/cancel-tool-calls/stdout.expected.jsonl delete mode 100644 snapshots/session/subagent-configured-effort-rejection/snapshot.yml diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 75b87d26fb..7edd06ff4e 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" @@ -47,6 +48,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -55,6 +57,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index 74917edc56..053074a1ff 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -280,7 +280,11 @@ export class SubagentModelSelectionCardController { this.publish() await this.scope.mutate([ { op: 'set', path: ['enabled'], value: desiredEnabled }, - { op: 'set', path: ['allowedModels'], value: desired }, + { + op: 'set', + path: ['allowedModels'], + value: desired.map(route => ({ provider: route.provider, model: route.model })), + }, ], this.draftRevision) if (generation !== this.saveGeneration) return const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) 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 fe0d4013ee..25091897f0 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -188,7 +188,7 @@ describe('SettingsScopeController', () => { const write = scope.mutate(ops) ops[0] = { op: 'unset', path: ['enabled'] } - ;(ops[1] as { value: Array<{ model: string }> }).value[0]!.model = 'changed' + ;(ops[1] as unknown as { value: Array<{ model: string }> }).value[0]!.model = 'changed' await write expect(mutate).toHaveBeenCalledWith( @@ -202,7 +202,7 @@ describe('SettingsScopeController', () => { }) it('preserves an editor-owned revision fence behind earlier queued writes', async () => { - const first = deferred>() + const first = deferred>() const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'system' }, 7)) .mockResolvedValueOnce(described({ preference: 'dark' }, 8)) @@ -217,11 +217,12 @@ describe('SettingsScopeController', () => { first.resolve(ok(view({ preference: 'dark' }, 8))) await Promise.all([earlier, fenced]) - expect(mutate).toHaveBeenNthCalledWith(2, { - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'light' }], - expectedRevision: 7, - }) + expect(mutate).toHaveBeenNthCalledWith( + 2, + 'ui-test', + [{ op: 'set', path: ['preference'], value: 'light' }], + 7, + ) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 8 }) }) diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 6fe8b866e4..91632402d6 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SubagentRuntime from '@deepseek-ai/dsh-subagent' @@ -22,14 +22,18 @@ export function fakeAgent(id = 'parent-1'): Agent { /** Mount the real tool and service stack around one scripted subagent provider. */ const setupAgents = new WeakMap() +const setupProviders = new WeakMap>>() let setupAgentCounter = 0 /** Test-only opt-in translated to the real Host setting and Session path. */ -type SetupConfig = tool.Config & { withModelSelection?: boolean } +type SetupConfig = tool.Config & { + withModelSelection?: boolean + parentAgentOptions?: AgentOptions +} const TEST_ALLOWED_MODELS = [ - 'allowed-model', 'configured-model', 'current-model', 'fast-model', 'other-model', - 'parent-model', 'unlisted-model', + 'allowed-model', 'child-model', 'configured-model', 'current-model', 'fast-model', + 'other-model', 'parent-model', 'selected-model', 'unlisted-model', ].flatMap(model => [ { provider: 'alpha', model }, { provider: 'current-provider', model }, @@ -38,7 +42,7 @@ const TEST_ALLOWED_MODELS = [ export async function setup(toolConfig: SetupConfig, mockConfig: Partial = {}): Promise { const ctx = new Context() - const { withModelSelection, ...config } = toolConfig + const { withModelSelection, parentAgentOptions, ...config } = toolConfig if (withModelSelection === true) { await ctx.plugin(SubagentModelSelectionConfig, { enabled: true, @@ -47,9 +51,11 @@ export async function setup(toolConfig: SetupConfig, mockConfig: Partial { await agentCtx.plugin(tool, { ...config, modelSelectionSettings: true }) }, @@ -61,11 +67,20 @@ export async function setup(toolConfig: SetupConfig, mockConfig: Partial { + const provider = setupProviders.get(ctx) + if (provider === undefined) throw new Error('context has no setup provider') + setupProviders.delete(ctx) + await provider.dispose() +} + /** Return the real Agent created for a settings-controlled setup. */ export function modelSelectionSetupAgent(ctx: Context): Agent { const agent = setupAgents.get(ctx) diff --git a/packages/subagent/tool-subagent/tests/list-models.spec.ts b/packages/subagent/tool-subagent/tests/list-models.spec.ts index d1739dcce8..308fe47f8e 100644 --- a/packages/subagent/tool-subagent/tests/list-models.spec.ts +++ b/packages/subagent/tool-subagent/tests/list-models.spec.ts @@ -238,4 +238,11 @@ describe('list_subagent_models', () => { expect(text(result)).toContain('available providers: alpha') expect(text(result)).not.toContain('secret') }) + + it('reports no available provider when the authorized registry intersection is empty', async () => { + const ctx = await setupListTool([{ provider: 'missing', model: 'fast' }]) + const result = await call(ctx, { provider: 'missing' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('available providers: (none)') + }) }) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 4078a828b7..ac1a103d11 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' @@ -171,7 +171,7 @@ describe('SubagentModelSelectionConfig', () => { const result = await ctx.tools.execute({ signal: new AbortController().signal, - callId: CallId('disallowed-session-route'), + callId: ToolCallId('disallowed-session-route'), name: 'subagent', arguments: { description: 'forced route', diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 9137e2a9dd..b9565975bb 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -10,7 +10,11 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' -import { assertAllowedModelRoutes, assertAllowedModelSelection } from '../src/model-selection.ts' +import { + assertAllowedModelRoutes, + assertAllowedModelSelection, + preflightChildLlmRoute, +} from '../src/model-selection.ts' import { callSubagent, modelSelectionSetupAgent, setup, text } from './harness.ts' const REASONING = { @@ -284,6 +288,12 @@ describe('dsh-tool-subagent model selection', () => { expect(text(result)).toContain('without an effective provider and model') }) + it('rejects preflight without an effective provider and model', async () => { + const ctx = await setup({ provider: 'mock' }) + await expect(preflightChildLlmRoute(ctx.llm, {}, undefined, AbortSignal.abort())) + .rejects.toThrow('without an effective provider and model') + }) + it.each([ { provider: 'alpha' }, { model: 'fast-model' }, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index cff7140a25..27c250a225 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import LlmRuntime, { ToolCallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { ToolCallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' @@ -21,7 +21,15 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import { callSubagent, fakeAgent, setup, testToolSignal, text } from './harness.ts' +import { + callSubagent, + disposeSetupProvider, + fakeAgent, + modelSelectionSetupAgent, + setup, + testToolSignal, + text, +} from './harness.ts' /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real @@ -221,36 +229,19 @@ describe('dsh-tool-subagent', () => { }) it('merges model overrides over provider-owned route defaults before preflight', async () => { - let seen: { agentOptions?: { provider?: string; model?: string; reasoningEffort?: string; maxTokens?: number } } | undefined - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - ctx.subagents.registerProvider({ - name: 'capture', - capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, + let seen: SubagentStartRequest | undefined + const ctx = await setup({ + provider: 'mock', + withModelSelection: true, + agentOptions: { reasoningEffort: ReasoningEffortId('high'), maxTokens: 321 }, + maxDepth: 'provider-managed', + }, { agentRouteDefaults: { provider: 'alpha', model: 'child-model' }, - start: async (request) => { - seen = request - return { - id: SessionId('capture-child'), - localAgent: undefined, - result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - dispose: async () => {}, - } - }, + onStart: (request) => { seen = request }, }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], { efforts: [{ id: ReasoningEffortId('high'), name: 'High' }], })) - await ctx.plugin(tool, { - provider: 'capture', - enableModelSelection: true, - agentOptions: { reasoningEffort: ReasoningEffortId('high'), maxTokens: 321 }, - maxDepth: 'provider-managed', - }) await callSubagent(ctx, { description: 'd', @@ -258,7 +249,7 @@ describe('dsh-tool-subagent', () => { provider: 'alpha', model: 'child-model', }) - expect(ctx.tools.schemas().find(schema => schema.name === 'subagent')?.description) + expect(ctx.tools.schemas(modelSelectionSetupAgent(ctx)).find(schema => schema.name === 'subagent')?.description) .toContain('this provider\'s route defaults') expect(seen?.agentOptions).toEqual({ provider: 'alpha', @@ -270,40 +261,21 @@ describe('dsh-tool-subagent', () => { it('does not inherit parent effort for a provider-owned route default', async () => { let seen: SubagentStartRequest | undefined - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - ctx.subagents.registerProvider({ - name: 'provider-defaults', - capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - agentRouteDefaults: { provider: 'alpha', model: 'child-model' }, - start: async (request) => { - seen = request - return { - id: SessionId('provider-default-child'), - localAgent: undefined, - result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - dispose: async () => {}, - } - }, - }) - ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) - await ctx.plugin(tool, { - provider: 'provider-defaults', - enableModelSelection: true, - maxDepth: 'provider-managed', - }) - const parent = { - ...fakeAgent('same-route-parent'), - options: { + const ctx = await setup({ + provider: 'mock', + withModelSelection: true, + parentAgentOptions: { provider: 'alpha', model: 'child-model', reasoningEffort: ReasoningEffortId('high'), }, - } as Agent + maxDepth: 'provider-managed', + }, { + agentRouteDefaults: { provider: 'alpha', model: 'child-model' }, + onStart: (request) => { seen = request }, + }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) + const parent = modelSelectionSetupAgent(ctx) const result = await callSubagent(ctx, { description: 'd', @@ -312,6 +284,7 @@ describe('dsh-tool-subagent', () => { model: 'child-model', }, { agent: parent }) + if (result.isError) throw new Error(text(result)) expect(result.isError).toBe(false) expect(seen?.agentOptions).toEqual({ provider: 'alpha', model: 'child-model' }) }) @@ -994,24 +967,15 @@ describe('dsh-tool-subagent background mode', () => { }) it('rejects startup when the provider changes during asynchronous route preflight', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - const oldStart = vi.fn(async (): Promise => { throw new Error('old provider must not start') }) + const oldStart = vi.fn() const replacementStart = vi.fn(async (): Promise => { throw new Error('replacement provider must not start') }) - const disposeOld = ctx.subagents.registerProvider({ - name: 'swapped', - capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - agentRouteDefaults: { provider: 'alpha', model: 'selected-model' }, - start: oldStart, - }) - await ctx.plugin(tool, { - provider: 'swapped', - enableModelSelection: true, + const ctx = await setup({ + provider: 'mock', + withModelSelection: true, maxDepth: 'provider-managed', + }, { + agentRouteDefaults: { provider: 'alpha', model: 'selected-model' }, + onStart: oldStart, }) const adapter = new MockAdapter([]) let releasePreflight!: () => void @@ -1029,9 +993,9 @@ describe('dsh-tool-subagent background mode', () => { model: 'selected-model', }) await vi.waitFor(() => { expect(resolveModel).toHaveBeenCalledOnce() }) - disposeOld() + await disposeSetupProvider(ctx) ctx.subagents.registerProvider({ - name: 'swapped', + name: 'mock', capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, agentRouteDefaults: { provider: 'beta', model: 'replacement-model' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f271832de3..0a1cf4046a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3127,6 +3127,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl b/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl new file mode 100644 index 0000000000..0b49c17e82 --- /dev/null +++ b/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl @@ -0,0 +1,7 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"node -e \"const fs=require('node:fs'); fs.writeFileSync('started.tmp', 'started'); fs.renameSync('started.tmp', 'started.txt'); setInterval(() => {}, 1000)\"","description":"Wait until cancellation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf skipped > skipped.txt","description":"Write skipped marker"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skipped","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted before dispatch"}}]}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/snapshots/session/ralph-loop/tool-schemas.1.expected.json b/snapshots/session/ralph-loop/tool-schemas.1.expected.json index 54d0732db7..dbcc5636e5 100644 --- a/snapshots/session/ralph-loop/tool-schemas.1.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -504,7 +487,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -516,18 +499,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/ralph-loop/tool-schemas.2.expected.json b/snapshots/session/ralph-loop/tool-schemas.2.expected.json index 54d0732db7..dbcc5636e5 100644 --- a/snapshots/session/ralph-loop/tool-schemas.2.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.2.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -504,7 +487,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -516,18 +499,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/snapshots/session/subagent-configured-effort-rejection/snapshot.yml b/snapshots/session/subagent-configured-effort-rejection/snapshot.yml deleted file mode 100644 index 327eb8de66..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/snapshot.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 1 -scenario: subagent-configured-effort-rejection -profile: headless -composition: subagent-configured-effort -recording: authored -header: - class: subagent-configured-effort - pin: true - systemPromptSource: text-turn - toolSchemasSource: text-turn -replay: - override: true From c873fc9d2ed2237ad3fd4e12ad3deaa21b46170a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:08:17 +0800 Subject: [PATCH 35/38] fix(client): stop rebuilding the turn rail on every chat render TurnNavigator was an unmemoized component rendering one div and one button per loaded Turn, so every ChatView render rebuilt the whole rail: 143 button rebuilds per commit in a 300-Turn session against 8.4 in a 4-Turn one, while a streaming answer commits dozens of times. memo alone would not have helped, because navigateToTurn was rebuilt on every render and broke prop identity, so it moves into useCallback. --- .../ui-chat/src/client/chat/ChatView.tsx | 5 ++-- .../ui-chat/src/client/chat/TurnNavigator.tsx | 16 +++++++++-- .../ui-chat/tests/chat-view.client.spec.tsx | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index ade617c95e..ecce592e67 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -498,7 +498,8 @@ export function ChatView({ loadOlder() } - const navigateToTurn = (item: TurnNavigationItem): void => { + // Identity feeds the memoized rail; a fresh closure per render would defeat it. + const navigateToTurn = useCallback((item: TurnNavigationItem): void => { const local = listRef.current if (local === null) return const row = anchorElement(local, item.anchorKey) @@ -519,7 +520,7 @@ export function ChatView({ const position = isAtBottom ? null : scrollPosition(local, el) if (isAtBottom) chatScroll.save(null) else if (position !== null) chatScroll.save(position) - } + }, [loadingOlder, chatScroll]) return (
    diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index b2533e533c..55818f85cd 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -1,5 +1,5 @@ import { - useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, + memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' import type { TurnNavigationItem } from '../contract/snapshot.ts' @@ -53,8 +53,7 @@ function itemAtPointer( return items[Math.round(ratio * (items.length - 1))] } -/** Compact rail of the currently loaded Turns with hover and focus previews. */ -export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { +function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { const [previewTurn, setPreviewTurn] = useState(null) const previewId = useId() if (items.length < 2) return null @@ -116,3 +115,14 @@ export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigato
    ) } + +/** + * Compact rail of the currently loaded Turns with hover and focus previews. + * + * Memoized because it renders two host elements per loaded Turn while the + * enclosing view re-renders on every streaming delta: without the guard a long + * session rebuilds hundreds of marks per commit for a rail that only changes + * when a Turn is added, removed, or becomes active. Its props must therefore + * stay referentially stable across those commits. + */ +export const TurnNavigator = memo(TurnNavigatorRail) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index ec6af626ee..7de458c73c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -412,6 +412,34 @@ describe('Chat node rendering', () => { }) describe('ChatView', () => { + it('leaves the turn rail unrendered when an unrelated Chat update commits', () => { + const snapshot = chatSnapshotFixture({ + nodes: [ + userInTurn(1, 'first prompt', 1), + assistant(2, 'first response', 1), + userInTurn(4, 'second prompt', 2), + assistant(5, 'second response', 2), + ], + turnEnds: new Map([[1, 3], [2, 6]]), + }) + const h = makeHarness({}, {}, snapshot) + // The rail asks for its own accessible name once per render, so counting + // that key counts renders without reaching into the component. + let railRenders = 0 + const translate = h.props.t + const counting = ((key: string, vars?: Record) => { + if (key === 'chat.turnNavigation.label') railRenders += 1 + return (translate as (k: string, v?: Record) => string)(key, vars) + }) as ChatViewSlotProps['t'] + render() + const afterMount = railRenders + expect(afterMount).toBeGreaterThan(0) + + act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) }) + + expect(railRenders).toBe(afterMount) + }) + it('projects loaded turns into prompt and response navigation previews', () => { const snapshot = chatSnapshotFixture({ nodes: [ From a7b054b8f667e6f85ce0e2d197c85a285a5c5cd0 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 27 Aug 2026 13:09:08 +0800 Subject: [PATCH 36/38] docs: refresh module dependency graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8866f5a1f1..5d08c21d3e 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: 93170bd4d76d768d1cf5f1bc5efdf94d1ce2453a -module-graph.zh.md: ac7f7e70d2364469ce5de5bf0734457e8e4dfb3e +module-graph.md: 28885c4bde3215075f213135ad64db8feadb4a9a +module-graph.zh.md: 68d22f46c645dd62426f893bec4a0b5399d5ef6e diff --git a/docs/module-graph.md b/docs/module-graph.md index 93170bd4d7..28885c4bde 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1343,6 +1343,7 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes + pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1900,7 +1901,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ac7f7e70d2..68d22f46c6 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1345,6 +1345,7 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes + pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1902,7 +1903,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | From 2722c202adc3a26a9dafb3d3d190ee89b1063d42 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 27 Aug 2026 13:25:51 +0800 Subject: [PATCH 37/38] fix(subagent): tolerate policy-only preset states --- .../subagent/tool-subagent/src/invariant.ts | 26 +++++++++------- .../tests/model-selection-settings.spec.ts | 31 +++++++++++++++++-- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 9207e01e4f..0c6751e506 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -15,20 +15,22 @@ export const name = 'tool-subagent-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert that a durable opt-in is represented by both model-facing definitions. */ +/** Assert that model-selectable definitions are complete and reconstructable. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { ctx.on('agent/pre-step', async ({ agent }, next) => { - if (subagentModelSelectionPolicy(agent.session) !== undefined) { - const schemas = ctx.tools.schemas(agent) - const selectable = schemas.some((schema) => { - const properties = (schema.parameters as { properties?: Record }).properties - return properties?.['provider'] !== undefined - && properties['model'] !== undefined - && properties['reasoning_effort'] !== undefined - }) - if (!selectable || !schemas.some(schema => schema.name === 'list_subagent_models')) { - fail('a subagent/model-selection-policy session must expose route fields and list_subagent_models') - } + const schemas = ctx.tools.schemas(agent) + const selectable = schemas.some((schema) => { + const properties = (schema.parameters as { properties?: Record }).properties + return properties?.['provider'] !== undefined + && properties['model'] !== undefined + && properties['reasoning_effort'] !== undefined + }) + const discoverable = schemas.some(schema => schema.name === 'list_subagent_models') + if ( + (selectable || discoverable) + && (subagentModelSelectionPolicy(agent.session) === undefined || !selectable || !discoverable) + ) { + fail('model-selectable subagent definitions require a durable policy, route fields, and list_subagent_models') } return next() }, { global: true }) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index ac1a103d11..53cbfac798 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -189,6 +189,8 @@ describe('SubagentModelSelectionConfig', () => { it('installs per-Agent definitions for a shared preset scope', async () => { const ctx = await boot() + await ctx.plugin(InvariantRegistry, { enabled: true }) + await ctx.plugin(ToolInvariant) const preset = createScope(ctx, { preset: 'standard' }) const other = createScope(ctx, { preset: 'minimal' }) await preset.ctx.plugin(tool, { @@ -219,9 +221,22 @@ describe('SubagentModelSelectionConfig', () => { enabledBinding!.rebind(scopeOf(other.ctx)!) ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(false) }) + const next = () => Promise.resolve({ kind: 'enter' as const, messages: [] }) + const payload = { + agent: enabled.agent, + messages: [], + turn: 1, + step: 1, + signal: new AbortController().signal, + } + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) + .resolves.toEqual({ kind: 'enter', messages: [] }) + enabledBinding!.rebind(scopeOf(preset.ctx)!) ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(true) }) + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) + .resolves.toEqual({ kind: 'enter', messages: [] }) await enabled.dispose() ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') @@ -322,7 +337,7 @@ describe('SubagentModelSelectionConfig', () => { await withoutAgent.fiber.dispose() }) - it('checks the durable decision against the published tool definitions', async () => { + it('checks model-selectable definitions without rejecting a policy-only preset', async () => { const ctx = await boot() await ctx.plugin(InvariantRegistry, { enabled: true }) await ctx.plugin(ToolInvariant) @@ -341,7 +356,7 @@ describe('SubagentModelSelectionConfig', () => { disabled.session.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) - .rejects.toThrow('must expose route fields and list_subagent_models') + .resolves.toEqual({ kind: 'enter', messages: [] }) await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true, @@ -350,6 +365,18 @@ describe('SubagentModelSelectionConfig', () => { const enabled = await createAgent(ctx, 'invariant-enabled') await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) + + const enabledSchemas = ctx.tools.schemas(enabled) + const schemas = vi.spyOn(ctx.tools, 'schemas') + schemas.mockReturnValue(enabledSchemas.filter(schema => schema.name !== 'list_subagent_models')) + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) + .rejects.toThrow('require a durable policy, route fields, and list_subagent_models') + + schemas.mockReturnValue(enabledSchemas) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + const withoutPolicy = await createAgent(ctx, 'invariant-without-policy') + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: withoutPolicy }, next)) + .rejects.toThrow('require a durable policy, route fields, and list_subagent_models') await ctx.fiber.dispose() }) }) From af562d3649a8925ce048aa6cb9fc5ebec3543919 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:03:59 +0800 Subject: [PATCH 38/38] fix(api-gateway): keep idle websocket alive --- ...sion-history-and-event-transport.i18n.yaml | 4 +-- ...-18-session-history-and-event-transport.md | 10 ++++-- ...-session-history-and-event-transport.zh.md | 10 ++++-- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 17 +++++++++- docs/config-catalog.zh.md | 17 +++++++++- packages/api/gateway/README.i18n.yaml | 4 +-- packages/api/gateway/README.md | 3 +- packages/api/gateway/README.zh.md | 3 +- packages/api/gateway/package.json | 2 ++ packages/api/gateway/src/index.ts | 22 ++++++++++++- packages/api/gateway/src/stream-server.ts | 17 ++++++++++ .../gateway/tests/gateway-stream.host.spec.ts | 31 +++++++++++++++++-- .../gateway/tests/stream-server.host.spec.ts | 29 +++++++++++++++-- packages/api/gateway/tsconfig.host.json | 6 ++++ pnpm-lock.yaml | 6 ++++ 16 files changed, 166 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 00652d3b09..2cd0ed343b 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 808565ff7df60b8aa6aa3f18820c1139b8bf5362 -2026-08-18-session-history-and-event-transport.zh.md: 8bd00def4531afa9cdf77ae7f689f2e77908545e +2026-08-18-session-history-and-event-transport.md: 5f4aba19d147eae3f49fcefc9a8006d0f557dc6c +2026-08-18-session-history-and-event-transport.zh.md: dcf7e7ffc5ad325756b1fb37dd5ad60d2f0b3147 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 808565ff7d..5f4aba19d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -62,6 +62,8 @@ API Proxy owns neither the Session or Workspace Remote namespace nor the Host do The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream. +The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. The Host imposes no Pong deadline, so half-open detection remains with TCP and network intermediaries. + After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`. In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux. @@ -76,7 +78,7 @@ Unexpected normal completion of `$events`, a Host error, a malformed opening fra Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state. -Plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. +Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. ### General Remote stream model @@ -320,13 +322,15 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re **Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection. +**Send application-level JSON heartbeat frames.** This would expand the strict Remote stream message union and require browser handling for traffic with no business meaning. WebSocket Ping/Pong provides carrier activity without changing logical-stream semantics. + **Retain API Proxy's Host mux.** This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently. **Update Session list time from aggregate `session/event`.** List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable `lastPromptAt` projection expresses the ordering fact directly. ## Verification -Gateway mux tests pin connection without logical streams, idle residency, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. +Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure. @@ -364,6 +368,8 @@ Durable logs repair a missing suffix by sequence number and page; Session contro Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation. +Each resident browser connection adds one empty Ping/Pong exchange per configured interval. Deployments can shorten the interval for stricter idle timeouts without changing the Remote stream protocol or browser code. + Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary. The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 8bd00def45..dcf7e7ffc5 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -62,6 +62,8 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。 +Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。Host 不设置 Pong deadline,因此半开检测仍由 TCP 与网络中间层承担。 + 首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。 进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。 @@ -76,7 +78,7 @@ Host event source 在返回首帧前同步安装增量 listener。Gateway 随后 Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 -插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。 +Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。 ### 通用 Remote stream 模型 @@ -320,13 +322,15 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace **给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。 +**发送应用层 JSON 心跳帧。** 这会扩展严格的 Remote stream message union,并要求浏览器处理没有业务含义的流量。WebSocket Ping/Pong 无需改变 logical stream 语义即可保持 carrier 活跃。 + **继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。 **从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。 ## 验证 -Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 +Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。 @@ -364,6 +368,8 @@ Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent C Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。 +每条常驻浏览器连接会按配置间隔增加一次空载荷 Ping/Pong 交换。面对更严格的空闲超时,部署方可缩短间隔,而无需改变 Remote stream 协议或浏览器代码。 + Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。 通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2407817a57..750dab17ed 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: 57b7f120ff09459e02f998806f6335f2b45d1b1b -config-catalog.zh.md: f0911b9d004d885868758351e16e3783b660eb18 +config-catalog.md: ab16221ff6c13768c9b0fb6a8189e30565dcc289 +config-catalog.zh.md: 8c9d956ad3ad5f672f73e5b4dd02aaed938c8667 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57b7f120ff..ab16221ff6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -274,6 +274,22 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-gateway` + +Requires: `typert` + +```ts config-catalog +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} +``` + +Source: [`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -3314,7 +3330,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-settings-controller` ([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f0911b9d00..8c9d956ad3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -276,6 +276,22 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-gateway` + +需要:`typert` + +```ts config-catalog +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} +``` + +来源:[`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -3316,7 +3332,6 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-settings-controller`([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 9bc399b963..8353039833 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 2e0cb32e4db6c1bee8576a5addd5492fb7ef53ac -README.zh.md: f67e13f6b1f789da02796397a121e59a55427cca +README.md: 504ff95494d8c374426c18d0a77fb238457561e3 +README.zh.md: e556b4981ce5789e6fe9f74bb7d4dc9c5217ae4c diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 2e0cb32e4d..504ff95494 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. -A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready. @@ -68,6 +68,7 @@ No direct effect; invoked business Services own any model-visible result. - `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. +- WebSocket heartbeats keep idle intermediaries active but do not require a timely Pong or terminate an unresponsive peer. Half-open carriers remain subject to TCP or intermediary failure detection before the Client reconnects. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index f67e13f6b1..e556b4981c 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。 @@ -68,6 +68,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 +- WebSocket 心跳用于保持空闲中间层活跃,但不会要求及时收到 Pong,也不会主动终止无响应对端。半开 carrier 仍需等待 TCP 或中间层检测失败后,Client 才会重连。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index e74d19946f..13c7e402ae 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -56,7 +56,9 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "ws": "^8.21.0" }, "peerDependencies": { diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 86a71d834a..ec4eb1eaef 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -9,6 +9,8 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import z from '@deepseek-ai/schemastery' import { remoteMethods, TypertLookupFailure, @@ -106,6 +108,17 @@ interface PendingRemoteEvent { type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal +const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000 + +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} + +interface ResolvedConfig extends Config { + readonly websocketHeartbeatIntervalMs: number +} /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -156,6 +169,10 @@ class RemoteInvocationCancelled extends Error { */ export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + static Config: z = z.object({ + websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) + .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), + }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ readonly wireStream: TypertGatewayWireStream = { @@ -171,9 +188,11 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Register the Gateway against the active Typert registry. * @param ctx - owning Host Context with Typert registry access. + * @param config - validated Gateway transport configuration. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: Config) { super(ctx, 'typertGateway') + const resolved = config as ResolvedConfig ctx.on('internal/service', () => { this.srcClaims = undefined }) @@ -188,6 +207,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const mux = new RemoteStreamMuxServer( (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), this.wireStream.failure, + resolved.websocketHeartbeatIntervalMs, ) webCtx.effect(() => { const route: WebUpgradeRoute = { diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 04b0df0427..28a3589542 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -23,14 +23,17 @@ export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure export class RemoteStreamMuxServer { private readonly server = new WebSocketServer({ noServer: true }) private readonly connections = new Set>() + private heartbeatTimer: NodeJS.Timeout | undefined /** * @param open - Gateway stream dispatcher. * @param failure - Gateway error-to-wire mapper. + * @param heartbeatIntervalMs - interval between WebSocket Ping control frames. */ constructor( private readonly open: RemoteStreamOpener, private readonly failure: RemoteStreamFailureMapper, + private readonly heartbeatIntervalMs: number, ) {} /** @@ -41,6 +44,7 @@ export class RemoteStreamMuxServer { */ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { this.server.handleUpgrade(req, socket, head, (websocket) => { + this.startHeartbeat() const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) const done = connection.run() this.connections.add(done) @@ -50,6 +54,8 @@ export class RemoteStreamMuxServer { /** Terminate all sockets and wait until every iterator has returned. */ async close(): Promise { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = undefined for (const socket of this.server.clients) socket.terminate() const closed = Promise.withResolvers() this.server.close((error) => { @@ -59,6 +65,17 @@ export class RemoteStreamMuxServer { await closed.promise await Promise.all(this.connections) } + + /** Start one `unref()` timer after the first upgrade; it spans empty-client periods until close(). */ + private startHeartbeat(): void { + if (this.heartbeatTimer !== undefined) return + this.heartbeatTimer = setInterval(() => { + for (const socket of this.server.clients) { + if (socket.readyState === WebSocket.OPEN) socket.ping() + } + }, this.heartbeatIntervalMs) + this.heartbeatTimer.unref() + } } interface ActiveStream { diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index d7289783d3..debb6763d5 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -5,6 +5,7 @@ import WebSocket, { type RawData } from 'ws' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import WebServer from '@deepseek-ai/dsh-host-webserver' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { bindTypertRemote, Remote, @@ -17,6 +18,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, + type Config as GatewayConfig, type TypertRemoteEventDispatch, type TypertRemoteEventInvocation, type TypertRemoteEventOutcome, @@ -211,6 +213,15 @@ afterEach(async () => { }) describe('Typert Remote streams', () => { + it('validates the WebSocket heartbeat timer range', () => { + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 30_000 }) + expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })) + .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }) + for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { + expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow() + } + }) + it('opens decoded carrier payloads through the in-process wire adapter', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.wireStream.open( @@ -280,6 +291,19 @@ describe('Typert Remote streams', () => { })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) }) + it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => { + const { ctx } = await setup(true, { websocketHeartbeatIntervalMs: 20 }) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + const ping = once(socket, 'ping') + await once(socket, 'open') + expect((await ping)[0]).toEqual(Buffer.alloc(0)) + + socket.close() + await once(socket, 'close') + }) + it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => { const { ctx, service } = await setup(true) const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { @@ -964,7 +988,10 @@ describe('Typert Remote streams', () => { }) }) -async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> { +async function setup( + transport: boolean, + gatewayConfig: GatewayConfig = {}, +): Promise<{ readonly ctx: Context; readonly service: FeedService }> { const ctx = new Context() roots.push(ctx) if (transport) { @@ -972,7 +999,7 @@ async function setup(transport: boolean): Promise<{ readonly ctx: Context; reado provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) - await ctx.plugin(TypertGatewayService) + await ctx.plugin(TypertGatewayService, gatewayConfig) if (transport) { await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) } diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 9746943b63..2cc4c99f8c 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -25,6 +25,31 @@ afterEach(async () => { }) describe('Remote stream mux server carrier lifecycle', () => { + it('sends WebSocket Ping control frames without application messages', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const client = await connect(entry.url) + const serverSocket = acceptedSocket(entry.mux) + const messages = vi.fn() + client.on('message', messages) + + const ping = once(client, 'ping') + const pong = once(serverSocket, 'pong') + expect((await ping)[0]).toEqual(Buffer.alloc(0)) + expect((await pong)[0]).toEqual(Buffer.alloc(0)) + expect(messages).not.toHaveBeenCalled() + + const closingPing = vi.spyOn(serverSocket, 'ping') + client.pause() + serverSocket.close() + expect(serverSocket.readyState).toBe(WebSocket.CLOSING) + await new Promise((resolve) => { setTimeout(resolve, 25) }) + expect(closingPing).not.toHaveBeenCalled() + + const closed = once(client, 'close') + client.resume() + await closed + }) + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) @@ -168,8 +193,8 @@ const mapFailure: RemoteStreamFailureMapper = error => ({ details: {}, }) -async function startMux(open: RemoteStreamOpener): Promise { - const mux = new RemoteStreamMuxServer(open, mapFailure) +async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 30_000): Promise { + const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs) const http = createServer() http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) await new Promise((resolve, reject) => { diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 46d1b3a88d..54d5f964f3 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -19,6 +19,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../runtime-diagnostics/invariants" }, @@ -30,6 +33,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../util/timeout" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a1cf4046a..83e99c0ed7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,9 +627,15 @@ importers: packages/api/gateway: dependencies: + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery ws: specifier: ^8.21.0 version: 8.21.0