mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #3020 from deepseek-harness/fix/subagent-model-switch-plugins
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: bf4788b141370933197d9ec1a1ad3c8e76a6740c
|
||||
2026-08-18-model-selected-subagent-routes.zh.md: 1d83e2e91ffe87fff7f8e9d1988320cb2bb8f2f7
|
||||
2026-08-18-model-selected-subagent-routes.md: 9cdbdbec3b79a93043fa6ae95a6a6dedf6072086
|
||||
2026-08-18-model-selected-subagent-routes.zh.md: 3b81f5417339325dd60884049a853d1094a9f1eb
|
||||
|
||||
@@ -12,15 +12,15 @@ The model also needs a bounded way to discover live providers and model-owned ef
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves 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 Agent-scoped `modelSelectionSettings` instance resolves a Session policy and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. The policy uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md); there is no unrestricted static mode. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. A settings-enabled instance against a provider without the capability fails the plugin mount.
|
||||
|
||||
Provider and model form one route and must be supplied together. An effort may be supplied alone when configured, parent, or provider-owned route defaults provide the effective route. Static `provider.agentRouteDefaults`, when present, establish the provider/model baseline; `Config.agentOptions` and model arguments overlay it before route-aware effort clearing. Providers without static defaults use compatible fields from the parent Agent's latest logged request selection, with creation options supplying the fallback before its first request and retaining the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort only from the selected baseline; changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection.
|
||||
|
||||
An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service.
|
||||
|
||||
An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id.
|
||||
An enabled definition registers `list_subagent_models`. With no arguments the tool lists authorized registered providers; with `provider` it calls that adapter's advisory model catalog only after authorization; with `provider` and `model` it authorizes the exact route before resolving its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-off Host-owned `subagent-model-selection` setting with an explicit `enabled` switch and `allowedModels` list. The Plugins settings page stores both fields atomically. A new top-level Session snapshots the route policy as `subagent/model-selection-policy` when the setting is enabled, before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. Discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it.
|
||||
|
||||
Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost.
|
||||
Shipped `subagent_fork` instances do not read model-selection settings even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost.
|
||||
|
||||
The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix.
|
||||
|
||||
@@ -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 settings-enabled Session can select only its recorded exact child LLM routes; disabled Sessions omit and reject model-facing route fields.
|
||||
- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy exists; discovery and explicit selection are constrained to that policy.
|
||||
- Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse.
|
||||
- Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default.
|
||||
- Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged.
|
||||
|
||||
@@ -12,15 +12,15 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出已启用的 Session 决定,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,不要求配置路由允许列表。已注册的 LLM 提供方路由都可供子级选择;本工具不会在部署的 LLM 注册表之上增加第二套授权策略。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。
|
||||
只有 Agent 作用域的 `modelSelectionSettings` 实例解析出 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。该策略使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权;不存在无限制静态模式。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果 settings 已启用的实例缺少该提供方能力,插件挂载会失败。
|
||||
|
||||
提供方与模型共同组成一条路由,必须一起提供。如果配置值、父级值或提供方持有的路由默认值能够提供生效路由,则可以只提供推理强度。静态的 `provider.agentRouteDefaults` 在存在时构成 provider/model 基线;`Config.agentOptions` 与模型参数会在路由相关强度清除之前覆盖它。没有静态默认值的提供方会使用父 Agent 最新记录请求中的兼容字段,首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。只有所选基线的路由不变时才会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。
|
||||
|
||||
显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。
|
||||
|
||||
启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `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` 设置,其中包含默认关闭的显式 `enabled` 开关与 `allowedModels` 列表。Plugins 设置页会原子保存两个字段。设置启用时,新的顶层 Session 会在任何模型请求之前,把路由策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。
|
||||
|
||||
随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。
|
||||
随附的 `subagent_fork` 实例不会读取模型选择设置,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。
|
||||
|
||||
委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。
|
||||
|
||||
@@ -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`;其目录条目不会限制委派。
|
||||
- settings 已启用的 Session 只能选择其记录的精确子级 LLM 路由;禁用的 Session 会省略并拒绝面向模型的路由字段。
|
||||
- 主委派工具实例默认关闭选择,为新 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: 0f9816ae89562545c267d87713c13faecd9efc66
|
||||
2026-08-24-user-authorized-subagent-model-routes.zh.md: ca63215a5ec7ae56ae2f077a7fb19fc1e204527c
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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 an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process.
|
||||
|
||||
A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled.
|
||||
|
||||
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.
|
||||
|
||||
Model selection has no unrestricted static mode. The default-off Host setting is the only authority, and an enabled Session always carries an exact allowlist. The primary spawn tool reads that setting; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**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.
|
||||
|
||||
**Infer enablement from a non-empty `allowedModels` array.** Rejected because disabling would have to discard a useful selection or preserve a non-empty array whose meaning depends on write history. The explicit switch is authoritative, and the settings scope submits both fields in one Host-validated mutation so no intermediate state is persisted.
|
||||
|
||||
**Store per-route reasoning-effort allowlists.** Rejected because the user decision concerns child models, while effort ids and compatibility belong to the exact adapter route. Every adapter-supported effort remains available after the route is authorized.
|
||||
|
||||
**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.
|
||||
- The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it.
|
||||
- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged-route retention, connection-generation invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow.
|
||||
|
||||
## Related decisions
|
||||
|
||||
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).
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# 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 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。
|
||||
|
||||
设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。
|
||||
|
||||
固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。
|
||||
|
||||
模型选择不再有无限制的静态模式。默认关闭的 Host 设置是唯一授权来源,启用的 Session 始终携带精确允许列表。主 spawn 工具读取该设置;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在委派描述中渲染允许路由。** 不采用,因为很大或变化的列表会扩大每次请求,并使较早的提示词前缀失效。按需发现会保持固定 schema 的前缀稳定,且只在请求目录时记录其内容。
|
||||
|
||||
**只过滤设置 UI 或发现结果。** 不采用,因为模型可以猜测路由,或从较早的 transcript 中保留路由。授权由启动子级的执行器强制执行。
|
||||
|
||||
**从非空 `allowedModels` 数组推断是否启用。** 不采用,因为关闭功能时要么必须丢弃仍有用的选择,要么要保留一个含义取决于写入历史的非空数组。显式开关是权威依据,设置 scope 会在一次由 Host 校验的 mutation 中提交两个字段,因此不会持久化中间状态。
|
||||
|
||||
**保存每条路由的推理强度允许列表。** 不采用,因为用户决定针对子级模型,而强度 id 与兼容性属于精确适配器路由。路由获准后,仍可使用适配器支持的每种强度。
|
||||
|
||||
**每次发现或委派调用都读取当前设置。** 不采用,因为设置编辑会静默改变运行中 Session 的模型可见能力和执行权限。持久 Session 快照会让恢复与子级继承保持确定。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 新适配器注册和新公布模型不会扩大用户授权。
|
||||
- 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。
|
||||
- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。
|
||||
- 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。
|
||||
- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存路由保留、连接换代失效、暂存后的整数组写入、陈旧 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。
|
||||
|
||||
## Related decisions
|
||||
|
||||
路由参数、适配器预检、发现工具与 fork 缓存限制仍由[模型选择的 subagent 路由](2026-08-18-model-selected-subagent-routes.zh.md)负责。
|
||||
@@ -165,7 +165,7 @@ External packages **directly declared** only by repository tooling, test infrast
|
||||
| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
|
||||
| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
|
||||
| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
|
||||
| [`debug`](https://github.com/visionmedia/debug) | MIT |
|
||||
| [`debug`](https://github.com/debug-js/debug) | MIT |
|
||||
| [`esbuild`](https://github.com/evanw/esbuild) | MIT |
|
||||
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
|
||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
enableModelSelection: true
|
||||
backgroundMode: continuable
|
||||
maxDepth: 1
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
|
||||
expect(modelRequests[0]?.reasoning_effort).toBe('max')
|
||||
expect(modelRequests[0]?.max_tokens).toBe(1234)
|
||||
expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models')
|
||||
expect(tools.map(tool => tool.function?.name)).not.toContain('list_subagent_models')
|
||||
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
|
||||
const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
|
||||
|
||||
@@ -241,13 +241,19 @@ describe('the shipped Web composition', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('applies the default-off subagent model-selection preference only to new sessions', async () => {
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
|
||||
it('applies the default-off subagent model allowlist only to new sessions', async () => {
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: false,
|
||||
allowedModels: [],
|
||||
})
|
||||
const disabled = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-model-selection-disabled'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: true,
|
||||
allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }],
|
||||
})
|
||||
const enabled = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-model-selection-enabled'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- status: 已保存 minimax-cn。
|
||||
- list:
|
||||
- listitem:
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list
|
||||
- text: 提供方
|
||||
- combobox "提供方":
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
- 'button "展开设置: Agent 循环"':
|
||||
- text: Agent 循环 Agent 如何派发工具调用。
|
||||
- img
|
||||
- listitem:
|
||||
- 'button "展开设置: Subagent"':
|
||||
- text: Subagent 控制 Agent 为 Subagent 选择模型的权限。
|
||||
- img
|
||||
- listitem:
|
||||
- 'button "展开设置: 网页搜索"':
|
||||
- text: 网页搜索 DeepSeek 搜索提供方。
|
||||
|
||||
@@ -76,7 +76,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
const dialog = await openPlugins()
|
||||
|
||||
// Every card the shipped web composition exposes: the shell executor, the
|
||||
// agent loop, and the DeepSeek search provider.
|
||||
// agent loop, subagent selection, and the DeepSeek search provider.
|
||||
await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1)
|
||||
await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1)
|
||||
@@ -88,6 +90,45 @@ describe('web e2e: plugin configuration section', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
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: '允许 Agent 为 Subagent 选择模型' })
|
||||
|
||||
await toggle.click()
|
||||
const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' })
|
||||
await models.waitFor({ timeout: 10_000 })
|
||||
const firstModel = models.getByRole('checkbox').first()
|
||||
await firstModel.check()
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
|
||||
const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' })
|
||||
await expandSubagent.waitFor({ timeout: 5_000 })
|
||||
await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
expect(await settingsDocument()).toContain('enabled: true')
|
||||
expect(await settingsDocument()).toContain('allowedModels:')
|
||||
expect(await settingsDocument()).toContain('provider:')
|
||||
expect(await settingsDocument()).toContain('model:')
|
||||
await expandSubagent.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0)
|
||||
|
||||
await toggle.click()
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expandSubagent.waitFor({ timeout: 5_000 })
|
||||
await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
expect(await settingsDocument()).toContain('allowedModels:')
|
||||
expect(await settingsDocument()).toContain('provider:')
|
||||
expect(await settingsDocument()).toContain('model:')
|
||||
await expandSubagent.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('stages an edit and writes it only when saved', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write'))
|
||||
const dialog = await openPlugins()
|
||||
@@ -109,6 +150,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
|
||||
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
|
||||
await expandTerminal.waitFor({ timeout: 5_000 })
|
||||
await expandTerminal.click()
|
||||
// Presence in the user layer is what the badge reports, and the reset is
|
||||
// offered only for a field that has one.
|
||||
await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1)
|
||||
@@ -167,6 +211,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
|
||||
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 })
|
||||
.toBe(false)
|
||||
const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
|
||||
await expandTerminal.waitFor({ timeout: 5_000 })
|
||||
await expandTerminal.click()
|
||||
expect(await timeout.inputValue()).toBe('60000')
|
||||
expect(await dialog.getByText('已覆盖').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -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.zh.md: 1ab6838e4cea77a7d98a2227aca6e8ac47d84fcd
|
||||
config-catalog.md: 57b7f120ff09459e02f998806f6335f2b45d1b1b
|
||||
config-catalog.zh.md: f0911b9d004d885868758351e16e3783b660eb18
|
||||
|
||||
@@ -2863,12 +2863,9 @@ export interface Config {
|
||||
* a distinct name.
|
||||
*/
|
||||
toolName?: string
|
||||
/** Let the model discover and select the child LLM route (default false). */
|
||||
enableModelSelection?: boolean
|
||||
/**
|
||||
* Sample the Host `subagent-model-selection` user setting for each new
|
||||
* top-level session and inherit that decision in its child sessions. Mutually
|
||||
* exclusive with `enableModelSelection`.
|
||||
* top-level session and inherit that decision in its child sessions.
|
||||
*/
|
||||
modelSelectionSettings?: boolean
|
||||
/**
|
||||
@@ -2918,7 +2915,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>
|
||||
|
||||
|
||||
@@ -2865,12 +2865,9 @@ export interface Config {
|
||||
* a distinct name.
|
||||
*/
|
||||
toolName?: string
|
||||
/** Let the model discover and select the child LLM route (default false). */
|
||||
enableModelSelection?: boolean
|
||||
/**
|
||||
* Sample the Host `subagent-model-selection` user setting for each new
|
||||
* top-level session and inherit that decision in its child sessions. Mutually
|
||||
* exclusive with `enableModelSelection`.
|
||||
* top-level session and inherit that decision in its child sessions.
|
||||
*/
|
||||
modelSelectionSettings?: boolean
|
||||
/**
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: 93170bd4d76d768d1cf5f1bc5efdf94d1ce2453a
|
||||
module-graph.zh.md: ac7f7e70d2364469ce5de5bf0734457e8e4dfb3e
|
||||
module-graph.md: 28885c4bde3215075f213135ad64db8feadb4a9a
|
||||
module-graph.zh.md: 68d22f46c645dd62426f893bec4a0b5399d5ef6e
|
||||
|
||||
@@ -1343,6 +1343,7 @@ flowchart TD
|
||||
pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings
|
||||
pkg_client_ui_settings_plugin_inventory --> pkg_invariants
|
||||
pkg_client_ui_settings_plugins --> pkg_api_remotes
|
||||
pkg_client_ui_settings_plugins --> pkg_client_connection
|
||||
pkg_client_ui_settings_plugins --> pkg_client_locale
|
||||
pkg_client_ui_settings_plugins --> pkg_client_ui_renderer
|
||||
pkg_client_ui_settings_plugins --> pkg_client_ui_settings
|
||||
@@ -1900,7 +1901,7 @@ flowchart TD
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -1345,6 +1345,7 @@ flowchart TD
|
||||
pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings
|
||||
pkg_client_ui_settings_plugin_inventory --> pkg_invariants
|
||||
pkg_client_ui_settings_plugins --> pkg_api_remotes
|
||||
pkg_client_ui_settings_plugins --> pkg_client_connection
|
||||
pkg_client_ui_settings_plugins --> pkg_client_locale
|
||||
pkg_client_ui_settings_plugins --> pkg_client_ui_renderer
|
||||
pkg_client_ui_settings_plugins --> pkg_client_ui_settings
|
||||
@@ -1902,7 +1903,7 @@ flowchart TD
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -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: 9206672b89ac31e30bee176f536b3057eb37f3ee
|
||||
subagent.zh.md: 30e4e09a145a4b3d51044d85c602ce81fd44c3b3
|
||||
|
||||
@@ -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 selection preference for the next eligible Agent publication.
|
||||
* @returns the enabled state and exact allowed routes.
|
||||
*/
|
||||
currentEnabled(): boolean
|
||||
current(): SubagentModelSelectionSettings
|
||||
```
|
||||
|
||||
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 selection preference for the next eligible Agent publication.
|
||||
* @returns the enabled state and exact allowed routes.
|
||||
*/
|
||||
currentEnabled(): boolean
|
||||
current(): SubagentModelSelectionSettings
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
|
||||
tool-catalog.md: 7b166243fc3f5ef2c1bacdddaf5ee44c5b155622
|
||||
tool-catalog.zh.md: b44a0de4968dbcd760db546037f35e844608c819
|
||||
tool-catalog.md: 16142c2f7d98cf1037b034d2836c742b62f26594
|
||||
tool-catalog.zh.md: 533caacc7a923b5ea36f527292f420b610f1c488
|
||||
|
||||
+3
-15
@@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. |
|
||||
| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. |
|
||||
@@ -1561,7 +1561,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag
|
||||
|
||||
### `subagent`
|
||||
|
||||
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.
|
||||
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1575,18 +1575,6 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
|
||||
@@ -1601,7 +1589,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
|
||||
The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.
|
||||
The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.
|
||||
|
||||
<a id="deepseek-aidsh-tool-subagent-control"></a>
|
||||
|
||||
|
||||
+3
-15
@@ -37,7 +37,7 @@
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 |
|
||||
| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 |
|
||||
@@ -1567,7 +1567,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
|
||||
### `subagent`
|
||||
|
||||
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。子级 LLM 选择是可选的。省略 `provider`、`model` 与 `reasoning_effort` 会使用配置的子级默认值,并从父 Agent 继承兼容的缺失值。先用 `list_subagent_models` 检查公布的路由和强度,再一起提供 `provider` 与 `model`。改变生效路由但不指定强度时,会使用所选模型的默认强度。
|
||||
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1581,18 +1581,6 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
|
||||
@@ -1607,7 +1595,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
|
||||
来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
|
||||
注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。
|
||||
注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。
|
||||
|
||||
<a id="deepseek-aidsh-tool-subagent-control"></a>
|
||||
|
||||
|
||||
@@ -696,8 +696,7 @@
|
||||
"@deepseek-ai/dsh-llm-deepseek",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl",
|
||||
"@deepseek-ai/dsh-skill-filesystem",
|
||||
"@deepseek-ai/dsh-tool-subagent"
|
||||
"@deepseek-ai/dsh-skill-filesystem"
|
||||
]
|
||||
},
|
||||
"packages/shell/tool-pwsh": {
|
||||
|
||||
@@ -357,7 +357,6 @@
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
enableModelSelection: true
|
||||
backgroundMode: continuable
|
||||
|
||||
# Fork omits model selection so provider/model stay equal to the parent and
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
|
||||
- insert:
|
||||
# Host-owned opt-in sampled when a new Web session receives its preset
|
||||
# delegation tools. The Models page edits this settings namespace.
|
||||
# delegation tools. The Plugins page edits this settings namespace.
|
||||
- id: subagent-model-selection-settings
|
||||
name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
|
||||
README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da
|
||||
README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f
|
||||
README.md: 658b357992a1926f1f21e109c19b7c0975da58ea
|
||||
README.zh.md: dcc19f80a15e6e7e81c3dea128a999b83e8311ba
|
||||
|
||||
@@ -35,10 +35,6 @@ The primary field on an editor card is a single **API key** input — the page n
|
||||
|
||||
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits.
|
||||
|
||||
### Subagent model selection
|
||||
|
||||
When the Host advertises the `subagent-model-selection` settings namespace, Models shows a localized switch above the provider rows. The switch defaults off and writes only `{ enabled }` through `settings.update` with the namespace revision. The Host samples the value when it composes a new top-level Session, so changing it does not reconfigure running Sessions. Child Sessions inherit their parent's recorded decision.
|
||||
|
||||
### Adding and deleting providers
|
||||
|
||||
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks `llm.discoverModels` about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
|
||||
|
||||
@@ -35,10 +35,6 @@ kind: "package-reference"
|
||||
|
||||
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。
|
||||
|
||||
### 子代理模型选择
|
||||
|
||||
当宿主提供 `subagent-model-selection` 设置 namespace 时,Models 会在提供方行上方显示一个本地化开关。该开关默认关闭,并通过 `settings.update` 携带 namespace revision、只写入 `{ enabled }`。宿主在组合新的顶层 Session 时读取此值,因此更改它不会重新配置正在运行的 Session。子 Session 会继承其父级已记录的决定。
|
||||
|
||||
### 新增与删除提供方
|
||||
|
||||
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**就表单显示的端点询问 `llm.discoverModels`,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
|
||||
|
||||
@@ -40,87 +40,6 @@
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.preferenceCard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
margin-top: 4px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.preferenceCopy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preferenceTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.preferenceDescription {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.switch {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-border-l3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchOn {
|
||||
background: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.switch:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.switch:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.switchThumb {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-primary-foreground);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.switchOn .switchThumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.preferenceStatus,
|
||||
.preferenceCard > .error {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.preferenceStatus {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
/* Extra air between the title/intro block and the first provider card. */
|
||||
|
||||
@@ -23,7 +23,6 @@ import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './stor
|
||||
import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts'
|
||||
import type { SettingsSchemaOperations } from './schema-operations.ts'
|
||||
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
|
||||
import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -309,24 +308,12 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
// one whose schema names the protocols one may speak; without it mounted
|
||||
// there is nothing to declare and the entry point stays disabled.
|
||||
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema)
|
||||
const subagentModelSelection = state.namespaces.get('subagent-model-selection')
|
||||
|
||||
return (
|
||||
<div className={styles['section']}>
|
||||
<h2 className={styles['title']}>{t('title')}</h2>
|
||||
<p className={styles['intro']}>{t('intro')}</p>
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
{subagentModelSelection === undefined
|
||||
? null
|
||||
: (
|
||||
<SubagentModelSelectionCard
|
||||
namespace={subagentModelSelection}
|
||||
writable={state.writable}
|
||||
api={api}
|
||||
controller={controller}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{savedIdentity === undefined
|
||||
? null
|
||||
: (
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/** User control for model-selectable subagent delegation in new sessions. */
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ModelsSettingsStore } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import { messageOf } from './store.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Props for the Host-owned subagent model-selection preference. */
|
||||
export interface SubagentModelSelectionCardProps {
|
||||
/** Current redacted namespace view. */
|
||||
namespace: SettingsNamespaceView
|
||||
/** Whether the settings provider accepts writes. */
|
||||
writable: boolean
|
||||
/** Settings wire face. */
|
||||
api: SettingsWireFace
|
||||
/** Models page controller to refresh after a commit. */
|
||||
controller: ModelsSettingsStore
|
||||
/** Localized Models copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** Read the schema-validated resolved boolean from a namespace view. */
|
||||
function enabledOf(namespace: SettingsNamespaceView): boolean {
|
||||
if (typeof namespace.value !== 'object' || namespace.value === null) return false
|
||||
return (namespace.value as { enabled?: unknown }).enabled === true
|
||||
}
|
||||
|
||||
/** Render and persist the default-off new-session preference. */
|
||||
export function SubagentModelSelectionCard({
|
||||
namespace,
|
||||
writable,
|
||||
api,
|
||||
controller,
|
||||
t,
|
||||
}: SubagentModelSelectionCardProps): ReactNode {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const enabled = enabledOf(namespace)
|
||||
|
||||
const toggle = (): void => {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
setError(undefined)
|
||||
void api.settings.update(
|
||||
namespace.ns,
|
||||
{ enabled: !enabled },
|
||||
namespace.revision,
|
||||
).then(async (response) => {
|
||||
if (!response.ok) throw new Error(response.error.message)
|
||||
controller.acceptNamespace(response.value)
|
||||
await controller.load()
|
||||
setSaved(true)
|
||||
}).catch((reason: unknown) => {
|
||||
setError(messageOf(reason))
|
||||
}).finally(() => { setSaving(false) })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles['preferenceCard']} aria-labelledby="subagent-model-selection-title">
|
||||
<div className={styles['preferenceCopy']}>
|
||||
<h3 id="subagent-model-selection-title" className={styles['preferenceTitle']}>
|
||||
{t('subagentModelSelectionTitle')}
|
||||
</h3>
|
||||
<p className={styles['preferenceDescription']}>{t('subagentModelSelectionDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={t('subagentModelSelectionToggle')}
|
||||
className={`${styles['switch']} ${enabled ? styles['switchOn'] : ''}`}
|
||||
disabled={!writable || saving}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className={styles['switchThumb']} />
|
||||
</button>
|
||||
{saved
|
||||
? <p className={styles['preferenceStatus']} role="status">{t('subagentModelSelectionSaved')}</p>
|
||||
: null}
|
||||
{error === undefined ? null : <p className={styles['error']} role="alert">{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -5,10 +5,6 @@ export const en = {
|
||||
nav: 'Models',
|
||||
title: 'Models',
|
||||
intro: 'Enter your API keys to use models from the following providers.',
|
||||
subagentModelSelectionTitle: 'Subagent model selection',
|
||||
subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.',
|
||||
subagentModelSelectionToggle: 'Allow subagents to choose models',
|
||||
subagentModelSelectionSaved: 'Saved. New sessions use this setting.',
|
||||
edit: 'Edit',
|
||||
editProvider: 'Edit {provider}',
|
||||
remove: 'Delete',
|
||||
@@ -113,10 +109,6 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
nav: '模型',
|
||||
title: '模型',
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
subagentModelSelectionTitle: 'Subagent 自选模型',
|
||||
subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。',
|
||||
subagentModelSelectionToggle: '允许 subagent 自选模型',
|
||||
subagentModelSelectionSaved: '已保存,新会话将使用此设置。',
|
||||
edit: '编辑',
|
||||
editProvider: '编辑 {provider}',
|
||||
remove: '删除',
|
||||
|
||||
@@ -137,24 +137,15 @@ export class ModelsSettingsStore {
|
||||
private generation = 0
|
||||
|
||||
/**
|
||||
* @param api - the page's wire faces (credentials Remote, llm reads, settings writes).
|
||||
* @param api - the page's credentials Remote and LLM wire faces.
|
||||
* @param describeFace - the shared mirror's describe face (namespace views and writability).
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: ModelsWire,
|
||||
private readonly api: Pick<ModelsWire, 'credentials' | 'llm'>,
|
||||
private readonly schema: SettingsSchemaOperations,
|
||||
private readonly describeFace: SettingsDescribeFace,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fold one successful settings write into the shared mirror before rejoining
|
||||
* this page's rows.
|
||||
* @param view - namespace view returned by the settings wire method.
|
||||
*/
|
||||
acceptNamespace(view: SettingsNamespaceView): void {
|
||||
this.describeFace.acceptView(view)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the whole page snapshot: the provider directory and the mirror's
|
||||
* settings answer in parallel, then one batched credential describe over
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai
|
||||
import {
|
||||
ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile,
|
||||
} from '../src/client/ModelsSection.tsx'
|
||||
import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
||||
import {
|
||||
@@ -328,72 +327,6 @@ describe('ModelsSection', () => {
|
||||
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
||||
expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false)
|
||||
})
|
||||
|
||||
it('persists the default-off subagent model-selection switch for new sessions', async () => {
|
||||
const enabledNamespace: SettingsNamespaceView = {
|
||||
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
||||
value: { enabled: true },
|
||||
user: { enabled: true },
|
||||
revision: 5,
|
||||
}
|
||||
const update = vi.fn(() => Promise.resolve(remoteOk(enabledNamespace)))
|
||||
await mountSection({ update })
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
fireEvent.click(toggle)
|
||||
|
||||
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'subagent-model-selection',
|
||||
{ enabled: true },
|
||||
4,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved)
|
||||
})
|
||||
|
||||
it('reports rejected subagent model-selection updates and permits a retry', async () => {
|
||||
const update = vi.fn()
|
||||
.mockResolvedValueOnce(remoteFail('revision changed', 'settings-rejected'))
|
||||
.mockResolvedValueOnce(remoteOk({
|
||||
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
||||
value: { enabled: true },
|
||||
revision: 5,
|
||||
}))
|
||||
await mountSection({ update })
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
|
||||
fireEvent.click(toggle)
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('revision changed')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps malformed and read-only subagent preferences off', () => {
|
||||
const namespace = {
|
||||
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
||||
value: null,
|
||||
} as unknown as SettingsNamespaceView
|
||||
const mutate = vi.fn()
|
||||
render(
|
||||
<SubagentModelSelectionCard
|
||||
namespace={namespace}
|
||||
writable={false}
|
||||
api={{ settings: { mutate } } as never}
|
||||
controller={{ acceptNamespace: vi.fn(), load: vi.fn() } as never}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect((toggle as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(toggle)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => {
|
||||
await mountFirstRun()
|
||||
// Nothing is reachable yet, and DeepSeek has no configured credential and
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md
|
||||
README.md: 17ef6fda253ead749eba297519ad4b0647fc482e
|
||||
README.zh.md: 9f48f673c6d145e0ff1e182d5a47a710a531ba72
|
||||
README.md: 8453bd4cb8928bbd88217716f9d505cafaf46b4f
|
||||
README.zh.md: 6a9b716b843af804b1534f0fd0fa0fc8fb5686c1
|
||||
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
|
||||
Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`).
|
||||
|
||||
### What appears here
|
||||
|
||||
@@ -33,7 +33,9 @@ The tab reads which settings namespaces the Host serves and dispatches one slot
|
||||
|
||||
### Editing and saving
|
||||
|
||||
A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted — the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
|
||||
A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A successful save collapses the card after the read-back confirms the writes; a failed save keeps the card open, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted.
|
||||
|
||||
The Subagent card stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects.
|
||||
|
||||
### Secret-role fields
|
||||
|
||||
@@ -55,7 +57,7 @@ The section declares `settings.plugins.tab`, a root list slot whose labels becom
|
||||
|
||||
### The write path
|
||||
|
||||
Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches.
|
||||
Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
|
||||
打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
|
||||
|
||||
### 这里会出现什么
|
||||
|
||||
@@ -33,7 +33,9 @@ kind: "package-reference"
|
||||
|
||||
### 编辑与保存
|
||||
|
||||
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算——卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
|
||||
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。保存成功后,卡片会在回读确认写入后收起;保存失败时,卡片保持展开、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算。
|
||||
|
||||
Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。
|
||||
|
||||
### secret 角色字段
|
||||
|
||||
@@ -55,7 +57,7 @@ kind: "package-reference"
|
||||
|
||||
### 写入路径
|
||||
|
||||
保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。
|
||||
保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
@@ -47,6 +48,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
@@ -55,6 +57,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* disabled card the user cannot act on.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { CardShell } from './card-form.ts'
|
||||
@@ -46,7 +46,19 @@ export interface PluginCardProps {
|
||||
*/
|
||||
export function PluginCard(props: PluginCardProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const saveStarted = useRef(false)
|
||||
const { state } = props
|
||||
// Collapse only after Host-confirmed settlement; a rejected write keeps its
|
||||
// diagnostics and retained drafts visible for correction.
|
||||
useEffect(() => {
|
||||
if (state.saving) {
|
||||
saveStarted.current = true
|
||||
return
|
||||
}
|
||||
if (!saveStarted.current) return
|
||||
saveStarted.current = false
|
||||
if (!state.dirty && !state.failed) setOpen(false)
|
||||
}, [state.dirty, state.failed, state.saving])
|
||||
if (!state.available) return null
|
||||
const title = props.t(props.titleKey)
|
||||
const blocked = !state.dirty || state.invalid || state.saving
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
.permission {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.switch {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-border-l3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchOn {
|
||||
background: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.switch:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.switch:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-primary-foreground);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.switchOn .thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.selection {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.notice,
|
||||
.invalid,
|
||||
.conflict {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.notice {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.invalid,
|
||||
.conflict {
|
||||
color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.modelGroup {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modelGroup + .modelGroup {
|
||||
margin-top: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.providerName {
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.model {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/** User control for model-selectable subagent delegation in new sessions. */
|
||||
|
||||
import clsx from 'clsx'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
SubagentModelCandidate,
|
||||
SubagentModelSelectionCardFace,
|
||||
} from './subagent-model-selection-card-controller.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import css from './SubagentModelSelectionCard.module.css'
|
||||
|
||||
/** Props the renderer binds for the subagent model-selection card. */
|
||||
export type SubagentModelSelectionCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.plugins'>
|
||||
& InjectFace<SubagentModelSelectionCardFace>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
const availableGroups = new Map<string, {
|
||||
providerName: string
|
||||
candidates: SubagentModelCandidate[]
|
||||
}>()
|
||||
const unavailable: SubagentModelCandidate[] = []
|
||||
for (const candidate of state.candidates) {
|
||||
if (!candidate.available) {
|
||||
unavailable.push(candidate)
|
||||
continue
|
||||
}
|
||||
const group = availableGroups.get(candidate.provider)
|
||||
if (group === undefined) {
|
||||
availableGroups.set(candidate.provider, {
|
||||
providerName: candidate.providerName,
|
||||
candidates: [candidate],
|
||||
})
|
||||
} else {
|
||||
group.candidates.push(candidate)
|
||||
}
|
||||
}
|
||||
const renderCandidate = (candidate: SubagentModelCandidate) => (
|
||||
<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>
|
||||
)
|
||||
return (
|
||||
<PluginCard
|
||||
t={t}
|
||||
titleKey="subagentModelSelectionTitle"
|
||||
descriptionKey="subagentModelSelectionDescription"
|
||||
state={state}
|
||||
onSave={props.save}
|
||||
onDiscard={props.discard}
|
||||
>
|
||||
<div className={css.permission}>
|
||||
<div className={css.toggleRow}>
|
||||
<span className={css.toggleLabel}>{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>
|
||||
<p className={css.hint}>
|
||||
{t(state.enabled ? 'subagentModelSelectionChoose' : 'subagentModelSelectionOff')}
|
||||
</p>
|
||||
</div>
|
||||
{state.enabled
|
||||
? (
|
||||
<div className={css.selection}>
|
||||
{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.catalogPartial
|
||||
? <p className={css.notice}>{t('subagentModelSelectionPartial')}</p>
|
||||
: null}
|
||||
{state.candidates.length > 0
|
||||
? (
|
||||
<fieldset className={css.models}>
|
||||
<legend>{t('subagentModelSelectionAllowed')}</legend>
|
||||
{[...availableGroups].map(([provider, group]) => (
|
||||
<div key={provider} className={css.modelGroup}>
|
||||
<div className={css.providerName}>{group.providerName}</div>
|
||||
{group.candidates.map(renderCandidate)}
|
||||
</div>
|
||||
))}
|
||||
{unavailable.length > 0
|
||||
? (
|
||||
<div className={css.modelGroup}>
|
||||
<div className={css.providerName}>{t('subagentModelSelectionUnavailableGroup')}</div>
|
||||
{unavailable.map(renderCandidate)}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</fieldset>
|
||||
)
|
||||
: state.catalogStatus === 'ready'
|
||||
? <p className={css.notice}>{t('subagentModelSelectionEmpty')}</p>
|
||||
: null}
|
||||
{state.invalid ? <p className={css.invalid}>{t('subagentModelSelectionRequired')}</p> : null}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{state.conflicted
|
||||
? <p className={css.conflict} role="status">{t('subagentModelSelectionConflict')}</p>
|
||||
: null}
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,12 @@
|
||||
*
|
||||
* The section declares `settings.plugins.tab`; its own `configurable` tab then
|
||||
* declares `settings.plugin.item` and renders whatever cards were registered
|
||||
* into it. The three cards this package ships are the host-plane sections the
|
||||
* into it. The cards this package ships are the host-plane sections the
|
||||
* deployment already exposes; each binds its namespace through the client
|
||||
* settings scope, which keeps them unaware of one another and of other tabs.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry)
|
||||
@@ -25,10 +26,14 @@ import { BashCard } from './BashCard.tsx'
|
||||
import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
|
||||
import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
|
||||
import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
|
||||
import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
|
||||
import { WebSearchCard } from './WebSearchCard.tsx'
|
||||
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts'
|
||||
import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
|
||||
import { ConfigurablePluginsTabController } from './tab-store.ts'
|
||||
import {
|
||||
SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController,
|
||||
} from './subagent-model-selection-card-controller.ts'
|
||||
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
@@ -49,13 +54,14 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co
|
||||
const NS = 'settings.plugins'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Mount the plugin configuration section and the cards this package ships.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const t = ctx.locale.bind(NS)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries')
|
||||
|
||||
@@ -63,6 +69,10 @@ export function apply(ctx: ClientContext): void {
|
||||
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
|
||||
const webSearch = new WebSearchCardController(
|
||||
ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials)
|
||||
const subagentModelSelection = new SubagentModelSelectionCardController(
|
||||
ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }),
|
||||
api,
|
||||
)
|
||||
|
||||
// The credential a card reports is not part of any settings section, so its
|
||||
// scope publishes nothing when one is written. This is the only signal that
|
||||
@@ -71,6 +81,19 @@ export function apply(ctx: ClientContext): void {
|
||||
() => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }),
|
||||
'ui-settings-plugins: credential invalidations',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('llm/adapters-updated', () => { subagentModelSelection.refreshCatalog() }),
|
||||
'ui-settings-plugins: subagent adapter invalidations',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('settings/document-updated', () => { subagentModelSelection.refreshCatalog() }),
|
||||
'ui-settings-plugins: subagent settings invalidations',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.on('connection/reset', () => { subagentModelSelection.resetConnection() }),
|
||||
'ui-settings-plugins: subagent connection generation',
|
||||
)
|
||||
ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference')
|
||||
|
||||
// The shared SettingsScope mirror updates after document commits and reconnects.
|
||||
const configurable = new ConfigurablePluginsTabController(
|
||||
@@ -130,7 +153,7 @@ export function apply(ctx: ClientContext): void {
|
||||
}, PluginsSettingsSection))
|
||||
|
||||
// The existing configuration page is one ordinary tab. It keeps ownership
|
||||
// of the card slot and the three shipped card contributions below.
|
||||
// of the card slot and the shipped card contributions below.
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'configurable',
|
||||
@@ -154,6 +177,12 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
inject: () => agentLoop.inject(),
|
||||
}, AgentLoopCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
key: SUBAGENT_MODEL_SELECTION_NS,
|
||||
locale: NS,
|
||||
inject: () => subagentModelSelection.inject(),
|
||||
}, SubagentModelSelectionCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
key: WEB_SEARCH_NS,
|
||||
|
||||
@@ -11,6 +11,12 @@ export type PluginsSettingsLocaleKey =
|
||||
| 'webSearchTitle' | 'webSearchDescription'
|
||||
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
|
||||
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
|
||||
| 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription'
|
||||
| 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed'
|
||||
| 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry'
|
||||
| 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable'
|
||||
| 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty'
|
||||
| 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<PluginsSettingsLocaleKey, string> = {
|
||||
@@ -51,6 +57,21 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
|
||||
webSearchBaseUrlHint: 'Leave blank to use the provider default.',
|
||||
webSearchMaxUses: 'Max searches per request',
|
||||
webSearchMaxUsesHint: 'How many times one request may search before it must answer.',
|
||||
subagentModelSelectionTitle: 'Subagent',
|
||||
subagentModelSelectionDescription: 'Control which models agents may choose for subagents.',
|
||||
subagentModelSelectionToggle: 'Allow agents to choose models for subagents',
|
||||
subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each subagent from the authorized models below. Applies only to new sessions.',
|
||||
subagentModelSelectionAllowed: 'Models agents may choose',
|
||||
subagentModelSelectionLoading: 'Loading models…',
|
||||
subagentModelSelectionLoadFailed: 'Models could not be loaded.',
|
||||
subagentModelSelectionRetry: 'Retry',
|
||||
subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.',
|
||||
subagentModelSelectionUnavailable: 'Currently unavailable',
|
||||
subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable',
|
||||
subagentModelSelectionEmpty: 'No model provider currently advertises a model.',
|
||||
subagentModelSelectionRequired: 'Select at least one model before saving.',
|
||||
subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.',
|
||||
subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
@@ -92,4 +113,19 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
|
||||
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
|
||||
webSearchMaxUses: '单次请求最多搜索次数',
|
||||
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
|
||||
subagentModelSelectionTitle: 'Subagent',
|
||||
subagentModelSelectionDescription: '控制 Agent 为 Subagent 选择模型的权限。',
|
||||
subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型',
|
||||
subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。',
|
||||
subagentModelSelectionAllowed: 'Agent 可选择的模型',
|
||||
subagentModelSelectionLoading: '正在加载模型…',
|
||||
subagentModelSelectionLoadFailed: '无法加载模型。',
|
||||
subagentModelSelectionRetry: '重试',
|
||||
subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。',
|
||||
subagentModelSelectionUnavailable: '当前不可用',
|
||||
subagentModelSelectionUnavailableGroup: '已保存但当前不可用',
|
||||
subagentModelSelectionEmpty: '当前没有模型提供方公布模型。',
|
||||
subagentModelSelectionRequired: '保存前请至少选择一个模型。',
|
||||
subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。',
|
||||
subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。',
|
||||
}
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
/** Staged editor for the Host-owned subagent model allowlist. */
|
||||
|
||||
import type {
|
||||
IApiClient,
|
||||
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'
|
||||
|
||||
/** One exact provider/model route stored as user authorization. */
|
||||
export interface AllowedSubagentModel {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Settings fields stored for subagent model selection. */
|
||||
export interface SubagentModelSelectionSettings {
|
||||
/** Whether model-facing child route selection applies to new Sessions. */
|
||||
enabled: boolean
|
||||
/** Exact child routes offered to newly composed top-level Sessions. */
|
||||
allowedModels: AllowedSubagentModel[]
|
||||
}
|
||||
|
||||
/** 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 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
|
||||
/** Live catalog joined with stored routes. */
|
||||
candidates: readonly SubagentModelCandidate[]
|
||||
/** Adapter-directory request state. */
|
||||
catalogStatus: 'idle' | 'loading' | 'ready' | 'error'
|
||||
/** Whether any provider-local catalog request failed. */
|
||||
catalogPartial: boolean
|
||||
/** Whether a newer Host revision invalidated the current draft. */
|
||||
conflicted: boolean
|
||||
}
|
||||
|
||||
/** Registration-side face for the subagent model-selection card. */
|
||||
export interface SubagentModelSelectionCardFace {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useSubagentModelSelectionCard. */
|
||||
subagentModelSelectionCard: SnapshotStore<SubagentModelSelectionCardState>
|
||||
}
|
||||
/** 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 switch and exact routes as one revision-fenced mutation. */
|
||||
save: () => void
|
||||
/** Drop the staged enabled state and route choices. */
|
||||
discard: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 catalogPartial = false
|
||||
private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle'
|
||||
private draftEnabled: boolean | undefined
|
||||
private draftRoutes: Map<string, AllowedSubagentModel> | undefined
|
||||
private draftRevision: number | undefined
|
||||
private saving = false
|
||||
private failed = false
|
||||
private conflicted = false
|
||||
private disposed = false
|
||||
private saveGeneration = 0
|
||||
private catalogGeneration = 0
|
||||
private readonly store: SnapshotStore<SubagentModelSelectionCardState>
|
||||
private readonly unsubscribe: () => void
|
||||
|
||||
/**
|
||||
* @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(() => {
|
||||
if (!this.saving && this.draftRoutes !== undefined
|
||||
&& this.scope.getSnapshot().revision !== this.draftRevision) {
|
||||
if (this.currentEnabled() === this.enabled()
|
||||
&& sameRoutes(this.currentRoutes(), this.desiredRoutes())) this.clearDraft()
|
||||
else this.conflicted = true
|
||||
}
|
||||
if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog()
|
||||
this.publish()
|
||||
})
|
||||
if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog()
|
||||
}
|
||||
|
||||
/** Stop observing settings and suppress late directory/write settlements. */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.saveGeneration += 1
|
||||
this.catalogGeneration += 1
|
||||
this.unsubscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
toggleEnabled: () => { this.toggleEnabled() },
|
||||
toggleModel: (key) => { this.toggleModel(key) },
|
||||
retryCatalog: () => { void this.loadCatalog() },
|
||||
save: () => { void this.save() },
|
||||
discard: () => { this.discard() },
|
||||
}
|
||||
}
|
||||
|
||||
private currentRoutes(): AllowedSubagentModel[] {
|
||||
return this.scope.getSnapshot().value?.allowedModels.map(route => ({ ...route })) ?? []
|
||||
}
|
||||
|
||||
private currentEnabled(): boolean {
|
||||
return this.scope.getSnapshot().value?.enabled ?? false
|
||||
}
|
||||
|
||||
private selected(): Set<string> {
|
||||
return new Set(this.draftRoutes?.keys() ?? this.currentRoutes().map(subagentModelKey))
|
||||
}
|
||||
|
||||
private enabled(): boolean {
|
||||
return this.draftEnabled ?? this.currentEnabled()
|
||||
}
|
||||
|
||||
private beginDraft(): Map<string, AllowedSubagentModel> {
|
||||
if (this.draftRoutes === undefined) {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
this.draftEnabled = snapshot.value?.enabled ?? false
|
||||
this.draftRoutes = new Map(
|
||||
snapshot.value?.allowedModels.map(route => [subagentModelKey(route), { ...route }]) ?? [],
|
||||
)
|
||||
this.draftRevision = snapshot.revision
|
||||
}
|
||||
return this.draftRoutes
|
||||
}
|
||||
|
||||
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.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
|
||||
const candidate = this.candidates().find(candidate => candidate.key === key)
|
||||
if (candidate === undefined) return
|
||||
const routes = this.beginDraft()
|
||||
if (routes.has(key)) routes.delete(key)
|
||||
else routes.set(key, { provider: candidate.provider, model: candidate.model })
|
||||
this.failed = false
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private clearDraft(): void {
|
||||
this.draftEnabled = undefined
|
||||
this.draftRoutes = undefined
|
||||
this.draftRevision = undefined
|
||||
this.failed = false
|
||||
this.conflicted = false
|
||||
}
|
||||
|
||||
private discard(): void {
|
||||
if (this.saving) return
|
||||
this.clearDraft()
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private candidates(): SubagentModelCandidate[] {
|
||||
const retained = new Map(this.currentRoutes().map(route => [subagentModelKey(route), route]))
|
||||
for (const [key, route] of this.draftRoutes ?? []) retained.set(key, route)
|
||||
return subagentModelCandidates(this.catalogGroups, [...retained.values()], this.selected())
|
||||
}
|
||||
|
||||
private desiredRoutes(): AllowedSubagentModel[] {
|
||||
return [...this.draftRoutes?.values() ?? this.currentRoutes()].map(route => ({ ...route }))
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
const desiredEnabled = this.enabled()
|
||||
const desired = this.desiredRoutes()
|
||||
if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving
|
||||
|| (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired))
|
||||
|| (desiredEnabled && desired.length === 0)) return
|
||||
if (this.draftRoutes !== undefined && snapshot.revision !== this.draftRevision) {
|
||||
this.conflicted = true
|
||||
this.publish()
|
||||
return
|
||||
}
|
||||
const generation = this.saveGeneration
|
||||
this.saving = true
|
||||
this.failed = false
|
||||
this.conflicted = false
|
||||
this.publish()
|
||||
await this.scope.mutate([
|
||||
{ op: 'set', path: ['enabled'], value: desiredEnabled },
|
||||
{
|
||||
op: 'set',
|
||||
path: ['allowedModels'],
|
||||
value: desired.map(route => ({ provider: route.provider, model: route.model })),
|
||||
},
|
||||
], this.draftRevision)
|
||||
if (generation !== this.saveGeneration) return
|
||||
const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)
|
||||
this.saving = false
|
||||
this.failed = !landed
|
||||
if (landed) this.clearDraft()
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Invalidate and reload model candidates after a Host model input changes. */
|
||||
refreshCatalog(): void {
|
||||
if (this.disposed) return
|
||||
this.catalogGeneration += 1
|
||||
this.catalogStatus = 'idle'
|
||||
this.catalogPartial = false
|
||||
if (this.enabled()) void this.loadCatalog()
|
||||
else this.publish()
|
||||
}
|
||||
|
||||
/** Drop Host-specific candidates and drafts, then reload after reconnecting. */
|
||||
resetConnection(): void {
|
||||
if (this.disposed) return
|
||||
this.saveGeneration += 1
|
||||
this.saving = false
|
||||
this.clearDraft()
|
||||
this.catalogGroups = []
|
||||
this.refreshCatalog()
|
||||
}
|
||||
|
||||
private async loadCatalog(): Promise<void> {
|
||||
if (this.disposed || this.catalogStatus === 'loading') return
|
||||
const generation = this.catalogGeneration
|
||||
this.catalogStatus = 'loading'
|
||||
this.catalogPartial = false
|
||||
this.publish()
|
||||
try {
|
||||
const response = await this.api.llm.models({})
|
||||
if (generation !== this.catalogGeneration) return
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
this.catalogGroups = response.result.value.groups
|
||||
this.catalogPartial = response.result.value.failures.length > 0
|
||||
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,
|
||||
dirty: this.currentEnabled() !== enabled || !sameRoutes(current, desired),
|
||||
invalid: enabled && desired.length === 0,
|
||||
saving: this.saving,
|
||||
failed: this.failed,
|
||||
enabled,
|
||||
candidates: this.candidates(),
|
||||
catalogStatus: this.catalogStatus,
|
||||
catalogPartial: this.catalogPartial,
|
||||
conflicted: this.conflicted,
|
||||
}
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/clien
|
||||
import type {
|
||||
ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts'
|
||||
|
||||
// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
|
||||
// so browser-language detection never runs and a fresh LocaleRuntime opens on
|
||||
@@ -27,6 +28,9 @@ async function bench(served?: string[]) {
|
||||
locale.setLocale('zh')
|
||||
ctx.provide('locale', locale)
|
||||
const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } }))
|
||||
const models = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'm', result: { ok: true, value: { groups: [], failures: [] } },
|
||||
}))
|
||||
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
|
||||
? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } }
|
||||
: {
|
||||
@@ -43,9 +47,14 @@ async function bench(served?: string[]) {
|
||||
credentials: { describe: describeCredentials, set: vi.fn() },
|
||||
settings: { describe: describeSettings },
|
||||
})
|
||||
ctx.provide('connection', { isLoopback: true, api: {} } as never)
|
||||
ctx.provide('connection', {
|
||||
isLoopback: true,
|
||||
api: { llm: { models } },
|
||||
} as never)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote }
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote,
|
||||
}
|
||||
}
|
||||
|
||||
function declareRoot(slots: SlotRegistry): () => void {
|
||||
@@ -57,7 +66,7 @@ function declareRoot(slots: SlotRegistry): () => void {
|
||||
|
||||
describe('ui-settings-plugins apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers one Plugins section and declares the tab and card slots', async () => {
|
||||
@@ -117,7 +126,7 @@ describe('ui-settings-plugins apply', () => {
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
|
||||
.toEqual(['shell', 'agent-loop', 'web-search-deepseek'])
|
||||
.toEqual(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek'])
|
||||
})
|
||||
|
||||
it('dispatches the served namespaces its cards claim, and no others', async () => {
|
||||
@@ -176,6 +185,23 @@ describe('ui-settings-plugins apply', () => {
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('refreshes the subagent catalog after model inputs change or the connection resets', async () => {
|
||||
const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog')
|
||||
const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection')
|
||||
const { ctx, slots, remote } = await bench(['subagent-model-selection'])
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
refresh.mockClear()
|
||||
reset.mockClear()
|
||||
|
||||
remote.emit('llm/adapters-updated', [])
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
remote.emit('settings/document-updated', ['llm-deepseek', 1])
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
ctx.emit('connection/reset')
|
||||
expect(reset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a credential change for a reference no card watches', async () => {
|
||||
const { ctx, slots, describeCredentials, remote } = await bench()
|
||||
declareRoot(slots)
|
||||
@@ -203,7 +229,7 @@ describe('ui-settings-plugins apply', () => {
|
||||
declareRoot(slots)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('settings.plugin.item')).toHaveLength(3)
|
||||
expect(slots.entries('settings.plugin.item')).toHaveLength(4)
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
@@ -12,6 +12,8 @@ import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx
|
||||
import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
|
||||
import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
|
||||
import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
|
||||
import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
|
||||
import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx'
|
||||
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
|
||||
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
|
||||
import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
|
||||
@@ -19,6 +21,7 @@ import type { BashCardState } from '../src/client/bash-card-controller.ts'
|
||||
import type { CardFieldState, CardShell } from '../src/client/card-form.ts'
|
||||
import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts'
|
||||
import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts'
|
||||
import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -66,7 +69,7 @@ function renderConfigurable(namespaces: string[], cards: Record<string, string>
|
||||
render(<ConfigurablePluginsTab {...props} />)
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
function renderBashCard(state: Partial<BashCardState> = {}) {
|
||||
const store = createSnapshotStore<BashCardState>({
|
||||
...settled,
|
||||
timeoutMs: field('60000'),
|
||||
@@ -76,6 +79,36 @@ function renderBash(state: Partial<BashCardState> = {}) {
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
|
||||
render(<BashCard {...props} />)
|
||||
return { actions, store }
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
return renderBashCard(state).actions
|
||||
}
|
||||
|
||||
function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}) {
|
||||
const store = createSnapshotStore<SubagentModelSelectionCardState>({
|
||||
...settled,
|
||||
enabled: false,
|
||||
candidates: [],
|
||||
catalogStatus: 'idle',
|
||||
catalogPartial: false,
|
||||
conflicted: false,
|
||||
...state,
|
||||
})
|
||||
const actions = {
|
||||
toggleEnabled: vi.fn(),
|
||||
toggleModel: vi.fn(),
|
||||
retryCatalog: vi.fn(),
|
||||
save: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useSubagentModelSelectionCard: bindSnapshotSelector(store),
|
||||
} as unknown as SubagentModelSelectionCardProps
|
||||
render(<SubagentModelSelectionCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
@@ -292,6 +325,137 @@ describe('BashCard', () => {
|
||||
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses after a successful save settles', () => {
|
||||
const { actions, store } = renderBashCard({ dirty: true })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
||||
expect(actions.save).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => { store.set({ ...store.getSnapshot(), saving: true }) })
|
||||
act(() => { store.set({ ...store.getSnapshot(), dirty: false, saving: false }) })
|
||||
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a failed save open', () => {
|
||||
const { store } = renderBashCard({ dirty: true })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
||||
|
||||
act(() => { store.set({ ...store.getSnapshot(), saving: true }) })
|
||||
act(() => { store.set({ ...store.getSnapshot(), failed: true, saving: false }) })
|
||||
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
|
||||
expect(screen.getByText(en.saveFailed)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubagentModelSelectionCard', () => {
|
||||
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(actions.toggleEnabled).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('groups available adapter candidates by provider', () => {
|
||||
const actions = renderSubagentModelSelection({
|
||||
enabled: true,
|
||||
candidates: [
|
||||
{
|
||||
key: 'alpha\0fast',
|
||||
provider: 'alpha',
|
||||
model: 'fast',
|
||||
providerName: 'Alpha API',
|
||||
modelName: 'Fast',
|
||||
available: true,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
key: 'alpha\0deep',
|
||||
provider: 'alpha',
|
||||
model: 'deep',
|
||||
providerName: 'Alpha API',
|
||||
modelName: 'Deep',
|
||||
available: true,
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
catalogStatus: 'ready',
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
|
||||
expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true')
|
||||
expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ }))
|
||||
expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast')
|
||||
expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep')
|
||||
})
|
||||
|
||||
it('renders directory progress, failures, unavailable routes, and validation', () => {
|
||||
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()
|
||||
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',
|
||||
catalogPartial: true,
|
||||
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()
|
||||
expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy()
|
||||
|
||||
cleanup()
|
||||
renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' })
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('distinguishes a stale draft from a rejected save', () => {
|
||||
renderSubagentModelSelection({ dirty: true, conflicted: true })
|
||||
fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
|
||||
|
||||
expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy()
|
||||
expect(screen.queryByText(en.saveFailed)).toBeNull()
|
||||
})
|
||||
|
||||
it('stays hidden when unavailable and disables writes when read-only', () => {
|
||||
renderSubagentModelSelection({ available: false })
|
||||
expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull()
|
||||
|
||||
cleanup()
|
||||
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(actions.toggleEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCard', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { CardForm, numberField, textField } from '../src/client/card-form.ts'
|
||||
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
SettingsDescribeMirror, type SettingsMirrorSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
|
||||
import {
|
||||
SubagentModelSelectionCardController,
|
||||
subagentModelCandidates,
|
||||
type SubagentModelSelectionSettings,
|
||||
} from '../src/client/subagent-model-selection-card-controller.ts'
|
||||
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
|
||||
|
||||
/** Make the stub behave like a Host that accepts every write. */
|
||||
@@ -21,6 +27,18 @@ function acceptWrites<T>(host: StubSettingsScope<T>): void {
|
||||
host.set.mockImplementation((field: string, value: unknown) => {
|
||||
host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
|
||||
})
|
||||
host.mutate.mockImplementation((ops: readonly SettingsPathOpView[]) => {
|
||||
const value = { ...section() }
|
||||
const user = { ...layer() }
|
||||
for (const op of ops) {
|
||||
const field = op.path[0]!
|
||||
if (op.op === 'set') {
|
||||
value[field] = op.value
|
||||
user[field] = op.value
|
||||
}
|
||||
}
|
||||
host.publish({ value: value as T, user })
|
||||
})
|
||||
host.unset.mockImplementation((field: string) => {
|
||||
const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
|
||||
const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
|
||||
@@ -37,6 +55,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>>()
|
||||
@@ -383,6 +429,431 @@ describe('AgentLoopCardController', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubagentModelSelectionCardController', () => {
|
||||
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 the switch and routes atomically', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
acceptWrites(host)
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 3,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
|
||||
})
|
||||
face.toggleModel('alpha\0fast')
|
||||
face.save()
|
||||
await vi.waitFor(() => {
|
||||
expect(host.mutate).toHaveBeenCalledWith([
|
||||
{ op: 'set', path: ['enabled'], value: true },
|
||||
{ op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
|
||||
], 3)
|
||||
})
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
failed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts an empty draft when a ready test scope has no decoded value', () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api)
|
||||
host.publish({ status: 'ready', writable: true, revision: 0, value: undefined })
|
||||
const face = controller.inject()
|
||||
|
||||
face.toggleEnabled()
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
enabled: true, dirty: true, invalid: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the Host value and reports a rejected write', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
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: { enabled: false, allowedModels: [] }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
|
||||
})
|
||||
face.toggleModel('alpha\0fast')
|
||||
face.save()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true)
|
||||
})
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
dirty: true,
|
||||
saving: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads stored routes, stages removal and disablement, and discards both', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
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, revision: 5,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
|
||||
await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
|
||||
expect(state().catalogPartial).toBe(true)
|
||||
|
||||
face.toggleModel('missing')
|
||||
expect(state().dirty).toBe(false)
|
||||
face.toggleModel('alpha\0fast')
|
||||
expect(state()).toMatchObject({ dirty: true, invalid: true })
|
||||
face.discard()
|
||||
expect(state()).toMatchObject({ dirty: false, invalid: false, enabled: true })
|
||||
|
||||
face.toggleEnabled()
|
||||
expect(state()).toMatchObject({ dirty: true, enabled: false })
|
||||
face.toggleEnabled()
|
||||
expect(state()).toMatchObject({ dirty: false, enabled: true })
|
||||
})
|
||||
|
||||
it('retains selected routes when disabling and loads an already-ready enabled card', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
acceptWrites(host)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 5,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
|
||||
})
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
const face = controller.inject()
|
||||
await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() })
|
||||
|
||||
face.toggleEnabled()
|
||||
face.save()
|
||||
await vi.waitFor(() => {
|
||||
expect(host.mutate).toHaveBeenCalledWith([
|
||||
{ op: 'set', path: ['enabled'], value: false },
|
||||
{ op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
|
||||
], 5)
|
||||
})
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
enabled: false, dirty: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a directory error and retries it', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({ error: 'offline' })
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({ status: 'ready', writable: true, value: { enabled: false, 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('rejects a draft after the Host revision changes', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 4,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
|
||||
})
|
||||
face.toggleModel('alpha\0fast')
|
||||
|
||||
host.publish({
|
||||
revision: 5,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
|
||||
})
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
conflicted: true, failed: false, dirty: true,
|
||||
})
|
||||
face.save()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.mutate).not.toHaveBeenCalled()
|
||||
face.discard()
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
conflicted: false, failed: false, dirty: false, enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a draft when a newer Host revision already contains it', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 4,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
|
||||
face.toggleModel('alpha\0fast')
|
||||
|
||||
host.publish({
|
||||
revision: 5,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] },
|
||||
})
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
conflicted: false, dirty: false, enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('retains unsaved routes across a catalog refresh', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
acceptWrites(host)
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 2,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const refreshed = deferred<never>()
|
||||
const models = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'catalog-1',
|
||||
result: { ok: true, value: {
|
||||
groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
failures: [],
|
||||
} },
|
||||
})
|
||||
.mockImplementationOnce(() => refreshed.promise)
|
||||
const controller = new SubagentModelSelectionCardController(
|
||||
host.scope, { llm: { models } } as never,
|
||||
)
|
||||
const face = controller.inject()
|
||||
const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => { expect(state().candidates).toHaveLength(1) })
|
||||
face.toggleModel('alpha\0fast')
|
||||
|
||||
controller.refreshCatalog()
|
||||
expect(state()).toMatchObject({
|
||||
catalogStatus: 'loading',
|
||||
candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })],
|
||||
})
|
||||
refreshed.resolve({
|
||||
rpcId: 'catalog-2',
|
||||
result: { ok: true, value: { groups: [], failures: [] } },
|
||||
} as never)
|
||||
await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
|
||||
expect(state().candidates).toEqual([
|
||||
expect.objectContaining({ key: 'alpha\0fast', available: false, selected: true }),
|
||||
])
|
||||
|
||||
face.save()
|
||||
await vi.waitFor(() => {
|
||||
expect(host.mutate).toHaveBeenCalledWith([
|
||||
{ op: 'set', path: ['enabled'], value: true },
|
||||
{ op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
|
||||
], 2)
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a draft when the connection generation changes', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const models = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 4,
|
||||
value: { enabled: false, allowedModels: [] }, user: {},
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(host.scope, models.api)
|
||||
const face = controller.inject()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
|
||||
face.toggleModel('alpha\0fast')
|
||||
|
||||
controller.resetConnection()
|
||||
host.publish({
|
||||
revision: 4,
|
||||
value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
|
||||
})
|
||||
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
|
||||
conflicted: false, dirty: false, enabled: true,
|
||||
})
|
||||
face.save()
|
||||
await Promise.resolve()
|
||||
expect(host.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reloads the model catalog after invalidation', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
host.publish({
|
||||
status: 'ready', writable: true, revision: 1,
|
||||
value: { enabled: true, allowedModels: [] }, user: {},
|
||||
})
|
||||
const models = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'catalog-1',
|
||||
result: { ok: true, value: {
|
||||
groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
failures: [],
|
||||
} },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'catalog-2',
|
||||
result: { ok: true, value: {
|
||||
groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }],
|
||||
failures: [],
|
||||
} },
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController(
|
||||
host.scope, { llm: { models } } as never,
|
||||
)
|
||||
const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot()
|
||||
await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') })
|
||||
|
||||
controller.refreshCatalog()
|
||||
|
||||
await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') })
|
||||
expect(models).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('suppresses duplicate actions and late save settlements', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
const catalog = modelsApi({
|
||||
groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
|
||||
})
|
||||
const write = deferred<undefined>()
|
||||
const mutate = vi.fn(async (ops: readonly SettingsPathOpView[]) => {
|
||||
await write.promise
|
||||
const enabled = ops.find(op => op.path[0] === 'enabled')
|
||||
const allowedModels = ops.find(op => op.path[0] === 'allowedModels')
|
||||
host.publish({ value: {
|
||||
enabled: enabled?.op === 'set' ? enabled.value as boolean : false,
|
||||
allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [],
|
||||
} })
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.api)
|
||||
const face = controller.inject()
|
||||
|
||||
face.save()
|
||||
face.toggleModel('alpha\0fast')
|
||||
host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
|
||||
face.save()
|
||||
face.toggleEnabled()
|
||||
await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') })
|
||||
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(mutate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('suppresses duplicate directory loads and late resolve or reject settlements', async () => {
|
||||
const host = stubSettingsScope<SubagentModelSelectionSettings>()
|
||||
host.publish({ status: 'ready', writable: true, value: { enabled: false, 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: { enabled: false, allowedModels: [] }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.toggleEnabled()
|
||||
face.toggleModel('alpha\0fast')
|
||||
face.save()
|
||||
expect(host.mutate).not.toHaveBeenCalled()
|
||||
|
||||
controller.dispose()
|
||||
controller.refreshCatalog()
|
||||
controller.resetConnection()
|
||||
face.toggleEnabled()
|
||||
face.retryCatalog()
|
||||
face.save()
|
||||
host.publish({ value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] } })
|
||||
expect(host.mutate).not.toHaveBeenCalled()
|
||||
expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCardController', () => {
|
||||
it('reads the credential state for the reference the tab names', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
|
||||
README.md: a9a45d90eaa46502829ee6d2c1793b7dadb1f0a5
|
||||
README.zh.md: bd615cbb5a3236370b7bbc9fc735deeab6efd8ce
|
||||
README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5
|
||||
README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5
|
||||
|
||||
@@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re-
|
||||
|
||||
### Binding a namespace
|
||||
|
||||
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: one field path fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten.
|
||||
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
|
||||
|
||||
### Filling the settings slots
|
||||
|
||||
@@ -55,7 +55,7 @@ The plugin injects `connection` and `remote` and owns the one `settings.describe
|
||||
|
||||
### Scope derivation
|
||||
|
||||
`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope with the namespace revision as `expectedRevision`; a committed write folds its answer in, a rejected or failed latest write triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it.
|
||||
`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it.
|
||||
|
||||
### Schema service
|
||||
|
||||
@@ -95,7 +95,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
These limits define where the settings transport cannot reach; they are current package constraints.
|
||||
|
||||
- **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API.
|
||||
- **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 绑定命名空间
|
||||
|
||||
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:单一字段路径以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。
|
||||
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
|
||||
|
||||
### 填充设置 slot
|
||||
|
||||
@@ -55,7 +55,7 @@ kind: "package-reference"
|
||||
|
||||
### Scope 派生
|
||||
|
||||
`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope,以命名空间 revision 作为 `expectedRevision` 围栏;提交成功的写入把应答折回镜像,被拒绝或失败的最新写入触发一次恢复读取,被取代的写入把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。
|
||||
`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。
|
||||
|
||||
### Schema 服务
|
||||
|
||||
@@ -95,7 +95,6 @@ kind: "package-reference"
|
||||
这些限制说明设置传输层够不到的地方;它们是当前包约束。
|
||||
|
||||
- **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行仍在那里无效。
|
||||
- **每次写入仅一个字段**:`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Settings-namespace scope contracts owned beside the settings transport.
|
||||
*/
|
||||
|
||||
import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
/** Client-side sync state of one settings namespace. */
|
||||
export interface SettingsScopeSnapshot<T> {
|
||||
/**
|
||||
@@ -46,7 +48,8 @@ export interface SettingsScopeSpec<T> {
|
||||
/**
|
||||
* Reactive owner handle over one namespace's durable section — the browser
|
||||
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
|
||||
* and observe the snapshot and route explicit user choices through `set`.
|
||||
* and observe the snapshot and route explicit user choices through its
|
||||
* mutation methods.
|
||||
*/
|
||||
export interface SettingsScope<T> {
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
@@ -57,6 +60,16 @@ export interface SettingsScope<T> {
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void
|
||||
/**
|
||||
* Queue one atomic namespace mutation. All operations share one revision
|
||||
* fence, Host validation, persistence decision, and recovery read. Supplying
|
||||
* `expectedRevision` preserves an earlier read as the fence instead of using
|
||||
* the latest queued or mirrored revision.
|
||||
* @param ops - ordered field operations copied when queued.
|
||||
* @param expectedRevision - optional fixed revision read by the domain editor.
|
||||
* @returns settlement after the mutation and any latest-write recovery read.
|
||||
*/
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<void>
|
||||
/**
|
||||
* Queue one field write. Rapid writes preserve mutation order, each carries
|
||||
* the latest known namespace revision, and only the latest settlement may
|
||||
|
||||
@@ -104,7 +104,7 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void> {
|
||||
return this.write({ op: 'set', path: [field], value: value as JsonValue })
|
||||
return this.mutate([{ op: 'set', path: [field], value: value as JsonValue }])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,16 +114,23 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
* @returns settlement after the clear and any latest-write recovery read.
|
||||
*/
|
||||
unset(field: string): Promise<void> {
|
||||
return this.write({ op: 'unset', path: [field] })
|
||||
return this.mutate([{ op: 'unset', path: [field] }])
|
||||
}
|
||||
|
||||
private write(op: SettingsPathOpView): Promise<void> {
|
||||
/**
|
||||
* Queue one atomic namespace mutation; see {@link SettingsScope.mutate}.
|
||||
* @param ops - ordered field operations copied when queued.
|
||||
* @param expectedRevision - optional fixed revision read by the domain editor.
|
||||
* @returns settlement after the mutation and any latest-write recovery read.
|
||||
*/
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<void> {
|
||||
const ownedOps = structuredClone(ops) as SettingsPathOpView[]
|
||||
const generation = ++this.writeGeneration
|
||||
return this.enqueue(async () => {
|
||||
const revision = this.pendingRevision ?? this.getSnapshot().revision
|
||||
const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
|
||||
try {
|
||||
response = await this.api.settings.mutate(this.spec.namespace, [op], revision)
|
||||
response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision)
|
||||
} catch (_settingsWriteFailure) {
|
||||
await this.recover(generation)
|
||||
return
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {
|
||||
JsonValue, SettingsNamespaceView, SettingsPathOpView,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { SettingsSchemaService } from '../src/client/schema.ts'
|
||||
@@ -174,6 +176,56 @@ describe('SettingsScopeController', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sends one copied multi-field mutation behind one revision fence', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 7))
|
||||
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 8)))
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
const ops: SettingsPathOpView[] = [
|
||||
{ op: 'set', path: ['enabled'], value: true },
|
||||
{ op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
|
||||
]
|
||||
|
||||
const write = scope.mutate(ops)
|
||||
ops[0] = { op: 'unset', path: ['enabled'] }
|
||||
;(ops[1] as unknown as { value: Array<{ model: string }> }).value[0]!.model = 'changed'
|
||||
await write
|
||||
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
'ui-test',
|
||||
[
|
||||
{ op: 'set', path: ['enabled'], value: true },
|
||||
{ op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
|
||||
],
|
||||
7,
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves an editor-owned revision fence behind earlier queued writes', async () => {
|
||||
const first = deferred<Answer<SettingsNamespaceView>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 7))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 8))
|
||||
const mutate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(rejected())
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
|
||||
const earlier = scope.set('preference', 'dark')
|
||||
const fenced = scope.mutate([{ op: 'set', path: ['preference'], value: 'light' }], 7)
|
||||
first.resolve(ok(view({ preference: 'dark' }, 8)))
|
||||
await Promise.all([earlier, fenced])
|
||||
|
||||
expect(mutate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'ui-test',
|
||||
[{ op: 'set', path: ['preference'], value: 'light' }],
|
||||
7,
|
||||
)
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 8 })
|
||||
})
|
||||
|
||||
it('folds the latest write answer into the mirror so a sibling scope sees it', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4))
|
||||
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5)))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1717,6 +1717,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [
|
||||
'client-ui-settings-plugins BashCard',
|
||||
'client-ui-settings-plugins AgentLoopCard',
|
||||
'client-ui-settings-plugins SubagentModelSelectionCard',
|
||||
'client-ui-settings-plugins WebSearchCard',
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
|
||||
@@ -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: 'current(): SubagentModelSelectionSettings',
|
||||
description: 'Read a detached selection preference for the next eligible Agent publication.',
|
||||
parameters: [],
|
||||
returns: 'whether that Agent should receive model-selectable delegation.',
|
||||
returns: 'the enabled state and exact allowed routes.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -25,12 +25,19 @@
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
DSH_TEST_CHILD_FAILURE: !!js String(process.env.DSH_TEST_CHILD_FAILURE ?? '')
|
||||
|
||||
- id: subagent-model-selection-settings
|
||||
name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
|
||||
config:
|
||||
enabled: true
|
||||
allowedModels:
|
||||
- provider: mock
|
||||
model: mock-routed
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
name: './scoped-tool-subagent.ts'
|
||||
config:
|
||||
provider: dsh-sdk
|
||||
toolName: subagent
|
||||
enableModelSelection: true
|
||||
agentOptions:
|
||||
maxTokens: 777
|
||||
# The SDK backend advertises no depthLimit: the child harness owns its own
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/** Mount the SDK delegation tool in each fixture Agent's scope. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
import type { Config } from '@deepseek-ai/dsh-tool-subagent'
|
||||
|
||||
export const name = 'scoped-tool-subagent'
|
||||
export const inject = ['agents', 'subagentModelSelection']
|
||||
|
||||
/**
|
||||
* Install the configured delegation tool before a published Agent starts its loop.
|
||||
* @param ctx - fixture Host context carrying Agent lifecycle events.
|
||||
* @param config - delegation-tool configuration forwarded into each Agent scope.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.on('agent/created', ({ agent }) => {
|
||||
agent.ctx.plugin(ToolSubagent, {
|
||||
provider: config.provider,
|
||||
modelSelectionSettings: true,
|
||||
...(config.toolName === undefined ? {} : { toolName: config.toolName }),
|
||||
...(config.enableRunInBackground === undefined
|
||||
? {}
|
||||
: { enableRunInBackground: config.enableRunInBackground }),
|
||||
...(config.backgroundMode === undefined ? {} : { backgroundMode: config.backgroundMode }),
|
||||
...(config.agentOptions === undefined ? {} : { agentOptions: config.agentOptions }),
|
||||
...(config.persona === undefined ? {} : { persona: config.persona }),
|
||||
...(config.toolFilter === undefined ? {} : { toolFilter: config.toolFilter }),
|
||||
...(config.maxDepth === undefined ? {} : { maxDepth: config.maxDepth }),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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: 5520e98ddcdc4532a74cf7a8a812b9e60751eaf3
|
||||
README.zh.md: 069eca4f8c9dd0bf8fd939be2569c63f88c6be0b
|
||||
|
||||
@@ -44,8 +44,7 @@ Load the subagent service, an in-process or remote backend, and this tool; then
|
||||
|---|---|---|
|
||||
| `provider` | required | Provider name on `ctx.subagents` (e.g. `spawn`, `fork`, `acp`) |
|
||||
| `toolName` | `subagent` | Model-facing tool name; distinct for every loaded instance |
|
||||
| `enableModelSelection` | `false` | Statically expose child LLM selection fields and register `list_subagent_models`; requires provider `agentOptions` support |
|
||||
| `modelSelectionSettings` | `false` | Sample the Host preference for each new top-level Session; mutually exclusive with `enableModelSelection` and valid only in Agent scope |
|
||||
| `modelSelectionSettings` | `false` | Sample the Host's exact-route authorization preference for each new top-level Session; valid only in Agent scope and requires provider `agentOptions` support |
|
||||
| `enableRunInBackground` | `true` | Expose `run_in_background`; disabling also rejects forced background calls |
|
||||
| `backgroundMode` | `one-shot` | Background policy: `one-shot` defaults calls to foreground; `continuable` defaults them to background and requires the provider's `prepareContinuable` capability |
|
||||
| `agentOptions` | — | Configured child `provider`, `model`, adapter-owned `reasoningEffort`, and positive `maxTokens` defaults; requires provider `agentOptions` support and overlays any provider-owned route defaults |
|
||||
@@ -65,7 +64,7 @@ Under `continuable` policy, an omitted or `true` `run_in_background` starts a du
|
||||
|
||||
### Selecting a child LLM
|
||||
|
||||
Set `enableModelSelection: true` to expose optional `provider`, `model`, and `reasoning_effort` fields and register the shared `list_subagent_models` tool. Alternatively, set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection.enabled` preference when each top-level Session is composed. That decision is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. These modes are mutually exclusive and require a backend that advertises `agentOptions`; ACP, Codex, and Claude Code reject them, while DSH SDK supports route selection.
|
||||
Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each top-level Session is composed. When enabled, its non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it.
|
||||
|
||||
A call supplies `provider` and `model` together, or supplies only an effort when configured, parent, or provider-owned defaults provide the route. Static `provider.agentRouteDefaults`, when present, form the provider/model baseline; tool configuration and model fields overlay it before route-aware effort merging and exact-route preflight. Providers without these defaults use compatible values from the parent's latest logged request, then the parent's creation options before its first request, while retaining the configured `maxTokens`. Changing the route without an explicit effort clears the inherited route-owned effort, so the selected model resolves its default. The live LLM adapter validates the effective route before child creation. Catalog membership remains advisory, so a model can use an unlisted id when its adapter accepts it.
|
||||
|
||||
@@ -132,7 +131,7 @@ Read these pages when the package-level contract is not enough; they move from t
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Model selection adds `provider`, `model`, and `reasoning_effort` plus inheritance and selection guidance. The tool and prompt descriptions follow whether the child inherits the conversation. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default and the settlement notice, while one-shot mode documents its `false` default and job collection. A tool restriction removes both the schema and the guidance section.
|
||||
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. An enabled Session policy adds `provider`, `model`, and `reasoning_effort` plus inheritance and selection guidance; the provider must support `agentOptions`. Provider context inheritance changes the tool and prompt descriptions. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the job id collected with `job_output` or stopped with `job_kill`. While the tool is visible in an assembly's scope, a `tool:<toolName>` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result; a tool restriction removes both its schema and this guidance.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -146,7 +145,7 @@ Prefix-stable while provider instances and their configuration are unchanged. Ad
|
||||
|
||||
#### What the model sees
|
||||
|
||||
An instance with static `enableModelSelection: true`, or a settings-controlled instance whose Session 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.
|
||||
A settings-controlled instance whose Session carries a policy exposes the child LLM selection fields and `list_subagent_models`. Calls reject while the optional `ctx.llm` service is unavailable. Discovery returns only registered providers and advertised models in the exact route policy; an unauthorized provider is rejected before its adapter catalog is called, and an exact lookup must be allowed before it resolves the model's reasoning efforts and default. Execution independently enforces the same policy.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -214,7 +213,7 @@ These limits define what this tool does not return or enforce; they are current
|
||||
- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries any final assistant message, but it is not this call's return value and cannot be awaited here.
|
||||
- **Duplicate names across waiting one-shot instances are detected late** (`TODO(subagent-dup-toolname)`) — continuable instances reserve their prompt-section name during plugin application, but preventing provider-registration rollback for waiting one-shot instances requires a registry of intended names.
|
||||
- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable selection only when route changes preserve reuse or expose a bounded recomputation cost.
|
||||
- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM selection requires static enablement or an enabled per-Session preference and a provider that advertises `agentOptions`; ACP, Codex, and Claude Code reject it rather than ignore it.
|
||||
- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM selection requires an enabled per-Session preference and a provider that advertises `agentOptions`; both in-process providers and DSH SDK advertise it, while ACP, Codex, and Claude Code reject it rather than ignore it.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -44,8 +44,7 @@ kind: "package-reference"
|
||||
|---|---|---|
|
||||
| `provider` | 必填 | `ctx.subagents` 上的提供方名称(如 `spawn`、`fork`、`acp`) |
|
||||
| `toolName` | `subagent` | 面向模型的工具名称;每个已加载实例必须不同 |
|
||||
| `enableModelSelection` | `false` | 静态公开子级 LLM 选择字段并注册 `list_subagent_models`;要求提供方支持 `agentOptions` |
|
||||
| `modelSelectionSettings` | `false` | 为每个新顶层 Session 读取宿主偏好;与 `enableModelSelection` 互斥,且只在 Agent 作用域内有效 |
|
||||
| `modelSelectionSettings` | `false` | 为每个新顶层 Session 读取宿主的精确路由授权偏好;只在 Agent 作用域内有效,并要求提供方支持 `agentOptions` |
|
||||
| `enableRunInBackground` | `true` | 公开 `run_in_background`;禁用时也会拒绝强制后台调用 |
|
||||
| `backgroundMode` | `one-shot` | 后台策略:`one-shot` 默认前台调用;`continuable` 默认后台调用,并要求提供方具备 `prepareContinuable` 能力 |
|
||||
| `agentOptions` | — | 配置的子级 `provider`、`model`、适配器所有的 `reasoningEffort` 与正整数 `maxTokens` 默认值;要求提供方支持 `agentOptions`,并会覆盖提供方持有的路由默认值 |
|
||||
@@ -65,7 +64,7 @@ kind: "package-reference"
|
||||
|
||||
### 选择子级 LLM
|
||||
|
||||
设置 `enableModelSelection: true` 可公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。也可以设置 `modelSelectionSettings: true`,在组合每个顶层 Session 时读取宿主的 `subagent-model-selection.enabled` 偏好。该决定会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。这两种模式互斥,且要求后端声明 `agentOptions`;ACP、Codex 与 Claude Code 会拒绝它们,DSH SDK 则支持路由选择。
|
||||
设置 `modelSelectionSettings: true`,即可在组合每个顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。
|
||||
|
||||
一次调用需同时提供 `provider` 与 `model`;当配置值、父 agent 值或提供方持有的默认值能提供路由时,也可只提供推理等级。静态的 `provider.agentRouteDefaults` 在存在时构成提供方/模型基线;工具配置与模型字段会在路由相关强度合并和确切路由预检前覆盖它。没有这些默认值的提供方会使用父 agent 最新已记录请求中的兼容值,再使用父级首次请求前的创建选项,并保留配置的 `maxTokens`。更改路由但未显式提供推理等级时,会清除继承的路由自有等级,使所选模型解析自己的默认值。实时 LLM 适配器在创建子 agent 前校验有效路由。目录成员资格只提供建议,因此适配器接受时,模型可以使用未列出的 id。
|
||||
|
||||
@@ -132,7 +131,7 @@ kind: "package-reference"
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。模型选择会添加 `provider`、`model` 与 `reasoning_effort`,以及继承和选择指引。工具与提示词描述会随子 agent 是否继承对话而调整。启用后台模式会添加 `run_in_background`:可继续模式记录其默认值为 `true` 及结算通知,一次性模式记录其默认值为 `false` 及任务收集。工具限制会同时移除 schema 与指导 section。
|
||||
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。启用的 Session 策略会添加 `provider`、`model` 与 `reasoning_effort`,以及继承和选择指引;提供方必须支持 `agentOptions`。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `job_output` 收集或用 `job_kill` 停止的 job id。当工具在本次组装的作用域中可见时,一个 `tool:<toolName>` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -146,7 +145,7 @@ kind: "package-reference"
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
静态设置 `enableModelSelection: true` 的实例,或其 Session 决定为启用的设置控制实例,会公开子级 LLM 选择字段与 `list_subagent_models`。不带参数时,发现工具返回已注册提供方的 id 与名称;带 `provider` 时返回其公布模型;同时带 `provider` 与 `model` 时解析该模型,并返回其公布的推理等级与默认值。可选的 `ctx.llm` 服务不可用时,调用会失败。结果是只读运行时元数据,不是授权清单。
|
||||
Session 携带策略的 settings 控制实例会公开子级 LLM 选择字段与 `list_subagent_models`。可选 `ctx.llm` 服务不可用时,调用会失败。发现只返回精确路由策略中的已注册提供方与已公布模型;未授权提供方会在调用其适配器目录前被拒绝,精确查询也必须先获准,才会解析模型的推理强度与默认值。执行阶段会独立强制同一策略。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -214,7 +213,7 @@ Use subagent in the background by default. Start independent delegations togethe
|
||||
- **后台运行不通过本工具公开结果**——一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束,并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。
|
||||
- **等待中的一次性实例较晚才发现重复名称**(`TODO(subagent-dup-toolname)`)——可继续实例会在插件应用期间预留提示词 section 名称,但若要阻止等待中的一次性实例回滚提供方注册,仍需要一份预期名称注册表。
|
||||
- **随附 fork 工具不能选择子级 LLM 路由**——它们继承父级提供方与模型,使复制的对话前缀仍有资格复用 KV Cache。仅当路由变更能保留复用或公开有界重算成本时,才重新启用选择。
|
||||
- **非路由子 agent 策略按实例固定**——另一个 persona、工具过滤器或深度上限需要另一个名称不同的工具。LLM 选择要求静态启用或已启用的逐 Session 偏好,且提供方必须声明 `agentOptions`;ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。
|
||||
- **非路由子 agent 策略按实例固定**——另一个 persona、工具过滤器或深度上限需要另一个名称不同的工具。LLM 选择要求启用逐 Session 偏好,且提供方必须声明 `agentOptions`;两个进程内提供方和 DSH SDK 会声明该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -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'
|
||||
@@ -53,12 +54,9 @@ export interface Config {
|
||||
* a distinct name.
|
||||
*/
|
||||
toolName?: string
|
||||
/** Let the model discover and select the child LLM route (default false). */
|
||||
enableModelSelection?: boolean
|
||||
/**
|
||||
* Sample the Host `subagent-model-selection` user setting for each new
|
||||
* top-level session and inherit that decision in its child sessions. Mutually
|
||||
* exclusive with `enableModelSelection`.
|
||||
* top-level session and inherit that decision in its child sessions.
|
||||
*/
|
||||
modelSelectionSettings?: boolean
|
||||
/**
|
||||
@@ -108,7 +106,6 @@ export interface Config {
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
toolName: z.string().default('subagent'),
|
||||
enableModelSelection: z.boolean().default(false),
|
||||
modelSelectionSettings: z.boolean().default(false),
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'),
|
||||
@@ -316,14 +313,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
|
||||
throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
|
||||
}
|
||||
if (config.enableModelSelection === true && config.modelSelectionSettings === true) {
|
||||
throw new Error('tool-subagent: `enableModelSelection` and `modelSelectionSettings` are mutually exclusive')
|
||||
}
|
||||
const backgroundEnabled = config.enableRunInBackground !== false
|
||||
const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable'
|
||||
const toolName = config.toolName ?? 'subagent'
|
||||
|
||||
const modelSelectionCapable = config.enableModelSelection === true || config.modelSelectionSettings === true
|
||||
const modelSelectionCapable = config.modelSelectionSettings === true
|
||||
|
||||
const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => {
|
||||
if (typeof config.maxDepth === 'number' && !subagentProvider.capabilities.depthLimit) {
|
||||
@@ -357,8 +351,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 +482,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 +600,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
|
||||
if (config.modelSelectionSettings !== true) {
|
||||
install(ctx, config.enableModelSelection === true)
|
||||
install(ctx, undefined)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -615,21 +616,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.current()
|
||||
allowedModels = current.enabled ? current.allowedModels : undefined
|
||||
}
|
||||
}
|
||||
if (enabled) recordSubagentModelSelection(agent.session)
|
||||
return enabled
|
||||
if (allowedModels !== undefined) recordSubagentModelSelection(agent.session, allowedModels)
|
||||
return allowedModels === undefined ? undefined : { routes: allowedModels }
|
||||
}
|
||||
|
||||
const agent = ctx.agent
|
||||
@@ -649,11 +651,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// Reserve before the injected fiber runs: tool registration emits
|
||||
// `tools/change` synchronously, which re-enters the reconciliation below.
|
||||
installing.add(candidate)
|
||||
const enabled = selectForAgent(candidate)
|
||||
const fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => {
|
||||
install(runtimeCtx, enabled)
|
||||
})
|
||||
installing.delete(candidate)
|
||||
let fiber: ReturnType<Context['inject']>
|
||||
try {
|
||||
const policy = selectForAgent(candidate)
|
||||
fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => {
|
||||
install(runtimeCtx, policy)
|
||||
})
|
||||
} finally {
|
||||
installing.delete(candidate)
|
||||
}
|
||||
scopedInstalls.set(candidate, fiber)
|
||||
}
|
||||
const removeScoped = (candidate: Agent): void => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -15,20 +15,22 @@ export const name = 'tool-subagent-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Assert that a durable opt-in is represented by both model-facing definitions. */
|
||||
/** Assert that model-selectable definitions are complete and reconstructable. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
ctx.on('agent/pre-step', async ({ agent }, next) => {
|
||||
if (hasSubagentModelSelection(agent.session)) {
|
||||
const schemas = ctx.tools.schemas(agent)
|
||||
const selectable = schemas.some((schema) => {
|
||||
const properties = (schema.parameters as { properties?: Record<string, unknown> }).properties
|
||||
return properties?.['provider'] !== undefined
|
||||
&& properties['model'] !== undefined
|
||||
&& properties['reasoning_effort'] !== undefined
|
||||
})
|
||||
if (!selectable || !schemas.some(schema => schema.name === 'list_subagent_models')) {
|
||||
fail('a subagent/model-selection-enabled session must expose route fields and list_subagent_models')
|
||||
}
|
||||
const schemas = ctx.tools.schemas(agent)
|
||||
const selectable = schemas.some((schema) => {
|
||||
const properties = (schema.parameters as { properties?: Record<string, unknown> }).properties
|
||||
return properties?.['provider'] !== undefined
|
||||
&& properties['model'] !== undefined
|
||||
&& properties['reasoning_effort'] !== undefined
|
||||
})
|
||||
const discoverable = schemas.some(schema => schema.name === 'list_subagent_models')
|
||||
if (
|
||||
(selectable || discoverable)
|
||||
&& (subagentModelSelectionPolicy(agent.session) === undefined || !selectable || !discoverable)
|
||||
) {
|
||||
fail('model-selectable subagent definitions require a durable policy, route fields, and list_subagent_models')
|
||||
}
|
||||
return next()
|
||||
}, { global: true })
|
||||
|
||||
@@ -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
|
||||
@@ -11,11 +12,18 @@ interface ListSubagentModelsRequest {
|
||||
}
|
||||
|
||||
/** Resolve one registered provider with a model-correctable diagnostic. */
|
||||
function registeredProvider(llm: LlmRuntime, providerId: string): LlmProviderInfo {
|
||||
function registeredProvider(
|
||||
llm: LlmRuntime,
|
||||
policy: ModelSelectionPolicy,
|
||||
providerId: string,
|
||||
): LlmProviderInfo {
|
||||
const providers = llm.listProviders()
|
||||
const provider = providers.find(candidate => candidate.id === providerId)
|
||||
if (provider !== undefined) return provider
|
||||
const available = providers.map(candidate => candidate.id).join(', ') || '(none)'
|
||||
const available = providers
|
||||
.filter(candidate => policy.routes.some(route => route.provider === candidate.id))
|
||||
.map(candidate => candidate.id)
|
||||
.join(', ') || '(none)'
|
||||
throw new Error(`LLM provider "${providerId}" is not registered; available providers: ${available}`)
|
||||
}
|
||||
|
||||
@@ -27,6 +35,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> {
|
||||
@@ -39,19 +48,28 @@ async function listSubagentModels(
|
||||
}
|
||||
if (request.provider === undefined) {
|
||||
const providers = llm.listProviders()
|
||||
.filter(provider => policy.routes.some(route => route.provider === provider.id))
|
||||
return providers.length === 0
|
||||
? '(no LLM providers)'
|
||||
: providers.map(provider => `${provider.id} — ${provider.name}`).join('\n')
|
||||
}
|
||||
if (request.provider.length === 0) throw new Error('`provider` must be non-empty')
|
||||
const provider = registeredProvider(llm, request.provider)
|
||||
const allowedRoutes = policy.routes.filter(route => route.provider === request.provider)
|
||||
if (allowedRoutes.length === 0) {
|
||||
throw new Error(`LLM provider "${request.provider}" is not allowed for this Session`)
|
||||
}
|
||||
const provider = registeredProvider(llm, policy, request.provider)
|
||||
if (request.model === undefined) {
|
||||
const models = await llm.listModels(provider.id)
|
||||
const models = (await llm.listModels(provider.id))
|
||||
.filter(model => allowedRoutes.some(route => route.model === model.id))
|
||||
return models.length === 0
|
||||
? `(no advertised models for ${provider.id})`
|
||||
: models.map(model => modelLine(provider.id, model)).join('\n')
|
||||
}
|
||||
if (request.model.length === 0) throw new Error('`model` must be non-empty')
|
||||
if (!allowedRoutes.some(route => route.model === request.model)) {
|
||||
throw new Error(`child LLM route "${provider.id}/${request.model}" is not allowed for this Session`)
|
||||
}
|
||||
const model = await llm.resolveModelInfo(provider.id, request.model, signal)
|
||||
const efforts = model.reasoning?.efforts.map(effort => (
|
||||
`${effort.id}${model.reasoning?.defaultEffort === effort.id ? ' (default)' : ''} — ${effort.name}`
|
||||
@@ -63,8 +81,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 +107,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,44 @@ 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. */
|
||||
/** Whether newly composed top-level Sessions receive model selection. */
|
||||
enabled: boolean
|
||||
/** Exact child LLM routes offered to newly composed top-level Sessions. */
|
||||
allowedModels: AllowedModelRoute[]
|
||||
}
|
||||
|
||||
/** Schema served to settings clients for the opt-in preference. */
|
||||
export const SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA: z<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. */
|
||||
/** Initial enabled state inherited when the user document does not override it. */
|
||||
enabled?: boolean
|
||||
/** Initial route list inherited when the user document does not override it. */
|
||||
allowedModels?: AllowedModelRoute[]
|
||||
}
|
||||
|
||||
/** 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 = {
|
||||
enabled: config.enabled ?? false,
|
||||
allowedModels: config.allowedModels ?? [],
|
||||
}
|
||||
this.validate(entry)
|
||||
this.source = () => entry
|
||||
installSettingsSection(
|
||||
ctx,
|
||||
@@ -50,6 +67,7 @@ export class SubagentModelSelectionConfig extends Service {
|
||||
entry,
|
||||
{
|
||||
setSource: (source) => { this.source = source },
|
||||
validate: (value) => { this.validate(value) },
|
||||
// Consumers sample at Agent publication, so a settings update never
|
||||
// rebuilds the tool definitions of an Agent that is already running.
|
||||
onChange: () => {},
|
||||
@@ -58,11 +76,22 @@ 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 selection preference for the next eligible Agent publication.
|
||||
* @returns the enabled state and exact allowed routes.
|
||||
*/
|
||||
currentEnabled(): boolean {
|
||||
return this.source().enabled
|
||||
current(): SubagentModelSelectionSettings {
|
||||
const current = this.source()
|
||||
return {
|
||||
enabled: current.enabled,
|
||||
allowedModels: current.allowedModels.map(route => ({ ...route })),
|
||||
}
|
||||
}
|
||||
|
||||
private validate(value: SubagentModelSelectionSettings): void {
|
||||
assertAllowedModelRoutes(value.allowedModels)
|
||||
if (value.enabled && value.allowedModels.length === 0) {
|
||||
throw new Error('enabled subagent model selection requires at least one allowed model')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,36 @@ 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 { allowedModels } = event.data
|
||||
assertAllowedModelRoutes(allowedModels)
|
||||
const routes = allowedModels.map(route => ({ ...route }))
|
||||
if (routes.length === 0) throw new Error('subagent/model-selection-policy requires at least one route')
|
||||
return routes
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,63 @@
|
||||
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 interface ModelSelectionPolicy {
|
||||
/** Exact provider/model routes authorized for explicit selection. */
|
||||
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 durable or configuration boundary.
|
||||
* @param routes - Candidate exact routes to validate.
|
||||
* @returns an assertion that the candidate is a validated exact-route array.
|
||||
*/
|
||||
export function assertAllowedModelRoutes(routes: unknown): asserts routes is readonly AllowedModelRoute[] {
|
||||
if (!Array.isArray(routes)) {
|
||||
throw new Error('subagent model selection requires an array of routes')
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const candidates: readonly unknown[] = routes
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)
|
||||
|| !('provider' in candidate) || typeof candidate.provider !== 'string'
|
||||
|| !('model' in candidate) || typeof candidate.model !== 'string'
|
||||
|| candidate.provider.length === 0 || candidate.model.length === 0) {
|
||||
throw new Error('subagent model selection requires non-empty provider and model ids')
|
||||
}
|
||||
const route = { provider: candidate.provider, model: candidate.model }
|
||||
const key = modelRouteKey(route)
|
||||
if (seen.has(key)) {
|
||||
throw new Error(`subagent model selection repeats route "${route.provider}/${route.model}"`)
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** Model-facing child LLM route fields. */
|
||||
export interface DelegationModelRequest {
|
||||
@@ -70,6 +127,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 === undefined || !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.
|
||||
|
||||
@@ -2,11 +2,14 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import LlmRuntime, { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as mock from './scripted-provider.ts'
|
||||
import * as tool from '../src/index.ts'
|
||||
import SubagentModelSelectionConfig from '../src/model-selection-settings.ts'
|
||||
|
||||
/** Shared non-aborted tool signal for package-local integration tests. */
|
||||
export const testToolSignal = new AbortController().signal
|
||||
@@ -18,17 +21,73 @@ export function fakeAgent(id = 'parent-1'): Agent {
|
||||
}
|
||||
|
||||
/** Mount the real tool and service stack around one scripted subagent provider. */
|
||||
export async function setup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}): Promise<Context> {
|
||||
const setupAgents = new WeakMap<Context, Agent>()
|
||||
const setupProviders = new WeakMap<Context, Awaited<ReturnType<typeof mock.mountScriptedProvider>>>()
|
||||
let setupAgentCounter = 0
|
||||
|
||||
/** Test-only opt-in translated to the real Host setting and Session path. */
|
||||
type SetupConfig = tool.Config & {
|
||||
withModelSelection?: boolean
|
||||
parentAgentOptions?: AgentOptions
|
||||
}
|
||||
|
||||
const TEST_ALLOWED_MODELS = [
|
||||
'allowed-model', 'child-model', 'configured-model', 'current-model', 'fast-model',
|
||||
'other-model', 'parent-model', 'selected-model', 'unlisted-model',
|
||||
].flatMap(model => [
|
||||
{ provider: 'alpha', model },
|
||||
{ provider: 'current-provider', model },
|
||||
{ provider: 'missing', model },
|
||||
])
|
||||
|
||||
export async function setup(toolConfig: SetupConfig, mockConfig: Partial<mock.Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const { withModelSelection, parentAgentOptions, ...config } = toolConfig
|
||||
if (withModelSelection === true) {
|
||||
await ctx.plugin(SubagentModelSelectionConfig, {
|
||||
enabled: true,
|
||||
allowedModels: TEST_ALLOWED_MODELS,
|
||||
})
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
const provider = await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig })
|
||||
setupProviders.set(ctx, provider)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId(`model-selection-setup-${++setupAgentCounter}`),
|
||||
...parentAgentOptions !== undefined ? { agentOptions: parentAgentOptions } : {},
|
||||
setup: async (agentCtx) => {
|
||||
await agentCtx.plugin(tool, { ...config, modelSelectionSettings: true })
|
||||
},
|
||||
})
|
||||
setupAgents.set(ctx, handle.agent)
|
||||
return ctx
|
||||
}
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig })
|
||||
await ctx.plugin(tool, toolConfig)
|
||||
const provider = await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig })
|
||||
setupProviders.set(ctx, provider)
|
||||
await ctx.plugin(tool, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Dispose the scripted provider mounted by {@link setup}. */
|
||||
export async function disposeSetupProvider(ctx: Context): Promise<void> {
|
||||
const provider = setupProviders.get(ctx)
|
||||
if (provider === undefined) throw new Error('context has no setup provider')
|
||||
setupProviders.delete(ctx)
|
||||
await provider.dispose()
|
||||
}
|
||||
|
||||
/** Return the real Agent created for a settings-controlled setup. */
|
||||
export function modelSelectionSetupAgent(ctx: Context): Agent {
|
||||
const agent = setupAgents.get(ctx)
|
||||
if (agent === undefined) throw new Error('context has no model-selection setup Agent')
|
||||
return agent
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
|
||||
/** Execute the registered subagent tool through the real ToolRuntime pipeline. */
|
||||
@@ -40,7 +99,7 @@ export function callSubagent(
|
||||
// Distinguish "no override" (use a default agent) from an explicit
|
||||
// `{ agent: undefined }` (test the no-agent path). Under
|
||||
// exactOptionalPropertyTypes the key is omitted rather than set to undefined.
|
||||
const agent = 'agent' in over ? over.agent : fakeAgent()
|
||||
const agent = 'agent' in over ? over.agent : setupAgents.get(ctx) ?? fakeAgent()
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId(`call-${++callCounter}`),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LlmRuntime, {
|
||||
ToolCallId,
|
||||
@@ -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 {
|
||||
@@ -56,14 +57,33 @@ class CatalogAdapter extends LlmAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async function setupListTool() {
|
||||
async function setupListTool(routes = [
|
||||
{ provider: 'alpha', model: 'fast' },
|
||||
{ provider: 'alpha', model: 'plain' },
|
||||
{ provider: 'beta', model: 'fast' },
|
||||
{ provider: 'beta', model: 'plain' },
|
||||
]) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
const fiber = await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true })
|
||||
return { ctx, fiber }
|
||||
registerListSubagentModels(ctx, { routes })
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function setupAllowedListTool() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
registerListSubagentModels(ctx, {
|
||||
routes: [
|
||||
{ provider: 'alpha', model: 'fast' },
|
||||
{ provider: 'alpha', model: 'unlisted' },
|
||||
{ provider: 'missing', model: 'hidden' },
|
||||
],
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
let counter = 0
|
||||
@@ -93,23 +113,21 @@ describe('list_subagent_models', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true })
|
||||
registerListSubagentModels(ctx, { routes: [{ provider: 'alpha', model: 'fast' }] })
|
||||
const result = await call(ctx, {})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('`llm` service is unavailable')
|
||||
})
|
||||
|
||||
it('rejects two discovery-owning instances in one tool scope', async () => {
|
||||
const { ctx } = await setupListTool()
|
||||
await expect(ctx.plugin(tool, {
|
||||
provider: 'another-unused',
|
||||
toolName: 'subagent_other',
|
||||
enableModelSelection: true,
|
||||
}).then(() => undefined)).rejects.toThrow('tool "list_subagent_models" is already registered')
|
||||
const ctx = await setupListTool()
|
||||
expect(() => {
|
||||
registerListSubagentModels(ctx, { routes: [{ provider: 'alpha', model: 'fast' }] })
|
||||
}).toThrow('tool "list_subagent_models" is already registered')
|
||||
})
|
||||
|
||||
it('lists registered providers and follows live registration changes', async () => {
|
||||
const { ctx, fiber } = await setupListTool()
|
||||
const ctx = await setupListTool()
|
||||
const empty = await call(ctx, {})
|
||||
expect(empty.isError).toBe(false)
|
||||
expect(text(empty)).toBe('(no LLM providers)')
|
||||
@@ -123,20 +141,48 @@ describe('list_subagent_models', () => {
|
||||
const changed = await call(ctx, {})
|
||||
expect(text(changed)).toBe('beta — BETA API')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.get('list_subagent_models')).toBeUndefined()
|
||||
const tools = ctx.tools
|
||||
await ctx.fiber.dispose()
|
||||
expect(tools.get('list_subagent_models')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists one provider\'s advertised models without treating the catalog as a whitelist', async () => {
|
||||
const { ctx } = await setupListTool()
|
||||
const ctx = await setupListTool()
|
||||
ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
|
||||
const result = await call(ctx, { provider: 'alpha' })
|
||||
expect(result.isError).toBe(false)
|
||||
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('rejects an unauthorized provider before calling its adapter catalog', async () => {
|
||||
const ctx = await setupListTool([{ provider: 'alpha', model: 'fast' }])
|
||||
const adapter = new CatalogAdapter()
|
||||
const listModels = vi.spyOn(adapter, 'listModels')
|
||||
ctx.llm.registerAdapter(['alpha', 'secret'], adapter)
|
||||
|
||||
const result = await call(ctx, { provider: 'secret' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('provider "secret" is not allowed for this Session')
|
||||
expect(listModels).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders an empty advertised model list', async () => {
|
||||
const { ctx } = await setupListTool()
|
||||
const ctx = await setupListTool([{ provider: 'alpha', model: 'fast' }])
|
||||
ctx.llm.registerAdapter(['alpha'], new CatalogAdapter(true))
|
||||
const result = await call(ctx, { provider: 'alpha' })
|
||||
expect(result.isError).toBe(false)
|
||||
@@ -144,8 +190,8 @@ describe('list_subagent_models', () => {
|
||||
})
|
||||
|
||||
it('inspects exact-model efforts, descriptions, and defaults', async () => {
|
||||
const { ctx } = await setupListTool()
|
||||
ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
|
||||
const ctx = await setupListTool()
|
||||
ctx.llm.registerAdapter(['alpha', 'secret'], new CatalogAdapter())
|
||||
const result = await call(ctx, { provider: 'alpha', model: 'fast' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(
|
||||
@@ -155,7 +201,7 @@ describe('list_subagent_models', () => {
|
||||
})
|
||||
|
||||
it('renders exact models without reasoning metadata', async () => {
|
||||
const { ctx } = await setupListTool()
|
||||
const ctx = await setupListTool()
|
||||
ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
|
||||
const result = await call(ctx, { provider: 'alpha', model: 'plain' })
|
||||
expect(result.isError).toBe(false)
|
||||
@@ -165,16 +211,16 @@ describe('list_subagent_models', () => {
|
||||
it.each([
|
||||
{ args: { model: 'fast' }, expected: '`model` requires `provider`' },
|
||||
{ args: { provider: '' }, expected: '`provider` must be non-empty' },
|
||||
{ args: { provider: 'missing' }, expected: 'available providers: (none)' },
|
||||
{ args: { provider: 'missing' }, expected: 'is not allowed for this Session' },
|
||||
])('rejects incomplete or unavailable provider requests', async ({ args, expected }) => {
|
||||
const { ctx } = await setupListTool()
|
||||
const ctx = await setupListTool()
|
||||
const result = await call(ctx, args)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(expected)
|
||||
})
|
||||
|
||||
it('rejects an empty exact model after resolving the provider', async () => {
|
||||
const { ctx } = await setupListTool()
|
||||
const ctx = await setupListTool()
|
||||
ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
|
||||
const result = await call(ctx, { provider: 'alpha', model: '' })
|
||||
expect(result.isError).toBe(true)
|
||||
@@ -182,10 +228,21 @@ describe('list_subagent_models', () => {
|
||||
})
|
||||
|
||||
it('reports registered alternatives for an unavailable provider', async () => {
|
||||
const { ctx } = await setupListTool()
|
||||
const ctx = await setupListTool([
|
||||
{ provider: 'alpha', model: 'fast' },
|
||||
{ provider: 'missing', model: 'fast' },
|
||||
])
|
||||
ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
|
||||
const result = await call(ctx, { provider: 'missing' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('available providers: alpha')
|
||||
expect(text(result)).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('reports no available provider when the authorized registry intersection is empty', async () => {
|
||||
const ctx = await setupListTool([{ provider: 'missing', model: 'fast' }])
|
||||
const result = await call(ctx, { provider: 'missing' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('available providers: (none)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
@@ -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, { enabled: true, allowedModels: ALLOWED_MODELS })
|
||||
|
||||
expect(ctx.subagentModelSelection.currentEnabled()).toBe(true)
|
||||
expect(ctx.subagentModelSelection.current()).toEqual({ enabled: true, allowedModels: ALLOWED_MODELS })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -92,9 +96,46 @@ 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.current()).toEqual({ enabled: false, allowedModels: [] })
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: true,
|
||||
allowedModels: ALLOWED_MODELS,
|
||||
})
|
||||
expect(ctx.subagentModelSelection.current()).toEqual({ enabled: true, allowedModels: ALLOWED_MODELS })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects duplicate routes, enabled empty settings, 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"')
|
||||
|
||||
await expect(ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: true,
|
||||
allowedModels: [],
|
||||
})).rejects.toThrow('enabled subagent model selection requires at least one allowed model')
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: false,
|
||||
allowedModels: ALLOWED_MODELS,
|
||||
})
|
||||
expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: ALLOWED_MODELS })
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] })
|
||||
expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: [] })
|
||||
|
||||
const invalid = Session.create(SessionId('empty-policy'))
|
||||
invalid.append('subagent/model-selection-policy', { allowedModels: [] })
|
||||
expect(() => subagentModelSelectionPolicy(invalid)).toThrow('requires at least one route')
|
||||
|
||||
const malformed = Session.create(SessionId('malformed-policy'))
|
||||
malformed.append('subagent/model-selection-policy', {
|
||||
allowedModels: [{ provider: 1, model: 'fast-model' }],
|
||||
} as never)
|
||||
expect(() => subagentModelSelectionPolicy(malformed))
|
||||
.toThrow('requires non-empty provider and model ids')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -102,11 +143,14 @@ 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, {
|
||||
enabled: true,
|
||||
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)
|
||||
|
||||
@@ -117,8 +161,36 @@ describe('SubagentModelSelectionConfig', () => {
|
||||
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, {
|
||||
enabled: true,
|
||||
allowedModels: ALLOWED_MODELS,
|
||||
})
|
||||
const agent = await createAgent(ctx, 'enforced')
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: ToolCallId('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()
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
await ctx.plugin(ToolInvariant)
|
||||
const preset = createScope(ctx, { preset: 'standard' })
|
||||
const other = createScope(ctx, { preset: 'minimal' })
|
||||
await preset.ctx.plugin(tool, {
|
||||
@@ -138,7 +210,10 @@ describe('SubagentModelSelectionConfig', () => {
|
||||
|
||||
const disabled = await createComposed('preset-disabled')
|
||||
expect(selectable(ctx, disabled.agent)).toBe(false)
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: true,
|
||||
allowedModels: ALLOWED_MODELS,
|
||||
})
|
||||
const enabled = await createComposed('preset-enabled')
|
||||
expect(selectable(ctx, enabled.agent)).toBe(true)
|
||||
expect(selectable(ctx, disabled.agent)).toBe(false)
|
||||
@@ -146,9 +221,22 @@ describe('SubagentModelSelectionConfig', () => {
|
||||
enabledBinding!.rebind(scopeOf(other.ctx)!)
|
||||
ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
|
||||
await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(false) })
|
||||
const next = () => Promise.resolve({ kind: 'enter' as const, messages: [] })
|
||||
const payload = {
|
||||
agent: enabled.agent,
|
||||
messages: [],
|
||||
turn: 1,
|
||||
step: 1,
|
||||
signal: new AbortController().signal,
|
||||
}
|
||||
await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next))
|
||||
.resolves.toEqual({ kind: 'enter', messages: [] })
|
||||
|
||||
enabledBinding!.rebind(scopeOf(preset.ctx)!)
|
||||
ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
|
||||
await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(true) })
|
||||
await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next))
|
||||
.resolves.toEqual({ kind: 'enter', messages: [] })
|
||||
|
||||
await enabled.dispose()
|
||||
ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
|
||||
@@ -156,41 +244,72 @@ describe('SubagentModelSelectionConfig', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('releases a shared-preset installation reservation after policy selection fails', async () => {
|
||||
const ctx = await boot()
|
||||
const preset = createScope(ctx, { preset: 'standard' })
|
||||
const other = createScope(ctx, { preset: 'minimal' })
|
||||
await preset.ctx.plugin(tool, {
|
||||
provider: 'spawn',
|
||||
modelSelectionSettings: true,
|
||||
backgroundMode: 'continuable',
|
||||
})
|
||||
let binding: ReturnType<typeof bindScopeParent> | undefined
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-policy-retry'),
|
||||
setup: (agentCtx) => {
|
||||
binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!)
|
||||
},
|
||||
})
|
||||
expect(selectable(ctx, handle.agent)).toBe(false)
|
||||
|
||||
binding!.rebind(scopeOf(other.ctx)!)
|
||||
ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
|
||||
binding!.rebind(scopeOf(preset.ctx)!)
|
||||
vi.spyOn(ctx.subagentModelSelection, 'current')
|
||||
.mockImplementationOnce(() => { throw new Error('transient settings read') })
|
||||
.mockReturnValue({ enabled: true, allowedModels: ALLOWED_MODELS })
|
||||
|
||||
expect(() => { ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') })
|
||||
.toThrow('transient settings read')
|
||||
ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
|
||||
await vi.waitFor(() => { expect(selectable(ctx, handle.agent)).toBe(true) })
|
||||
|
||||
await handle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('inherits the parent decision and preserves seeded decisions across composition', async () => {
|
||||
const ctx = await boot()
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: true,
|
||||
allowedModels: ALLOWED_MODELS,
|
||||
})
|
||||
const parent = await createAgent(ctx, 'parent')
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
|
||||
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, {
|
||||
enabled: true,
|
||||
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)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects ambiguous static and settings-controlled configuration', async () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
expect(() => {
|
||||
tool.apply(ctx, {
|
||||
provider: 'missing',
|
||||
enableModelSelection: true,
|
||||
modelSelectionSettings: true,
|
||||
})
|
||||
}).toThrow('mutually exclusive')
|
||||
expect(subagentModelSelectionPolicy(resumedDisabled.session)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -218,7 +337,7 @@ describe('SubagentModelSelectionConfig', () => {
|
||||
await withoutAgent.fiber.dispose()
|
||||
})
|
||||
|
||||
it('checks the durable decision against the published tool definitions', async () => {
|
||||
it('checks model-selectable definitions without rejecting a policy-only preset', async () => {
|
||||
const ctx = await boot()
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
await ctx.plugin(ToolInvariant)
|
||||
@@ -235,14 +354,29 @@ 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')
|
||||
.resolves.toEqual({ kind: 'enter', messages: [] })
|
||||
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: true,
|
||||
allowedModels: ALLOWED_MODELS,
|
||||
})
|
||||
const enabled = await createAgent(ctx, 'invariant-enabled')
|
||||
await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next))
|
||||
.resolves.toEqual({ kind: 'enter', messages: [] })
|
||||
|
||||
const enabledSchemas = ctx.tools.schemas(enabled)
|
||||
const schemas = vi.spyOn(ctx.tools, 'schemas')
|
||||
schemas.mockReturnValue(enabledSchemas.filter(schema => schema.name !== 'list_subagent_models'))
|
||||
await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next))
|
||||
.rejects.toThrow('require a durable policy, route fields, and list_subagent_models')
|
||||
|
||||
schemas.mockReturnValue(enabledSchemas)
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
|
||||
const withoutPolicy = await createAgent(ctx, 'invariant-without-policy')
|
||||
await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: withoutPolicy }, next))
|
||||
.rejects.toThrow('require a durable policy, route fields, and list_subagent_models')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,12 @@ 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 { callSubagent, setup, text } from './harness.ts'
|
||||
import {
|
||||
assertAllowedModelRoutes,
|
||||
assertAllowedModelSelection,
|
||||
preflightChildLlmRoute,
|
||||
} from '../src/model-selection.ts'
|
||||
import { callSubagent, modelSelectionSetupAgent, setup, text } from './harness.ts'
|
||||
|
||||
const REASONING = {
|
||||
efforts: [
|
||||
@@ -32,9 +37,79 @@ function parentWithRoute(
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent model selection', () => {
|
||||
it('exposes static route fields and discovery when selection is enabled', async () => {
|
||||
const ctx = await setup({ provider: 'mock', enableModelSelection: true })
|
||||
const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')!
|
||||
it('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')
|
||||
expect(() => { assertAllowedModelRoutes({ provider: 'provider', model: 'model' }) })
|
||||
.toThrow('requires an array of routes')
|
||||
expect(() => { assertAllowedModelRoutes([{ provider: 1, model: 'model' }]) })
|
||||
.toThrow('requires non-empty provider and model ids')
|
||||
})
|
||||
|
||||
it('allows pure inheritance but rejects explicit values outside a Session allowlist', () => {
|
||||
const policy = {
|
||||
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('leaves deployment or parent defaults outside the allowlist usable when the call selects nothing', async () => {
|
||||
let starts = 0
|
||||
const ctx = await setup(
|
||||
{ provider: 'mock', withModelSelection: true },
|
||||
{ onStart: () => { starts += 1 } },
|
||||
)
|
||||
const parent = modelSelectionSetupAgent(ctx)
|
||||
;(parent as unknown as { options: Agent['options'] }).options = {
|
||||
provider: 'deployment-provider',
|
||||
model: 'deployment-model',
|
||||
}
|
||||
|
||||
const result = await callSubagent(ctx, { description: 'default route', prompt: 'do it' })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(starts).toBe(1)
|
||||
})
|
||||
it('exposes Session-authorized route fields and discovery when selection is enabled', async () => {
|
||||
const ctx = await setup({ provider: 'mock', withModelSelection: true })
|
||||
const agent = modelSelectionSetupAgent(ctx)
|
||||
const schema = ctx.tools.schemas(agent).find(entry => entry.name === 'subagent')!
|
||||
const props = (schema.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props).sort()).toEqual([
|
||||
'description',
|
||||
@@ -45,13 +120,13 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
'run_in_background',
|
||||
])
|
||||
expect(schema.description).toContain('list_subagent_models')
|
||||
expect(ctx.tools.get('list_subagent_models')).toBeDefined()
|
||||
expect(ctx.tools.get('list_subagent_models', agent)).toBeDefined()
|
||||
expect(schema.description).not.toContain('alpha')
|
||||
|
||||
const registration = ctx.llm.registerAdapter(['alpha'], new MockAdapter([]))
|
||||
const definition = ctx.tools.get('subagent')
|
||||
const definition = ctx.tools.get('subagent', agent)
|
||||
registration.replace(['beta'])
|
||||
expect(ctx.tools.get('subagent')).toBe(definition)
|
||||
expect(ctx.tools.get('subagent', agent)).toBe(definition)
|
||||
expect(definition?.description).not.toContain('beta')
|
||||
})
|
||||
|
||||
@@ -75,7 +150,7 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
|
||||
it('rejects enabled model selection when the provider cannot apply Agent options', async () => {
|
||||
await expect(setup(
|
||||
{ provider: 'mock', enableModelSelection: true, maxDepth: 'provider-managed' },
|
||||
{ provider: 'mock', withModelSelection: true, maxDepth: 'provider-managed' },
|
||||
{ capabilities: { agentOptions: false } },
|
||||
)).rejects.toThrow('provider "mock" does not support child model selection')
|
||||
})
|
||||
@@ -84,7 +159,7 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
const requests: SubagentStartRequest[] = []
|
||||
const ctx = await setup({
|
||||
provider: 'mock',
|
||||
enableModelSelection: true,
|
||||
withModelSelection: true,
|
||||
agentOptions: {
|
||||
provider: 'alpha',
|
||||
model: 'configured-model',
|
||||
@@ -93,6 +168,8 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
},
|
||||
}, { onStart: (request) => { requests.push(request) } })
|
||||
ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
|
||||
const parent = modelSelectionSetupAgent(ctx)
|
||||
;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options
|
||||
|
||||
const selected = await callSubagent(ctx, {
|
||||
description: 'route work',
|
||||
@@ -127,38 +204,44 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
const requests: SubagentStartRequest[] = []
|
||||
const ctx = await setup({
|
||||
provider: 'mock',
|
||||
enableModelSelection: true,
|
||||
withModelSelection: true,
|
||||
agentOptions: { provider: 'alpha' },
|
||||
}, { onStart: (request) => { requests.push(request) } })
|
||||
ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
|
||||
const parent = modelSelectionSetupAgent(ctx)
|
||||
;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options
|
||||
|
||||
const result = await callSubagent(ctx, {
|
||||
description: 'effort work',
|
||||
prompt: 'do it',
|
||||
reasoning_effort: 'low',
|
||||
}, { agent: parentWithRoute() })
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(requests[0]?.agentOptions).toEqual({ provider: 'alpha', reasoningEffort: 'low' })
|
||||
|
||||
const inherited = await setup({ provider: 'mock', enableModelSelection: true })
|
||||
const inherited = await setup({ provider: 'mock', withModelSelection: true })
|
||||
inherited.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
|
||||
const inheritedParent = modelSelectionSetupAgent(inherited)
|
||||
;(inheritedParent as unknown as { options: Agent['options'] }).options = parentWithRoute().options
|
||||
const inheritedResult = await callSubagent(inherited, {
|
||||
description: 'parent effort work',
|
||||
prompt: 'do it',
|
||||
reasoning_effort: 'low',
|
||||
}, { agent: parentWithRoute() })
|
||||
})
|
||||
expect(inheritedResult.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('inherits a parent effort only when an explicit route stays unchanged', async () => {
|
||||
const ctx = await setup({ provider: 'mock', enableModelSelection: true })
|
||||
const ctx = await setup({ provider: 'mock', withModelSelection: true })
|
||||
ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
|
||||
const parent = modelSelectionSetupAgent(ctx)
|
||||
;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options
|
||||
const result = await callSubagent(ctx, {
|
||||
description: 'same route work',
|
||||
prompt: 'do it',
|
||||
provider: 'alpha',
|
||||
model: 'parent-model',
|
||||
}, { agent: parentWithRoute() })
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
|
||||
@@ -166,11 +249,14 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
const requests: SubagentStartRequest[] = []
|
||||
const ctx = await setup({
|
||||
provider: 'mock',
|
||||
enableModelSelection: true,
|
||||
withModelSelection: true,
|
||||
agentOptions: { reasoningEffort: ReasoningEffortId('high') },
|
||||
}, { onStart: (request) => { requests.push(request) } })
|
||||
ctx.llm.registerAdapter(['current-provider'], new MockAdapter([], REASONING))
|
||||
const parent = parentWithRoute({ provider: 'created-provider', model: 'created-model' })
|
||||
const parent = modelSelectionSetupAgent(ctx)
|
||||
;(parent as unknown as { options: Agent['options'] }).options = {
|
||||
provider: 'created-provider', model: 'created-model',
|
||||
}
|
||||
parent.session.append('request/header', {
|
||||
header: { config: { provider: 'current-provider', model: 'current-model' } },
|
||||
reason: 'initial',
|
||||
@@ -181,7 +267,7 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
prompt: 'do it',
|
||||
provider: 'current-provider',
|
||||
model: 'current-model',
|
||||
}, { agent: parent })
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(requests[0]?.agentOptions).toEqual({
|
||||
@@ -192,7 +278,7 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
})
|
||||
|
||||
it('rejects an effort without any effective route', async () => {
|
||||
const ctx = await setup({ provider: 'mock', enableModelSelection: true })
|
||||
const ctx = await setup({ provider: 'mock', withModelSelection: true })
|
||||
const result = await callSubagent(ctx, {
|
||||
description: 'missing route',
|
||||
prompt: 'do it',
|
||||
@@ -202,12 +288,18 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
expect(text(result)).toContain('without an effective provider and model')
|
||||
})
|
||||
|
||||
it('rejects preflight without an effective provider and model', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
await expect(preflightChildLlmRoute(ctx.llm, {}, undefined, AbortSignal.abort()))
|
||||
.rejects.toThrow('without an effective provider and model')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ provider: 'alpha' },
|
||||
{ model: 'fast-model' },
|
||||
])('rejects a partial model-facing route before child creation', async (route) => {
|
||||
let starts = 0
|
||||
const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } })
|
||||
const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } })
|
||||
const result = await callSubagent(ctx, { description: 'partial route', prompt: 'do it', ...route })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('`provider` and `model` must be supplied together')
|
||||
@@ -219,7 +311,7 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
{ provider: 'alpha', model: '', expected: '`model` must be non-empty' },
|
||||
{ reasoning_effort: '', expected: '`reasoning_effort` must be non-empty' },
|
||||
])('rejects empty model-facing values', async ({ expected, ...selection }) => {
|
||||
const ctx = await setup({ provider: 'mock', enableModelSelection: true })
|
||||
const ctx = await setup({ provider: 'mock', withModelSelection: true })
|
||||
const result = await callSubagent(ctx, { description: 'empty route', prompt: 'do it', ...selection })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(expected)
|
||||
@@ -227,7 +319,7 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
|
||||
it('uses the LLM runtime for provider and reasoning-effort validation before child creation', async () => {
|
||||
let starts = 0
|
||||
const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } })
|
||||
const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } })
|
||||
ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING))
|
||||
|
||||
const unsupported = await callSubagent(ctx, {
|
||||
@@ -302,7 +394,6 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
await mock.mountScriptedProvider(ctx, { name: 'mock' })
|
||||
await ctx.plugin(tool, {
|
||||
provider: 'mock',
|
||||
enableModelSelection: true,
|
||||
agentOptions: {
|
||||
provider: 'alpha',
|
||||
model: 'fast-model',
|
||||
@@ -314,14 +405,6 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
expect(configured.isError).toBe(true)
|
||||
expect(text(configured)).toContain('`llm` service is unavailable')
|
||||
|
||||
const selected = await callSubagent(ctx, {
|
||||
description: 'selected route',
|
||||
prompt: 'do it',
|
||||
provider: 'alpha',
|
||||
model: 'other-model',
|
||||
})
|
||||
expect(selected.isError).toBe(true)
|
||||
expect(text(selected)).toContain('`llm` service is unavailable')
|
||||
})
|
||||
|
||||
it('keeps pure inherited routing usable without an LLM service lookup', async () => {
|
||||
@@ -339,15 +422,15 @@ describe('dsh-tool-subagent model selection', () => {
|
||||
})
|
||||
|
||||
it('warns that changing a fork route can lose inherited-prefix reuse', async () => {
|
||||
const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { inheritsParentContext: true })
|
||||
const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')!
|
||||
const ctx = await setup({ provider: 'mock', withModelSelection: true }, { inheritsParentContext: true })
|
||||
const schema = ctx.tools.schemas(modelSelectionSetupAgent(ctx)).find(entry => entry.name === 'subagent')!
|
||||
expect(schema.description).toContain('inherits this conversation')
|
||||
expect(schema.description).toContain('can prevent provider-side reuse of the inherited conversation prefix')
|
||||
})
|
||||
|
||||
it('propagates an exact-route resolver failure before child creation', async () => {
|
||||
let starts = 0
|
||||
const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } })
|
||||
const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } })
|
||||
const adapter = new MockAdapter([])
|
||||
vi.spyOn(adapter, 'resolveModel').mockRejectedValue(new Error('selected route unavailable'))
|
||||
ctx.llm.registerAdapter(['alpha'], adapter)
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface Config {
|
||||
capabilities?: Partial<SubagentCapabilities>
|
||||
/** Whether tool descriptions say the child inherits completed turns. */
|
||||
inheritsParentContext?: boolean
|
||||
/** Provider-owned child route defaults. */
|
||||
agentRouteDefaults?: Readonly<{ provider: string; model: string }>
|
||||
/** Structured value returned when the request asks for one. */
|
||||
structured?: unknown
|
||||
/** Observes each start; the child's result additionally waits for the returned promise. */
|
||||
@@ -110,7 +112,10 @@ export function mountScriptedProvider(ctx: Context, config: Config) {
|
||||
name: 'scripted-subagent-provider',
|
||||
inject: ['subagents'],
|
||||
apply(pluginCtx: Context): void {
|
||||
pluginCtx.subagents.registerProvider(new ScriptedSubagentProvider(config.name, config))
|
||||
const provider = new ScriptedSubagentProvider(config.name, config)
|
||||
pluginCtx.subagents.registerProvider(config.agentRouteDefaults === undefined
|
||||
? provider
|
||||
: Object.assign(provider, { agentRouteDefaults: config.agentRouteDefaults }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import LlmRuntime, { ToolCallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { ToolCallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -21,7 +21,15 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a
|
||||
import * as mock from './scripted-provider.ts'
|
||||
import * as tool from '../src/index.ts'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { callSubagent, fakeAgent, setup, testToolSignal, text } from './harness.ts'
|
||||
import {
|
||||
callSubagent,
|
||||
disposeSetupProvider,
|
||||
fakeAgent,
|
||||
modelSelectionSetupAgent,
|
||||
setup,
|
||||
testToolSignal,
|
||||
text,
|
||||
} from './harness.ts'
|
||||
|
||||
/**
|
||||
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
|
||||
@@ -221,36 +229,19 @@ describe('dsh-tool-subagent', () => {
|
||||
})
|
||||
|
||||
it('merges model overrides over provider-owned route defaults before preflight', async () => {
|
||||
let seen: { agentOptions?: { provider?: string; model?: string; reasoningEffort?: string; maxTokens?: number } } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture',
|
||||
capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
let seen: SubagentStartRequest | undefined
|
||||
const ctx = await setup({
|
||||
provider: 'mock',
|
||||
withModelSelection: true,
|
||||
agentOptions: { reasoningEffort: ReasoningEffortId('high'), maxTokens: 321 },
|
||||
maxDepth: 'provider-managed',
|
||||
}, {
|
||||
agentRouteDefaults: { provider: 'alpha', model: 'child-model' },
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
onStart: (request) => { seen = request },
|
||||
})
|
||||
ctx.llm.registerAdapter(['alpha'], new MockAdapter([], {
|
||||
efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
|
||||
}))
|
||||
await ctx.plugin(tool, {
|
||||
provider: 'capture',
|
||||
enableModelSelection: true,
|
||||
agentOptions: { reasoningEffort: ReasoningEffortId('high'), maxTokens: 321 },
|
||||
maxDepth: 'provider-managed',
|
||||
})
|
||||
|
||||
await callSubagent(ctx, {
|
||||
description: 'd',
|
||||
@@ -258,7 +249,7 @@ describe('dsh-tool-subagent', () => {
|
||||
provider: 'alpha',
|
||||
model: 'child-model',
|
||||
})
|
||||
expect(ctx.tools.schemas().find(schema => schema.name === 'subagent')?.description)
|
||||
expect(ctx.tools.schemas(modelSelectionSetupAgent(ctx)).find(schema => schema.name === 'subagent')?.description)
|
||||
.toContain('this provider\'s route defaults')
|
||||
expect(seen?.agentOptions).toEqual({
|
||||
provider: 'alpha',
|
||||
@@ -270,40 +261,21 @@ describe('dsh-tool-subagent', () => {
|
||||
|
||||
it('does not inherit parent effort for a provider-owned route default', async () => {
|
||||
let seen: SubagentStartRequest | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'provider-defaults',
|
||||
capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
agentRouteDefaults: { provider: 'alpha', model: 'child-model' },
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('provider-default-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
ctx.llm.registerAdapter(['alpha'], new MockAdapter([]))
|
||||
await ctx.plugin(tool, {
|
||||
provider: 'provider-defaults',
|
||||
enableModelSelection: true,
|
||||
maxDepth: 'provider-managed',
|
||||
})
|
||||
const parent = {
|
||||
...fakeAgent('same-route-parent'),
|
||||
options: {
|
||||
const ctx = await setup({
|
||||
provider: 'mock',
|
||||
withModelSelection: true,
|
||||
parentAgentOptions: {
|
||||
provider: 'alpha',
|
||||
model: 'child-model',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
},
|
||||
} as Agent
|
||||
maxDepth: 'provider-managed',
|
||||
}, {
|
||||
agentRouteDefaults: { provider: 'alpha', model: 'child-model' },
|
||||
onStart: (request) => { seen = request },
|
||||
})
|
||||
ctx.llm.registerAdapter(['alpha'], new MockAdapter([]))
|
||||
const parent = modelSelectionSetupAgent(ctx)
|
||||
|
||||
const result = await callSubagent(ctx, {
|
||||
description: 'd',
|
||||
@@ -312,6 +284,7 @@ describe('dsh-tool-subagent', () => {
|
||||
model: 'child-model',
|
||||
}, { agent: parent })
|
||||
|
||||
if (result.isError) throw new Error(text(result))
|
||||
expect(result.isError).toBe(false)
|
||||
expect(seen?.agentOptions).toEqual({ provider: 'alpha', model: 'child-model' })
|
||||
})
|
||||
@@ -964,7 +937,10 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
})
|
||||
|
||||
it('skips background startup when cancellation wins asynchronous route preflight', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock', enableModelSelection: true })
|
||||
const ctx = await backgroundSetup({
|
||||
provider: 'mock',
|
||||
agentOptions: { provider: 'alpha', model: 'selected-model' },
|
||||
})
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
const adapter = new MockAdapter([])
|
||||
let releasePreflight!: () => void
|
||||
@@ -979,8 +955,6 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
const resultPromise = callSubagent(ctx, {
|
||||
description: 'cancelled selection',
|
||||
prompt: 'do it',
|
||||
provider: 'alpha',
|
||||
model: 'selected-model',
|
||||
run_in_background: true,
|
||||
}, { agent: parent, signal: controller.signal })
|
||||
await vi.waitFor(() => { expect(resolveModel).toHaveBeenCalledOnce() })
|
||||
@@ -993,24 +967,15 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
})
|
||||
|
||||
it('rejects startup when the provider changes during asynchronous route preflight', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
const oldStart = vi.fn(async (): Promise<never> => { throw new Error('old provider must not start') })
|
||||
const oldStart = vi.fn()
|
||||
const replacementStart = vi.fn(async (): Promise<never> => { throw new Error('replacement provider must not start') })
|
||||
const disposeOld = ctx.subagents.registerProvider({
|
||||
name: 'swapped',
|
||||
capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
agentRouteDefaults: { provider: 'alpha', model: 'selected-model' },
|
||||
start: oldStart,
|
||||
})
|
||||
await ctx.plugin(tool, {
|
||||
provider: 'swapped',
|
||||
enableModelSelection: true,
|
||||
const ctx = await setup({
|
||||
provider: 'mock',
|
||||
withModelSelection: true,
|
||||
maxDepth: 'provider-managed',
|
||||
}, {
|
||||
agentRouteDefaults: { provider: 'alpha', model: 'selected-model' },
|
||||
onStart: oldStart,
|
||||
})
|
||||
const adapter = new MockAdapter([])
|
||||
let releasePreflight!: () => void
|
||||
@@ -1028,9 +993,9 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
model: 'selected-model',
|
||||
})
|
||||
await vi.waitFor(() => { expect(resolveModel).toHaveBeenCalledOnce() })
|
||||
disposeOld()
|
||||
await disposeSetupProvider(ctx)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'swapped',
|
||||
name: 'mock',
|
||||
capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
agentRouteDefaults: { provider: 'beta', model: 'replacement-model' },
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface StubSettingsScope<T> {
|
||||
scope: SettingsScope<T>
|
||||
/** Spy behind `scope.set`; resolves immediately. */
|
||||
set: ReturnType<typeof vi.fn>
|
||||
/** Spy behind `scope.mutate`; resolves immediately. */
|
||||
mutate: ReturnType<typeof vi.fn>
|
||||
/** Spy behind `scope.unset`; resolves immediately. */
|
||||
unset: ReturnType<typeof vi.fn>
|
||||
/** @returns how many listeners are currently subscribed (disposal assertions). */
|
||||
@@ -34,6 +36,7 @@ export function stubSettingsScope<T>(): StubSettingsScope<T> {
|
||||
}
|
||||
const listeners = new Set<() => void>()
|
||||
const set = vi.fn(() => Promise.resolve())
|
||||
const mutate = vi.fn(() => Promise.resolve())
|
||||
const unset = vi.fn(() => Promise.resolve())
|
||||
return {
|
||||
scope: {
|
||||
@@ -42,10 +45,12 @@ export function stubSettingsScope<T>(): StubSettingsScope<T> {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
mutate,
|
||||
set,
|
||||
unset,
|
||||
},
|
||||
set,
|
||||
mutate,
|
||||
unset,
|
||||
listenerCount: () => listeners.size,
|
||||
publish: (next) => {
|
||||
|
||||
@@ -379,6 +379,10 @@ describe('dsh-tool-workflow', () => {
|
||||
const section = sections.find(s => s.name === 'tool:orchestrate')
|
||||
expect(section?.text).toContain('orchestrate')
|
||||
expect(sections.some(s => s.name === 'tool:workflow')).toBe(false)
|
||||
ctx.systemPrompt.section({ name: 'tool:cordis-order-probe', order: 115.5, text: 'Cordis' })
|
||||
expect((await ctx.systemPrompt.assemble()).sections
|
||||
.filter(s => s.name === 'tool:cordis-order-probe' || s.name === 'tool:orchestrate')
|
||||
.map(s => s.name)).toEqual(['tool:cordis-order-probe', 'tool:orchestrate'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.get('orchestrate')).toBeUndefined()
|
||||
// …and gone with the fiber — a reload must not leak a stale section.
|
||||
|
||||
Generated
+3
@@ -3127,6 +3127,9 @@ importers:
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/remotes
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
|
||||
@@ -240,6 +240,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
AgentFactory: 'core.md',
|
||||
AgentHandle: 'core.md',
|
||||
ModelSelection: 'core.md',
|
||||
AllowedModelRoute: 'subagent.md',
|
||||
SubagentModelSelectionSettings: 'subagent.md',
|
||||
AgentOptions: 'core.md',
|
||||
AgentStatus: 'core.md',
|
||||
ContentBlock: 'llm-streaming.md',
|
||||
|
||||
@@ -114,6 +114,24 @@ describe('virtualManifest', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('selects the requested version when the store retains historical copies', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-notices-version-'))
|
||||
try {
|
||||
const name = '@scope/pkg'
|
||||
const store = join(root, 'store')
|
||||
for (const version of ['1.0.0', '2.0.0']) {
|
||||
const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name)
|
||||
mkdirSync(manifestDir, { recursive: true })
|
||||
writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' }))
|
||||
}
|
||||
|
||||
expect(virtualManifest(store, name, '2.0.0')).toMatchObject({ name, version: '2.0.0' })
|
||||
expect(virtualManifest(store, name, '3.0.0')).toBeUndefined()
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined when neither the prefix nor the content scan finds the package', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-'))
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
|
||||
import parseSpdx from 'spdx-expression-parse'
|
||||
@@ -248,38 +248,72 @@ export function claudeDistributionFromManifest(
|
||||
*
|
||||
* @param virtual - the `.pnpm` virtual store directory to scan.
|
||||
* @param name - the external package name, exactly as `node_modules` spells it.
|
||||
* @param expectedVersion - exact version required when the store retains more than one.
|
||||
* @returns the parsed manifest, or `undefined` when neither the prefix match
|
||||
* nor the content scan finds the package's `package.json`.
|
||||
* nor the content scan finds the requested package version.
|
||||
*/
|
||||
export function virtualManifest(virtual: string, name: string): VirtualManifest | undefined {
|
||||
export function virtualManifest(
|
||||
virtual: string,
|
||||
name: string,
|
||||
expectedVersion?: string,
|
||||
): VirtualManifest | undefined {
|
||||
const prefix = `${name.replace('/', '+')}@`
|
||||
const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix))
|
||||
if (entry !== undefined) {
|
||||
return JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest
|
||||
const entries = readdirSync(virtual)
|
||||
for (const entry of entries.filter(dir => dir.startsWith(prefix))) {
|
||||
const manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest
|
||||
if (expectedVersion === undefined || manifest.version === expectedVersion) return manifest
|
||||
}
|
||||
for (const dir of readdirSync(virtual)) {
|
||||
for (const dir of entries) {
|
||||
const candidate = resolve(virtual, dir, 'node_modules', name, 'package.json')
|
||||
if (existsSync(candidate)) {
|
||||
return JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest
|
||||
const manifest = JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest
|
||||
if (expectedVersion === undefined || manifest.version === expectedVersion) return manifest
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const workspaceLinkedManifestCache = new Map<string, VirtualManifest | undefined>()
|
||||
|
||||
/**
|
||||
* Resolve the package version selected for a declaring workspace instead of an
|
||||
* unrelated historical version that still occupies the shared virtual store.
|
||||
* @param name - external package identity.
|
||||
* @returns the first current workspace link for that package, when installed.
|
||||
*/
|
||||
function workspaceLinkedManifest(name: string): VirtualManifest | undefined {
|
||||
if (workspaceLinkedManifestCache.has(name)) return workspaceLinkedManifestCache.get(name)
|
||||
for (const [path, manifest] of loadWorkspaceManifests().manifests) {
|
||||
if (!ALL_KINDS.some(kind => name in (manifest[kind] ?? {}))) continue
|
||||
const linked = resolve(root, dirname(path), 'node_modules', name, 'package.json')
|
||||
if (!existsSync(linked)) continue
|
||||
const found = JSON.parse(readFileSync(linked, 'utf8')) as VirtualManifest
|
||||
workspaceLinkedManifestCache.set(name, found)
|
||||
return found
|
||||
}
|
||||
workspaceLinkedManifestCache.set(name, undefined)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Resolve one installed external package manifest from either pnpm store. */
|
||||
function installedManifest(name: string): VirtualManifest | undefined {
|
||||
function installedManifest(name: string, expectedVersion?: string): VirtualManifest | undefined {
|
||||
const linked = workspaceLinkedManifest(name)
|
||||
if (linked !== undefined && (expectedVersion === undefined || linked.version === expectedVersion)) return linked
|
||||
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
|
||||
// Workspace-local link farms can expose a dependency that is not linked at
|
||||
// the repository root; both are backed by the root workspace's lockfile.
|
||||
for (const store of ['node_modules', 'native/landlock-run/node_modules']) {
|
||||
const direct = resolve(root, store, name, 'package.json')
|
||||
if (existsSync(direct)) {
|
||||
manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest
|
||||
break
|
||||
const candidate = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest
|
||||
if (expectedVersion === undefined || candidate?.version === expectedVersion) {
|
||||
manifest = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
const virtual = resolve(root, store, '.pnpm')
|
||||
if (!existsSync(virtual)) continue
|
||||
manifest = virtualManifest(virtual, name)
|
||||
manifest = virtualManifest(virtual, name, expectedVersion)
|
||||
if (manifest !== undefined) break
|
||||
}
|
||||
return manifest
|
||||
@@ -308,7 +342,7 @@ function collectClaudeDistribution(): ClaudeDistribution {
|
||||
const distribution = claudeDistributionFromManifest(manifest)
|
||||
let installedPayloads = 0
|
||||
for (const payload of distribution.payloads) {
|
||||
const installed = installedManifest(payload.name)
|
||||
const installed = installedManifest(payload.name, payload.version)
|
||||
if (installed === undefined) continue
|
||||
installedPayloads += 1
|
||||
if (
|
||||
|
||||
@@ -63,6 +63,7 @@ import type TeamService from '@deepseek-ai/dsh-experimental-agent-team'
|
||||
import * as ToolTeam from '@deepseek-ai/dsh-experimental-tool-agent-team'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
import { registerListSubagentModels } from '../packages/subagent/tool-subagent/src/list-models.ts'
|
||||
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread'
|
||||
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
|
||||
@@ -467,10 +468,11 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
registerCatalogSubagentProvider(ctx, 'mock')
|
||||
await ctx.plugin(ToolSubagent, { provider: 'mock', enableModelSelection: true })
|
||||
await ctx.plugin(ToolSubagent, { provider: 'mock' })
|
||||
registerListSubagentModels(ctx, { routes: [{ provider: 'mock', model: 'mock' }] })
|
||||
},
|
||||
note:
|
||||
'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.',
|
||||
'The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
enableModelSelection: true
|
||||
backgroundMode: continuable
|
||||
maxDepth: 1
|
||||
|
||||
|
||||
@@ -260,23 +260,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_subagent_models",
|
||||
"description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Registered LLM provider id. Omit to list providers."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
@@ -477,7 +460,7 @@
|
||||
},
|
||||
{
|
||||
"name": "subagent",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -489,18 +472,6 @@
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."
|
||||
|
||||
@@ -260,23 +260,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_subagent_models",
|
||||
"description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Registered LLM provider id. Omit to list providers."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
@@ -477,7 +460,7 @@
|
||||
},
|
||||
{
|
||||
"name": "subagent",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -489,18 +472,6 @@
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."
|
||||
|
||||
@@ -260,23 +260,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_subagent_models",
|
||||
"description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Registered LLM provider id. Omit to list providers."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
@@ -461,7 +444,7 @@
|
||||
},
|
||||
{
|
||||
"name": "subagent",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -473,18 +456,6 @@
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."
|
||||
|
||||
@@ -35,12 +35,19 @@
|
||||
env:
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
|
||||
- id: subagent-model-selection-settings
|
||||
name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
|
||||
config:
|
||||
enabled: true
|
||||
allowedModels:
|
||||
- provider: mock
|
||||
model: mock-routed
|
||||
|
||||
- id: tool-subagent-dsh-sdk
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
name: '../../../packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts'
|
||||
config:
|
||||
provider: dsh-sdk
|
||||
toolName: subagent
|
||||
enableModelSelection: true
|
||||
enableRunInBackground: false
|
||||
agentOptions:
|
||||
maxTokens: 777
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
|
||||
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"Delegate once using the requested","messageSeqs":[7],"source":{"kind":"fallback"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"mock-delegate"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-delegate","name":"subagent","argumentsDelta":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":18,"time":0,"data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":19,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":2}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":5,"time":0,"data":{"turn":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":6,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":7,"time":0,"data":{"turn":1,"step":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":10,"time":0,"data":{"title":"Delegate once using the requested","messageSeqs":[8],"source":{"kind":"fallback"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":11,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":12,"time":0,"data":{"provider":"deepseek-official","model":"mock-delegate"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-delegate","name":"subagent","argumentsDelta":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":19,"time":0,"data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":20,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":1}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}}}
|
||||
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}}
|
||||
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
{"type":"permission/preset","data":{"preset":"workspace-write"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
|
||||
{"type":"approval/policy","data":{"policy":"ask"}}
|
||||
{"type":"subagent/model-selection-policy","data":{"allowedModels":[{"provider":"mock","model":"mock-routed"}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","data":{"title":"Delegate once using the requested","messageSeqs":[7],"source":{"kind":"fallback"}}}
|
||||
{"type":"session/title","data":{"title":"Delegate once using the requested","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"mock-delegate"}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
@@ -16,9 +17,9 @@
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
@@ -26,6 +27,6 @@
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
|
||||
@@ -260,23 +260,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_subagent_models",
|
||||
"description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Registered LLM provider id. Omit to list providers."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
@@ -461,7 +444,7 @@
|
||||
},
|
||||
{
|
||||
"name": "subagent",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -473,18 +456,6 @@
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."
|
||||
|
||||
@@ -260,23 +260,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_subagent_models",
|
||||
"description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Registered LLM provider id. Omit to list providers."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
@@ -477,7 +460,7 @@
|
||||
},
|
||||
{
|
||||
"name": "subagent",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -489,18 +472,6 @@
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user