dsh-team-model-policy 0.0.0-stage → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +190 -2
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/authorization.mjs +53 -0
- package/choices.mjs +290 -0
- package/cordis.patch.yml +10 -0
- package/index.mjs +269 -0
- package/lib/client.js +2250 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/model-tools.mjs +117 -0
- package/package.json +79 -4
- package/retirements.mjs +498 -0
- package/settings.mjs +17 -0
- package/src/client/build.cjs +98 -0
- package/src/client/card.cjs +448 -0
- package/src/client/index.cjs +77 -0
- package/src/client/locale.cjs +215 -0
- package/src/client/retirement-view.cjs +16 -0
- package/src/client/styles.cjs +41 -0
- package/src/client/subagent-filter.cjs +69 -0
- package/src/client/subagent-locale.cjs +141 -0
- package/src/client/subagent-view.module.css +292 -0
- package/src/client/subagent-view.tsx +331 -0
- package/src/client/team-locale.cjs +121 -0
- package/src/client/team-view.module.css +372 -0
- package/src/client/team-view.tsx +409 -0
- package/src/client/test/renderer.cjs +253 -0
- package/src/client/test/runtime.cjs +134 -0
- package/src/client/test/ui.test.cjs +467 -0
- package/src/client/view.cjs +87 -0
- package/task-actions.mjs +38 -0
- package/team-capacity.mjs +68 -0
- package/teammate-access.mjs +86 -0
- package/ui.mjs +5 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kogekiplay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,191 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 团队模型策略
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
这是独立的 DeepSeek Harness 插件,提供 **团队模型设置、成员权限跟随、子智能体清理和已完成任务移除**。它是社区插件,采用 MIT 许可证。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
本版仅支持 **DSH 0.2.2-rc.1**。先升级 DSH,再在“插件”页面通过以下公开地址安装:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
https://github.com/kogekiplay/dsh-team-model-policy/archive/refs/tags/v0.10.2.tar.gz
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
安装包已包含构建好的界面资源,不需要安装脚本或本地编译。升级后重新打开插件详情;如果已打开的界面仍显示旧内容,刷新当前 DSH 窗口即可。
|
|
14
|
+
|
|
15
|
+
## 原生检查更新
|
|
16
|
+
|
|
17
|
+
DSH 的原生“检查更新”只适用于**通过 npm 注册表、以插件自身名称安装的第三方包**。从 GitHub 归档、Git 地址或本地目录安装时,不显示该更新区域;设置 `repository` 或发布 Git 标签不会自动启用它。
|
|
18
|
+
|
|
19
|
+
本插件已准备公开 npm 发布配置。首次发布并在 DSH 中把安装来源切换为 `dsh-team-model-policy@版本号` 后,后续版本会出现在插件详情页的 **版本 → 检查更新** 中。请以 npm 注册表实际可查询到的版本为准;仅有 Git 标签不代表 npm 已发布。
|
|
20
|
+
|
|
21
|
+
原生更新先检查目标版本与当前 DSH 的兼容性,不兼容的版本会被标出。插件不会自行轮询、自动下载安装或绕过 DSH 的版本检查。
|
|
22
|
+
|
|
23
|
+
## 配置团队模型
|
|
24
|
+
|
|
25
|
+
打开 **设置 → 团队模型**,选择模式后保存:
|
|
26
|
+
|
|
27
|
+
- **跟随子智能体模型设置**:Lead 为每名队友选择已授权的模型。子智能体设置中最新保存的勾选列表立即生效,已有团队无需重开主会话。未指定推理强度时使用保存的默认值,初始为 **最高(max)**;模型不支持该强度时明确报错。
|
|
28
|
+
- **固定模型**:所有命名队友统一使用所选提供方、模型和推理强度。强度留空时使用该模型自身的默认值;此模式下不能逐个队友切换模型。
|
|
29
|
+
|
|
30
|
+
跟随模式的默认推理强度可选 **最高(max)、高(high)、中(medium)、低(low)、关闭思考(off)**。保存后,在 Lead 没有指定强度时使用该值;显式选择 `off` 会保持关闭思考。
|
|
31
|
+
|
|
32
|
+
模型或推理强度变更从下一步模型请求生效,已准备或发出的请求继续使用原选择。Lead 自身的模型不会被团队模型设置改变。页面使用 DSH 的统一保存表单,支持窄窗口和浅色、深色主题。
|
|
33
|
+
|
|
34
|
+
取消某条“提供方 + 模型”授权后,该组合不能再被选择或用于后续请求;其他提供方中的同名模型不受影响。请先在“子智能体”设置中保存授权,再让 Lead 重新获取可用模型。
|
|
35
|
+
|
|
36
|
+
如果“子智能体”页面只有运行限制、没有模型授权区,请检查官方模型授权组件是否加载。升级 DSH 后,profile 中残留的旧版官方扩展可能优先于应用内新版被解析,并因版本不兼容而停用该组件。应通过插件管理器更新对应扩展及依赖,再按管理器要求重启 DSH;不要清空已保存的模型勾选或授予版本豁免。`list_teammate_models` 会区分“授权组件未加载”和“用户关闭模型选择”,组件恢复后重新读取当前已保存授权。
|
|
37
|
+
|
|
38
|
+
## 主会话可用工具(Lead)
|
|
39
|
+
|
|
40
|
+
| 工具 | 用途 |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `list_teammate_models` | 获取当前授权的提供方和模型;指定提供方及准确模型后,可查询支持的推理强度。 |
|
|
43
|
+
| `create_teammate` | 创建队友,并指定提供方、模型和可选的 `reasoning_effort`。创建前会检查授权和模型支持情况。 |
|
|
44
|
+
| `set_teammate_model` | 修改既有队友下一步使用的模型和强度,不唤醒队友,也不中断当前请求。 |
|
|
45
|
+
| `remove_teammate` | 移除命名队友,停止其工作和后台作业,回收未完成任务,并保留历史。仅 Lead 可执行。 |
|
|
46
|
+
| `list_manageable_subagents` | 查看可手动清理的普通子智能体、团队成员及其后代,返回准确的会话 ID、父会话、类型和活动状态。 |
|
|
47
|
+
| `remove_subagent` | 按会话 ID 移除子智能体或子树。目标是团队成员时,自动使用带任务回收的退队流程。 |
|
|
48
|
+
|
|
49
|
+
原生 `spawn_teammate` 仍可用。跟随模式中,如果没有保存独立模型选择,则保留原生继承方式;需要指定不同模型时使用 `create_teammate`。新上下文与继承上下文两种创建方式,仍由 DSH 的团队和子会话服务管理。
|
|
50
|
+
|
|
51
|
+
例如,可以让 Lead:“重新获取最新可用模型,把 reviewer 切换到某提供方的指定模型,推理强度设为 max。”Lead 应先获取最新授权列表,再执行切换。
|
|
52
|
+
|
|
53
|
+
每名队友的模型选择按“团队根会话 + 队友名称”独立保存,重启后仍可恢复。成员列表报告成员自身实际发出的模型请求,不把继承上下文中的父会话请求误认为成员请求;尚未发出请求或模型选择待生效时,会提供说明。
|
|
54
|
+
|
|
55
|
+
## 团队工具与权限
|
|
56
|
+
|
|
57
|
+
命名的 **团队成员**通过原生创建流程继承 Lead 的能力组合。普通模式下直接使用当前工具列表中的内置工具;PTC 模式下通过 `run_code` 内的 `tools.*` 调用。程序内的工具声明不是独立的模型工具名称。
|
|
58
|
+
|
|
59
|
+
团队成员的**常驻沙箱模式和审批策略跟随当前 Lead**。创建、恢复、模型步骤和工具执行时会校验并同步,Lead 权限变化也会通知在线成员:
|
|
60
|
+
|
|
61
|
+
- Lead 放宽权限后,已有成员无需重新创建。
|
|
62
|
+
- Lead 收紧为只读后,后续写入会被拦截。
|
|
63
|
+
- 请在 Lead 会话中调整共享权限;队员单独调整的值会在同步时恢复为 Lead 的值。
|
|
64
|
+
|
|
65
|
+
同步只复制 Lead 的常驻授权,不复制单次审批产生的临时授权,不扩大工作区边界,也不把队员冒充为 Lead。需要直接询问用户、管理目标或具备 Lead 身份的操作仍交给 Lead。普通 `subagent`、`subagent_fork` 保持其创建时的独立授权快照。
|
|
66
|
+
|
|
67
|
+
权限变化会追加到成员自己的原生会话日志中,便于重启恢复和追溯。禁用插件后不再持续同步,日志中最后生效的权限值保留。
|
|
68
|
+
|
|
69
|
+
## 成员名额复用
|
|
70
|
+
|
|
71
|
+
插件启用时,`maxMembers` 表示**尚未成功移除的成员数上限**。例如上限为 8,创建 8 个成员并全部成功移除后,可以再创建 8 个新成员。Lead 不计入该上限。
|
|
72
|
+
|
|
73
|
+
只有完整退队且没有清理错误的记录才释放名额。未运行、准备中、创建失败但尚未手动移除、移除中或移除失败的成员仍占名额。
|
|
74
|
+
|
|
75
|
+
`create_teammate` 和原生 `spawn_teammate` 使用同一套名额处理,已有会话和重启后的会话都能复用名额。并发创建仍受原生事务检查,一个空位最多只能有一个请求成功。
|
|
76
|
+
|
|
77
|
+
**历史会话和旧名字永久保留,不能重复使用已经用过的队友名称。** 插件不会通过删除名册、修改原生成员状态或改写会话文件释放名额。
|
|
78
|
+
|
|
79
|
+
DSH 0.2.2-rc.1 原生按历史成员总数限制创建,尚无原生退队计数接口。本插件在每次创建事务中按成功退队记录抵扣历史占位,保持共享配置不变。禁用插件后恢复原生历史总数限制。
|
|
80
|
+
|
|
81
|
+
## 移除团队成员
|
|
82
|
+
|
|
83
|
+
打开 **智能体团队 → 成员**,点击对应队友的 **移除**,阅读确认说明后执行。也可以让 Lead 调用 `remove_teammate`,或在 Lead 会话中直接输入:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
/team-remove teammate-name
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
退队会停止成员及其后台工作,等待活动结束,归档子会话,并将其未完成共享任务回收为“待处理、未分配”。成功后,该成员从团队面板和 `list_agents` 中消失,后续消息和恢复执行会被拒绝。
|
|
90
|
+
|
|
91
|
+
Lead 不能移除自己。清理未完成时,成员保持阻止新工作并显示可重试错误,不会报告虚假的成功。历史保留,退队名称不能复用。
|
|
92
|
+
|
|
93
|
+
## 移除已完成任务
|
|
94
|
+
|
|
95
|
+
打开 **智能体团队 → 共享任务**,已完成的任务卡片显示 **移除**。单击立即执行,**不弹出二次确认**;处理中会禁用按钮,防止重复提交。
|
|
96
|
+
|
|
97
|
+
移除后任务从列表和计数中消失,刷新或重启后保持,任务历史仍然保留。待处理、进行中的任务不显示移除按钮。
|
|
98
|
+
|
|
99
|
+
后台会再次检查任务状态和所点击的任务版本。如果任务已被重新打开或发生变化,会保留任务并提示重试。任务仍被其他任务依赖时,也会显示原因;请先移除已完成的后续任务或调整依赖,插件不会自动修改依赖图。
|
|
100
|
+
|
|
101
|
+
界面通过以下命令执行相同的原生任务操作;Lead 也可使用原生 `team_task_update` 的 `delete` 动作:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
/team-task-remove task-id revision
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## 清理普通子智能体
|
|
108
|
+
|
|
109
|
+
**普通子智能体不一定是团队成员**,因此子智能体列表可能比团队名册更长。本插件分别处理这两类对象:
|
|
110
|
+
|
|
111
|
+
- 在标题栏的 **子智能体** 下拉列表中,点击某行的 **移除**,确认后执行。嵌套子会话保留展开、在主区域打开和在侧栏打开的功能。
|
|
112
|
+
- 普通本地子智能体:停止所选子树、等待后台工作结束、归档子会话,并从插件列表和计数中过滤;刷新和重启后保持。原生日志与目录记录保留,不物理删除。
|
|
113
|
+
- 团队成员:转入已有退队流程,额外回收未完成共享任务,不绕过任务回收。
|
|
114
|
+
- 外部执行没有可归档的本地会话;未知或损坏目录也无法验证归属,因此这些对象暂不提供移除按钮。
|
|
115
|
+
|
|
116
|
+
用户可在拥有该子智能体的父会话中输入:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
/subagent-remove session-id
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Lead 或父智能体也可先用 `list_manageable_subagents` 获取准确 ID,再调用 `remove_subagent`。不能移除当前会话、祖先或无关会话树中的智能体。
|
|
123
|
+
|
|
124
|
+
清理完全由用户或 Lead 手动触发。**未运行不代表任务已经完成,也不代表该智能体没有用处**,插件不会按空闲状态自动移除。失败操作显示可重试错误,只有清理成功才从列表消失。
|
|
125
|
+
|
|
126
|
+
## 支持的 DSH 版本
|
|
127
|
+
|
|
128
|
+
**本版仅支持 DSH 0.2.2-rc.1。** 插件随 DSH 更新,每次针对新的目标运行时验证和发布,不保留旧运行时的兼容范围。请先升级 DSH,再安装对应插件版本。
|
|
129
|
+
|
|
130
|
+
v0.10 系列已适配新的模型选择接口、会话投影句柄和模型目录刷新事件。旧会话经过官方 V3→V4 转换后,团队退队和普通子智能体清理记录仍会被识别,避免已移除对象重新出现;旧投影缓存会按版本失效并从历史重建。
|
|
131
|
+
|
|
132
|
+
模型选择在步骤准备前通过原生 `agent.model.select()` 记录。已经为某一步捕获的选择保持不变,稍后的更新用于下一步,不再通过旧的 `agent/request` 钩子修改模型。
|
|
133
|
+
|
|
134
|
+
## 禁用与重启
|
|
135
|
+
|
|
136
|
+
禁用插件前会等待插件已接收的操作收敛,然后释放相关监听、工具和界面入口。已移除的子会话仍保持归档,但原生历史名册和子智能体目录会重新显示;再次启用插件后恢复过滤。
|
|
137
|
+
|
|
138
|
+
成员最后记录的模型和权限可能保留在会话日志中。禁用后如需切换,请通过模型选择器或新的明确模型选择操作完成。历史请求、成员模型选择和任务日志不会被删除。
|
|
139
|
+
|
|
140
|
+
## 部署配置
|
|
141
|
+
|
|
142
|
+
通常直接使用设置页即可。需要在部署配置中覆盖时,可使用以下字段;界面写入同一设置项:
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
- id: team-model-policy
|
|
146
|
+
config:
|
|
147
|
+
mode: follow-subagent
|
|
148
|
+
defaultReasoningEffort: max
|
|
149
|
+
provider: ''
|
|
150
|
+
model: ''
|
|
151
|
+
reasoningEffort: ''
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
模型策略、退队服务和设置界面是三个独立组件。保存模型设置不会重新挂载整个设置窗口。跟随模式读取最新子智能体授权列表;普通子智能体工具仍遵循其原会话授权规则,不与团队模型目录混用。
|
|
155
|
+
|
|
156
|
+
退队服务的等待时间可通过 `team-member-retirements` 下的 `jobStopTimeoutMs` 调整,默认 30000 毫秒。超时不会假装完成清理,用户或 Lead 可以重试。
|
|
157
|
+
|
|
158
|
+
## 验证与开发
|
|
159
|
+
|
|
160
|
+
v0.10.0 适配已通过 **74 项单元/组件测试和 28 个目标运行时场景**,覆盖模型选择、动态授权、切换、普通与 PTC 权限同步、任务移除、名额复用、子智能体清理和冷恢复。v0.10.1 补齐中文说明。v0.10.2 区分授权组件未加载与用户关闭模型选择,并补齐 npm 公开发布配置;本次通过 19 项针对性单元测试和 12 个目标运行时场景,客户端构建产物保持一致。
|
|
161
|
+
|
|
162
|
+
测试使用隔离环境和专用团队。只有模型输出采用脚本,其余 Loader、工具、团队生命周期和持久化使用真实目标实现。迁移测试使用官方 V3→V4 转换器,并检查旧缓存重建和已移除对象的恢复阻止。桌面截图与实际点击验证会单独记录,不与自动测试混为一谈。
|
|
163
|
+
|
|
164
|
+
在源码目录可按改动范围运行:
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
node src/client/build.cjs --check
|
|
168
|
+
ELECTRON_RUN_AS_NODE=1 '/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness' --test tests/*.test.mjs
|
|
169
|
+
node tests/run-integration.mjs
|
|
170
|
+
node tests/run-subagent-integration.mjs
|
|
171
|
+
node tests/run-team-access.mjs --verify
|
|
172
|
+
node tests/run-team-access.mjs --ptc --verify
|
|
173
|
+
node tests/run-team-access.mjs --race --verify
|
|
174
|
+
node tests/run-team-access.mjs --race --ptc --verify
|
|
175
|
+
node tests/run-task-removal.mjs
|
|
176
|
+
node tests/run-team-capacity.mjs
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`DSH_TEST_CLI` 可指定 JSON 格式的启动命令数组,`DSH_TEST_INSTALL_ANCHOR` 可指定另一套目标安装包,`DSH_TEST_ZSTD` 可指定日志解码器。修改界面源码后须重新构建 `lib/client.js`。安装包包含生成文件,用户无需安装 `esbuild` 等开发依赖。
|
|
180
|
+
|
|
181
|
+
私有验收证据位于 `.evidence/`,不纳入 Git 或公开安装包。插件不包含 API 密钥、遥测、新的 HTTP 端点或替代服务器。
|
|
182
|
+
|
|
183
|
+
## 模型上下文
|
|
184
|
+
|
|
185
|
+
插件增加六个模型可见工具和两段简短策略说明,并通过运行时上下文解释团队权限跟随和 PTC 调用方式。Lead 专用工具从队员的全局工具列表中隐藏,原生角色校验继续生效。
|
|
186
|
+
|
|
187
|
+
成员模型选择和权限变化通过原生日志追溯,实际模型请求记录在 `request/header` 中。插件不会把成员状态作为未记录的隐式提示注入模型。
|
|
188
|
+
|
|
189
|
+
## 许可证
|
|
190
|
+
|
|
191
|
+
采用 MIT 许可证。改编自 DSH 的界面部分保留原作者声明,见随包发布的 `THIRD_PARTY_NOTICES.md`。
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
The Team popup component and styles are adapted from [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), packages/experimental/client-ui-agent-team. The subagent catalog preserves navigation and metric conventions from packages/client/ui-subagent.
|
|
4
|
+
|
|
5
|
+
MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 DeepSeek
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { normalizedRoute } from './choices.mjs';
|
|
2
|
+
|
|
3
|
+
/** Current, saved Subagent settings. Session snapshots remain historical data. */
|
|
4
|
+
export function currentAuthorization(ctx) {
|
|
5
|
+
const owner = ctx.get('subagentModelSelection');
|
|
6
|
+
if (typeof owner?.current !== 'function') return { available: false, enabled: false, routes: [] };
|
|
7
|
+
const preference = owner.current();
|
|
8
|
+
if (!preference.enabled) return { available: true, enabled: false, routes: [] };
|
|
9
|
+
return { available: true, enabled: true, routes: preference.allowedModels.map(route => ({ provider: route.provider, model: route.model })) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Validate an explicit or inherited teammate route against the latest user selection. */
|
|
13
|
+
export function authorizeCurrent(ctx, route) {
|
|
14
|
+
const choice = normalizedRoute(route);
|
|
15
|
+
const policy = currentAuthorization(ctx);
|
|
16
|
+
if (!policy.available) throw Object.assign(new Error('The Subagent model-selection settings service is not loaded. Enable the DSH Subagent settings component before choosing teammate models.'), { code: 'TEAM_MODEL_SELECTION_UNAVAILABLE' });
|
|
17
|
+
if (!policy.enabled) throw Object.assign(new Error('Enable model selection in Subagent settings before choosing teammate models.'), { code: 'TEAM_MODEL_SELECTION_DISABLED' });
|
|
18
|
+
if (!policy.routes.some(candidate => candidate.provider === choice.provider && candidate.model === choice.model)) {
|
|
19
|
+
throw Object.assign(new Error('Route "' + choice.provider + '/' + choice.model + '" is not currently authorized. Refresh list_teammate_models after saving Subagent settings.'), { code: 'TEAM_MODEL_ROUTE_NOT_ALLOWED' });
|
|
20
|
+
}
|
|
21
|
+
return choice;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Discover currently registered, allowed teammate models and their reasoning efforts. */
|
|
25
|
+
export async function listCurrentModels(ctx, request, signal) {
|
|
26
|
+
if (request.model !== undefined && request.provider === undefined) throw new Error('model requires provider');
|
|
27
|
+
const policy = currentAuthorization(ctx);
|
|
28
|
+
const providers = ctx.llm.listProviders().filter(provider => policy.routes.some(route => route.provider === provider.id));
|
|
29
|
+
const diagnostic = !policy.available ? { code: 'TEAM_MODEL_SELECTION_UNAVAILABLE', message: '子智能体模型授权组件未加载。请检查官方子智能体扩展及依赖版本,启用对应设置组件;已保存的模型勾选不会被清空。' }
|
|
30
|
+
: !policy.enabled ? { code: 'TEAM_MODEL_SELECTION_DISABLED', message: '模型选择开关已关闭。请在子智能体设置中启用并保存授权模型。' } : undefined;
|
|
31
|
+
if (request.provider === undefined) return { source: 'live-subagent-settings', available: policy.available, enabled: policy.enabled,
|
|
32
|
+
...(diagnostic === undefined ? {} : { diagnostic }), providers: providers.map(provider => ({ id: provider.id, name: provider.name })), routes: policy.routes };
|
|
33
|
+
if (!policy.available) throw Object.assign(new Error(diagnostic.message), { code: diagnostic.code });
|
|
34
|
+
const routes = policy.routes.filter(route => route.provider === request.provider);
|
|
35
|
+
if (!policy.enabled || routes.length === 0) throw Object.assign(new Error('This provider is not currently authorized in Subagent settings.'), { code: 'TEAM_MODEL_ROUTE_NOT_ALLOWED' });
|
|
36
|
+
const provider = providers.find(candidate => candidate.id === request.provider);
|
|
37
|
+
if (provider === undefined) throw new Error('The selected provider is not registered: ' + request.provider);
|
|
38
|
+
if (request.model === undefined) {
|
|
39
|
+
const models = await ctx.llm.listModels(provider.id);
|
|
40
|
+
signal.throwIfAborted();
|
|
41
|
+
const allowed = currentAuthorization(ctx);
|
|
42
|
+
return { source: 'live-subagent-settings', provider: provider.id,
|
|
43
|
+
models: models.filter(model => allowed.enabled && allowed.routes.some(route => route.provider === provider.id && route.model === model.id))
|
|
44
|
+
.map(model => ({ id: model.id, name: model.name, ...(model.description === undefined ? {} : { description: model.description }) })) };
|
|
45
|
+
}
|
|
46
|
+
authorizeCurrent(ctx, { provider: provider.id, model: request.model });
|
|
47
|
+
const model = await ctx.llm.resolveModelInfo(provider.id, request.model, signal);
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
authorizeCurrent(ctx, { provider: provider.id, model: request.model });
|
|
50
|
+
return { source: 'live-subagent-settings', provider: provider.id, model: model.id, name: model.name,
|
|
51
|
+
reasoningEfforts: model.reasoning?.efforts?.map(effort => ({ id: effort.id, name: effort.name })) ?? [],
|
|
52
|
+
...(model.reasoning?.defaultEffort === undefined ? {} : { defaultReasoningEffort: model.reasoning.defaultEffort }) };
|
|
53
|
+
}
|
package/choices.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-teammate LLM route choices for the public dsh-team-model-policy plugin.
|
|
3
|
+
*
|
|
4
|
+
* Choices are plugin-owned host state, never Session history: they live in the
|
|
5
|
+
* public `ctx.storageDomain` KV form under one domain, keyed by the exact Team
|
|
6
|
+
* root id and model-facing teammate name. Nothing here writes `model/selection`,
|
|
7
|
+
* `request/header`, `team/member`, or any native Session file.
|
|
8
|
+
*
|
|
9
|
+
* DSH packages are resolved through the installation manifest the host exposes
|
|
10
|
+
* as `ctx.profileContext.installAnchor`, so a profile-local plugin reuses the
|
|
11
|
+
* runtime's own copies instead of adding duplicate core peers.
|
|
12
|
+
*/
|
|
13
|
+
import { createRequire } from 'node:module'
|
|
14
|
+
import { pathToFileURL } from 'node:url'
|
|
15
|
+
|
|
16
|
+
/** Stable failure codes; callers branch on `error.code`. */
|
|
17
|
+
export const ChoicesErrorCode = Object.freeze({
|
|
18
|
+
INVALID_ROUTE: 'CHOICES_INVALID_ROUTE',
|
|
19
|
+
NO_POLICY: 'CHOICES_NO_POLICY',
|
|
20
|
+
ROUTE_NOT_ALLOWED: 'CHOICES_ROUTE_NOT_ALLOWED',
|
|
21
|
+
CLOSED: 'CHOICES_CLOSED',
|
|
22
|
+
UNSUPPORTED_HOST: 'CHOICES_UNSUPPORTED_HOST',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/** Typed refusal of a choice read, write, or authorization attempt. */
|
|
26
|
+
export class ChoicesError extends Error {
|
|
27
|
+
/**
|
|
28
|
+
* @param message - actionable detail.
|
|
29
|
+
* @param code - one of {@link ChoicesErrorCode}.
|
|
30
|
+
*/
|
|
31
|
+
constructor(message, code) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'ChoicesError'
|
|
34
|
+
this.code = code
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Domain name owned by this plugin; also the storage medium's unit name. */
|
|
39
|
+
export const CHOICES_DOMAIN = 'team_model_choices'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load the host's own storage-domain and zod packages.
|
|
43
|
+
*
|
|
44
|
+
* Resolution starts at the installation manifest because a profile-installed
|
|
45
|
+
* plugin has no direct dependency on the harness core, while the running host
|
|
46
|
+
* has already installed exactly one compatible copy of each package.
|
|
47
|
+
* @param anchor - absolute installation `package.json` path from `ctx.profileContext`.
|
|
48
|
+
* @returns the public domain vocabulary and record schema builder.
|
|
49
|
+
*/
|
|
50
|
+
export async function loadHostSchema(anchor) {
|
|
51
|
+
if (typeof anchor !== 'string' || anchor.length === 0) {
|
|
52
|
+
throw new ChoicesError('The host exposed no installation anchor for teammate choices.', ChoicesErrorCode.UNSUPPORTED_HOST)
|
|
53
|
+
}
|
|
54
|
+
const require = createRequire(anchor)
|
|
55
|
+
const { defineDomain, domainTable } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-storage-domain')).href)
|
|
56
|
+
const { z } = await import(pathToFileURL(require.resolve('zod')).href)
|
|
57
|
+
const record = z.object({
|
|
58
|
+
teamId: z.string().min(1),
|
|
59
|
+
name: z.string().min(1),
|
|
60
|
+
provider: z.string().min(1),
|
|
61
|
+
model: z.string().min(1),
|
|
62
|
+
reasoningEffort: z.string().min(1).optional(),
|
|
63
|
+
}).strict()
|
|
64
|
+
return { defineDomain, domainTable, record }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build this plugin's storage domain from the host's schema builder.
|
|
69
|
+
* @param schema - result of {@link loadHostSchema}.
|
|
70
|
+
* @returns the declared domain spec.
|
|
71
|
+
*/
|
|
72
|
+
export function choicesDomainOf(schema) {
|
|
73
|
+
return schema.defineDomain({
|
|
74
|
+
name: CHOICES_DOMAIN,
|
|
75
|
+
version: 1,
|
|
76
|
+
tables: { choices: schema.domainTable(schema.record) },
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Composite durable key; the separator cannot occur in a Session id or teammate name. */
|
|
81
|
+
function choiceKey(teamId, name) {
|
|
82
|
+
return teamId + '\u0000' + name
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Snapshot one record as detached data, preserving an absent effort as absent. */
|
|
86
|
+
function detach(record) {
|
|
87
|
+
if (record === undefined) return undefined
|
|
88
|
+
return {
|
|
89
|
+
provider: record.provider,
|
|
90
|
+
model: record.model,
|
|
91
|
+
...record.reasoningEffort === undefined ? {} : { reasoningEffort: record.reasoningEffort },
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Reject a malformed route at its JSON boundary before any state changes. */
|
|
96
|
+
export function normalizedRoute(route) {
|
|
97
|
+
if (route === null || typeof route !== 'object' || Array.isArray(route)) {
|
|
98
|
+
throw new ChoicesError('A teammate route must be an object with provider and model.', ChoicesErrorCode.INVALID_ROUTE)
|
|
99
|
+
}
|
|
100
|
+
for (const key of Object.keys(route)) {
|
|
101
|
+
if (key !== 'provider' && key !== 'model' && key !== 'reasoningEffort') {
|
|
102
|
+
throw new ChoicesError('A teammate route accepts only provider, model, and reasoningEffort.', ChoicesErrorCode.INVALID_ROUTE)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const { provider, model, reasoningEffort } = route
|
|
106
|
+
if (typeof provider !== 'string' || provider.length === 0 || provider.trim() !== provider) {
|
|
107
|
+
throw new ChoicesError('provider must be a non-empty id without surrounding whitespace.', ChoicesErrorCode.INVALID_ROUTE)
|
|
108
|
+
}
|
|
109
|
+
if (typeof model !== 'string' || model.length === 0 || model.trim() !== model) {
|
|
110
|
+
throw new ChoicesError('model must be a non-empty id without surrounding whitespace.', ChoicesErrorCode.INVALID_ROUTE)
|
|
111
|
+
}
|
|
112
|
+
if (reasoningEffort !== undefined && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
|
|
113
|
+
throw new ChoicesError('reasoningEffort must be a non-empty id when supplied.', ChoicesErrorCode.INVALID_ROUTE)
|
|
114
|
+
}
|
|
115
|
+
return Object.freeze({
|
|
116
|
+
provider,
|
|
117
|
+
model,
|
|
118
|
+
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Assert one non-empty identity part (Team root id or teammate name). */
|
|
123
|
+
function identity(teamId, name) {
|
|
124
|
+
if (typeof teamId !== 'string' || teamId.length === 0 || typeof name !== 'string' || name.length === 0) {
|
|
125
|
+
throw new ChoicesError('A teammate choice requires a Team root id and teammate name.', ChoicesErrorCode.INVALID_ROUTE)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Read this Session's own events, excluding inherited parent history. */
|
|
130
|
+
function ownEvents(session) {
|
|
131
|
+
if (session === null || typeof session !== 'object' || typeof session.snapshotEvents !== 'function') {
|
|
132
|
+
throw new ChoicesError('Authorizing a teammate route requires the Session that will send the request.', ChoicesErrorCode.NO_POLICY)
|
|
133
|
+
}
|
|
134
|
+
const from = session.inheritedEventCount ?? 0
|
|
135
|
+
return [...session.snapshotEvents(from)]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read the exact routes the Session's user authorized for explicit child selection.
|
|
140
|
+
* @param session - the Session whose durable decision applies.
|
|
141
|
+
* @returns detached routes, or undefined when no policy was recorded.
|
|
142
|
+
*/
|
|
143
|
+
export function allowedRoutes(session) {
|
|
144
|
+
let latest
|
|
145
|
+
for (const event of ownEvents(session)) {
|
|
146
|
+
if (event.type === 'subagent/model-selection-policy') latest = event
|
|
147
|
+
}
|
|
148
|
+
if (latest === undefined) return undefined
|
|
149
|
+
const routes = latest.data === null ? undefined : latest.data.allowedModels
|
|
150
|
+
if (!Array.isArray(routes)) {
|
|
151
|
+
throw new ChoicesError('The recorded model-selection policy has no route list.', ChoicesErrorCode.NO_POLICY)
|
|
152
|
+
}
|
|
153
|
+
return routes.map(route => ({ provider: route.provider, model: route.model }))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Enforce the Session's allowlist before a choice may be recorded or used.
|
|
158
|
+
*
|
|
159
|
+
* Provider and model are one route and must arrive together; reasoning effort
|
|
160
|
+
* is not part of the allowlist because the LLM adapter owns effort resolution
|
|
161
|
+
* and the caller preflights it with `llm.resolveCallConfig`.
|
|
162
|
+
* @param session - the Session whose durable decision applies.
|
|
163
|
+
* @param route - candidate provider, model, and optional reasoningEffort.
|
|
164
|
+
* @returns the frozen normalized route.
|
|
165
|
+
*/
|
|
166
|
+
export function authorize(session, route) {
|
|
167
|
+
const choice = normalizedRoute(route)
|
|
168
|
+
const routes = allowedRoutes(session)
|
|
169
|
+
if (routes === undefined) {
|
|
170
|
+
throw new ChoicesError(
|
|
171
|
+
'This Session recorded no model-selection policy; explicit teammate model choices require the subagent model-selection setting.',
|
|
172
|
+
ChoicesErrorCode.NO_POLICY,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
if (!routes.some(candidate => candidate.provider === choice.provider && candidate.model === choice.model)) {
|
|
176
|
+
throw new ChoicesError(
|
|
177
|
+
`Route "${choice.provider}/${choice.model}" is not allowed for this Session.`,
|
|
178
|
+
ChoicesErrorCode.ROUTE_NOT_ALLOWED,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
return choice
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load the host's domain vocabulary plus its record schema builder for tests and
|
|
186
|
+
* callers that inject nothing.
|
|
187
|
+
* @param schema - host-supplied `{ defineDomain, domainTable, z }`.
|
|
188
|
+
* @returns the injected schema.
|
|
189
|
+
*/
|
|
190
|
+
function injectedSchema(schema) {
|
|
191
|
+
if (schema === null || typeof schema !== 'object'
|
|
192
|
+
|| typeof schema.defineDomain !== 'function' || typeof schema.domainTable !== 'function'
|
|
193
|
+
|| schema.z === undefined || typeof schema.z.object !== 'function') {
|
|
194
|
+
throw new ChoicesError('An injected host schema needs defineDomain, domainTable, and z.', ChoicesErrorCode.UNSUPPORTED_HOST)
|
|
195
|
+
}
|
|
196
|
+
return schema
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Open the choice store over the host's domain KV.
|
|
201
|
+
*
|
|
202
|
+
* Saves are serialized on one chain so concurrent writes never reorder or lose
|
|
203
|
+
* an update, `save` resolves only after durability, and `close` rejects later
|
|
204
|
+
* writes while draining everything already queued. A failed write rejects and
|
|
205
|
+
* leaves the previous record in place.
|
|
206
|
+
* The host may inject its own domain vocabulary (`options.schema`, or a
|
|
207
|
+
* prebuilt `options.domainSpec`); without it the plugin loads the same public
|
|
208
|
+
* packages from `ctx.profileContext.installAnchor`. Neither path adds a new
|
|
209
|
+
* installation, and a host offering neither fails loud.
|
|
210
|
+
* @param ctx - host Context carrying `storageDomain` and `profileContext`.
|
|
211
|
+
* @param options - optional injected `schema` or `domainSpec` from the host.
|
|
212
|
+
* @returns the store handles.
|
|
213
|
+
*/
|
|
214
|
+
export async function createChoices(ctx, options) {
|
|
215
|
+
const schema = options?.schema === undefined ? undefined : injectedSchema(options.schema)
|
|
216
|
+
const domainSpec = options?.domainSpec ?? choicesDomainOf(schema ?? await loadHostSchema(ctx.profileContext?.installAnchor))
|
|
217
|
+
const facility = ctx.storageDomain ?? ctx.storage?.domain
|
|
218
|
+
if (facility === undefined || typeof facility.open !== 'function') {
|
|
219
|
+
throw new ChoicesError('The host exposed no storage domain for teammate choices.', ChoicesErrorCode.UNSUPPORTED_HOST)
|
|
220
|
+
}
|
|
221
|
+
const domain = await facility.open(domainSpec)
|
|
222
|
+
const table = domain.table('choices')
|
|
223
|
+
/** Refuses new loads and saves once close begins. */
|
|
224
|
+
let closing = false
|
|
225
|
+
/** Set when close begins, so jobs already queued fail before touching the medium. */
|
|
226
|
+
let closingDomain
|
|
227
|
+
let chain = Promise.resolve()
|
|
228
|
+
let closingStore
|
|
229
|
+
|
|
230
|
+
/** Queue one job behind every earlier job on this store's write chain. */
|
|
231
|
+
const enqueue = (job) => {
|
|
232
|
+
const next = chain.then(job)
|
|
233
|
+
// Chain links settle either way so one rejected save never blocks later ones.
|
|
234
|
+
chain = next.then(() => undefined, () => undefined)
|
|
235
|
+
return next
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
/**
|
|
240
|
+
* Read one teammate's recorded choice.
|
|
241
|
+
* @param teamId - exact Team root Session id.
|
|
242
|
+
* @param name - model-facing teammate name.
|
|
243
|
+
* @returns detached route, or undefined when this teammate has no choice.
|
|
244
|
+
*/
|
|
245
|
+
load(teamId, name) {
|
|
246
|
+
if (closing) throw new ChoicesError('The teammate choice store is closing.', ChoicesErrorCode.CLOSED)
|
|
247
|
+
identity(teamId, name)
|
|
248
|
+
return detach(table.get(choiceKey(teamId, name)))
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Record one teammate's authorized choice durably.
|
|
253
|
+
* @param teamId - exact Team root Session id.
|
|
254
|
+
* @param name - model-facing teammate name.
|
|
255
|
+
* @param route - candidate route; the caller authorizes it first.
|
|
256
|
+
* @returns the frozen stored route after durability.
|
|
257
|
+
*/
|
|
258
|
+
save(teamId, name, route) {
|
|
259
|
+
if (closing) {
|
|
260
|
+
return Promise.reject(new ChoicesError('The teammate choice store is closing.', ChoicesErrorCode.CLOSED))
|
|
261
|
+
}
|
|
262
|
+
let record
|
|
263
|
+
try {
|
|
264
|
+
identity(teamId, name)
|
|
265
|
+
// Validate before queueing: an invalid route never reaches the chain or the medium.
|
|
266
|
+
record = Object.freeze({ teamId, name, ...normalizedRoute(route) })
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return Promise.reject(error)
|
|
269
|
+
}
|
|
270
|
+
// Job admission already happened at call time: every save accepted here
|
|
271
|
+
// lands before close's drain, and only calls made after close began refuse.
|
|
272
|
+
return enqueue(async () => {
|
|
273
|
+
await table.put(choiceKey(teamId, name), record)
|
|
274
|
+
return detach(record)
|
|
275
|
+
})
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Drain queued writes and release the domain. Idempotent; later writes reject.
|
|
280
|
+
* @returns after the domain is closed.
|
|
281
|
+
*/
|
|
282
|
+
close() {
|
|
283
|
+
if (closingStore !== undefined) return closingStore
|
|
284
|
+
closing = true
|
|
285
|
+
closingDomain = domain
|
|
286
|
+
closingStore = chain.then(() => domain.close())
|
|
287
|
+
return closingStore
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
- insert:
|
|
2
|
+
- id: team-member-retirements
|
|
3
|
+
name: dsh-team-model-policy/retirements
|
|
4
|
+
- id: team-model-policy
|
|
5
|
+
name: dsh-team-model-policy/policy
|
|
6
|
+
config:
|
|
7
|
+
mode: follow-subagent
|
|
8
|
+
defaultReasoningEffort: max
|
|
9
|
+
- id: team-model-policy-settings
|
|
10
|
+
name: dsh-team-model-policy
|