mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(subagent): authorize selectable child models
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 缓存前缀。
|
||||
|
||||
+6
@@ -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
|
||||
@@ -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).
|
||||
+42
@@ -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)负责。
|
||||
@@ -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 运行的每一条命令。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
<a id="deepseek-aidsh-tool-subagent-report"></a>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
<a id="subagentmodel-selection-enabled--log-only"></a>
|
||||
<a id="subagentmodel-selection-policy--log-only"></a>
|
||||
|
||||
#### `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<string, never>
|
||||
'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/*`
|
||||
|
||||
|
||||
@@ -740,9 +740,9 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
|
||||
来源:[`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts)
|
||||
|
||||
<a id="subagentmodel-selection-enabled--log-only"></a>
|
||||
<a id="subagentmodel-selection-policy--log-only"></a>
|
||||
|
||||
#### `subagent/model-selection-enabled` — log-only
|
||||
#### `subagent/model-selection-policy` — 仅日志
|
||||
|
||||
```ts persistence-catalog
|
||||
/**
|
||||
@@ -751,10 +751,13 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
* request; absence means the fixed-route definition. Log-only: it carries
|
||||
* no `surfaceOp` and never enters model history.
|
||||
*/
|
||||
'subagent/model-selection-enabled': Record<string, never>
|
||||
'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/*`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+95
-33
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<SubagentModelSelectionCardFace>
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<li className={css.card} aria-labelledby="subagent-model-selection-title">
|
||||
<div className={css.copy}>
|
||||
<h3 id="subagent-model-selection-title" className={css.title}>
|
||||
{t('subagentModelSelectionTitle')}
|
||||
</h3>
|
||||
<p className={css.description}>{t('subagentModelSelectionDescription')}</p>
|
||||
<PluginCard
|
||||
t={t}
|
||||
titleKey="subagentModelSelectionTitle"
|
||||
descriptionKey="subagentModelSelectionDescription"
|
||||
state={state}
|
||||
onSave={props.save}
|
||||
onDiscard={props.discard}
|
||||
>
|
||||
<div className={css.toggleRow}>
|
||||
<span>{t('subagentModelSelectionToggle')}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={state.enabled}
|
||||
aria-label={t('subagentModelSelectionToggle')}
|
||||
className={clsx(css.switch, state.enabled && css.switchOn)}
|
||||
disabled={!state.writable || state.saving}
|
||||
onClick={props.toggleEnabled}
|
||||
>
|
||||
<span className={css.thumb} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={state.enabled}
|
||||
aria-label={t('subagentModelSelectionToggle')}
|
||||
className={clsx(css.switch, state.enabled && css.switchOn)}
|
||||
disabled={!state.writable || state.saving}
|
||||
onClick={props.toggle}
|
||||
>
|
||||
<span className={css.thumb} />
|
||||
</button>
|
||||
{state.enabled
|
||||
? (
|
||||
<div className={css.selection}>
|
||||
<p className={css.hint}>{t('subagentModelSelectionChoose')}</p>
|
||||
{state.catalogStatus === 'loading'
|
||||
? <p className={css.notice} role="status">{t('subagentModelSelectionLoading')}</p>
|
||||
: null}
|
||||
{state.catalogStatus === 'error'
|
||||
? (
|
||||
<div className={css.catalogError} role="alert">
|
||||
<span>{t('subagentModelSelectionLoadFailed')}</span>
|
||||
<button type="button" disabled={state.saving} onClick={props.retryCatalog}>
|
||||
{t('subagentModelSelectionRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{state.catalogFailures.length > 0
|
||||
? <p className={css.notice}>{t('subagentModelSelectionPartial')}</p>
|
||||
: null}
|
||||
{state.candidates.length > 0
|
||||
? (
|
||||
<fieldset className={css.models}>
|
||||
<legend>{t('subagentModelSelectionAllowed')}</legend>
|
||||
{state.candidates.map(candidate => (
|
||||
<label key={candidate.key} className={css.model}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={candidate.selected}
|
||||
disabled={!state.writable || state.saving}
|
||||
onChange={() => { props.toggleModel(candidate.key) }}
|
||||
/>
|
||||
<span>
|
||||
<span className={css.modelName}>{candidate.modelName}</span>
|
||||
<span className={css.route}>{`${candidate.providerName} · ${candidate.provider}/${candidate.model}`}</span>
|
||||
</span>
|
||||
{!candidate.available
|
||||
? <span className={css.unavailable}>{t('subagentModelSelectionUnavailable')}</span>
|
||||
: null}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)
|
||||
: state.catalogStatus === 'ready'
|
||||
? <p className={css.notice}>{t('subagentModelSelectionEmpty')}</p>
|
||||
: null}
|
||||
{state.invalid ? <p className={css.invalid}>{t('subagentModelSelectionRequired')}</p> : null}
|
||||
</div>
|
||||
)
|
||||
: <p className={css.hint}>{t('subagentModelSelectionOff')}</p>}
|
||||
{state.saved ? <p className={css.status} role="status">{t('subagentModelSelectionSaved')}</p> : null}
|
||||
{state.failed ? <p className={css.failed} role="alert">{t('subagentModelSelectionSaveFailed')}</p> : null}
|
||||
</li>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PluginsSettingsLocaleKey, string> = {
|
||||
@@ -54,10 +57,19 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
|
||||
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<PluginsSettingsLocaleKey, string> = {
|
||||
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: '设置保存失败,请重试。',
|
||||
}
|
||||
|
||||
+239
-38
@@ -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<SubagentModelSelectionCardState>
|
||||
}
|
||||
/** 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<string>,
|
||||
): 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<string> | 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<SubagentModelSelectionCardState>
|
||||
private readonly unsubscribe: () => void
|
||||
|
||||
/** @param scope - the bound `subagent-model-selection` settings scope. */
|
||||
constructor(private readonly scope: SettingsScope<SubagentModelSelectionSettings>) {
|
||||
/**
|
||||
* @param scope - bound `subagent-model-selection` settings scope.
|
||||
* @param api - Host LLM directory face.
|
||||
*/
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<SubagentModelSelectionSettings>,
|
||||
private readonly api: Pick<IApiClient, 'llm'>,
|
||||
) {
|
||||
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<void> {
|
||||
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<string> {
|
||||
return this.draftSelected ?? new Set(this.currentRoutes().map(subagentModelKey))
|
||||
}
|
||||
|
||||
private enabled(): boolean {
|
||||
return this.draftEnabled ?? this.currentRoutes().length > 0
|
||||
}
|
||||
|
||||
private beginDraft(): Set<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,22 +84,28 @@ function renderBash(state: Partial<BashCardState> = {}) {
|
||||
|
||||
function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}) {
|
||||
const store = createSnapshotStore<SubagentModelSelectionCardState>({
|
||||
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(<SubagentModelSelectionCard {...props} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((accept, fail) => {
|
||||
resolve = accept
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('CardForm', () => {
|
||||
function form() {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
@@ -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<SubagentModelSelectionSettings>()
|
||||
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<SubagentModelSelectionSettings>()
|
||||
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<SubagentModelSelectionSettings>()
|
||||
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<SubagentModelSelectionSettings>()
|
||||
let settle = (): void => {}
|
||||
const pending = new Promise<void>((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<SubagentModelSelectionSettings>()
|
||||
const catalog = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const write = deferred<undefined>()
|
||||
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<SubagentModelSelectionSettings>()
|
||||
host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} })
|
||||
|
||||
const pending = deferred<never>()
|
||||
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<never>()
|
||||
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<SubagentModelSelectionSettings>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'step/end',
|
||||
'step/start',
|
||||
'subagent/descriptor',
|
||||
'subagent/model-selection-enabled',
|
||||
'subagent/model-selection-policy',
|
||||
'team/member',
|
||||
'team/message/delivered',
|
||||
'team/message/queued',
|
||||
|
||||
@@ -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<Record<string, string>>;\n}',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 影响
|
||||
|
||||
|
||||
@@ -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<Context['agent']>): boolean => {
|
||||
let enabled = hasSubagentModelSelection(agent.session)
|
||||
if (!enabled) {
|
||||
const selectForAgent = (agent: NonNullable<Context['agent']>): 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)
|
||||
|
||||
@@ -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<string, unknown> }).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()
|
||||
|
||||
@@ -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<string> {
|
||||
@@ -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)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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<SubagentModelSelectionSettings> = 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<Config> = 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 }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, never>
|
||||
'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 })),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<AllowedModelRoute> = 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<string>()
|
||||
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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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')!
|
||||
|
||||
@@ -240,6 +240,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
AgentFactory: 'core.md',
|
||||
AgentHandle: 'core.md',
|
||||
ModelSelection: 'core.md',
|
||||
AllowedModelRoute: 'subagent.md',
|
||||
AgentOptions: 'core.md',
|
||||
AgentStatus: 'core.md',
|
||||
ContentBlock: 'llm-streaming.md',
|
||||
|
||||
Reference in New Issue
Block a user