From 4bfc2bf6ad887e9f83b1e51429806c461606fb6b Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 1 Aug 2026 13:02:07 +0800 Subject: [PATCH] feat(extensions): bind imported agent models Preserve model requests from OpenCode, Claude Code, and Codex agents, then resolve them through existing BitFun model configuration and approval owners. Expose fail-closed binding controls across Desktop, Peer Host, GUI, and TUI without adding plugin runtime behavior. --- .../external-ai-work-sources-design.md | 63 +- .../opencode-extension-compatibility.md | 2 +- .../rules/source/public-api-rules.mjs | 15 +- src/apps/cli/src/modes/chat.rs | 16 +- .../cli/src/modes/chat/external_review.rs | 272 +++++- .../cli/src/modes/chat/external_sources.rs | 37 +- src/apps/cli/src/modes/chat/tests.rs | 63 ++ .../peer_host/commands/external_sources.rs | 59 +- src/apps/cli/src/peer_host/commands/mod.rs | 1 + .../desktop/src/api/external_sources_api.rs | 70 +- .../src/api/remote_workspace_policy.rs | 4 + src/apps/desktop/src/lib.rs | 1 + .../claude-code-adapter/src/agent_source.rs | 4 +- .../tests/subagent_source.rs | 38 +- .../codex-adapter/src/agent_source.rs | 2 +- .../codex-adapter/tests/subagent_source.rs | 37 +- .../opencode-adapter/src/agent_source.rs | 2 +- .../tests/opencode_subagent_adapter.rs | 34 +- .../src/agentic/agents/registry/external.rs | 36 +- .../core/src/agentic/agents/registry/tests.rs | 15 +- .../src/agentic/coordination/coordinator.rs | 199 ++++- .../tools/implementations/task/execution.rs | 24 +- .../assembly/core/src/external_sources.rs | 340 +++++++- .../assembly/core/src/external_subagents.rs | 772 ++++++++++++++++-- .../assembly/external-sources/src/lib.rs | 4 + .../product-domains/src/external_sources.rs | 19 + .../product-domains/src/external_subagents.rs | 116 ++- .../tests/external_source_contracts.rs | 205 ++++- .../service-api/ExternalSourcesAPI.test.ts | 83 ++ .../api/service-api/ExternalSourcesAPI.ts | 67 ++ .../components/ExternalSourcesConfig.test.tsx | 150 ++++ .../components/ExternalSourcesConfig.tsx | 154 +++- .../en-US/settings/external-sources.json | 30 + .../zh-CN/settings/external-sources.json | 30 + .../zh-TW/settings/external-sources.json | 30 + 35 files changed, 2801 insertions(+), 193 deletions(-) diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 667843c3df..e70d2873ea 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -237,11 +237,62 @@ provider 失败事实,避免在空目录界面中伪装成“成功但没有 OpenCode Subagent 属于 L2:adapter 只读取声明,不执行外部代码;激活仍需确认实际模型、工具、执行域和来源关系。 仅 description 等 catalog 文案变化不会扩大运行权限,因此不重复询问;prompt 行为、来源、模型或工具变化必须重新确认。 -生态 adapter 只提交类型明确的模型请求,不能把 `provider/model` 等来源语法交给通用模块解析。Subagent 归属模块在审批 -前把请求解析并固定到一个当前可用的具体模型,形成“配置 ID + 运行配置内容摘要”的不可变绑定;`inherit`、`primary`、`fast`、 -`auto`、`default` 等字符串在此绑定中都是普通配置 ID,不能再次经过模型选择器解释。无法唯一确定时保持不可用。provider、 -模型名、endpoint 或其他影响运行身份的配置在同一 ID 下变化,也会生成新的配置版本并产生新的审批决策。运行中的调用 -继续使用启动时绑定的版本;执行入口若发现当前配置与旧内容摘要不一致,则拒绝执行,不能静默改用新配置或父会话模型。 +当前实现中,生态 adapter 只提交类型明确的 `Default`、`Inherit` 或不透明 `Reference`,不能把 `provider/model` +等来源语法交给通用模块再次解释。Subagent 归属模块在审批前按配置 ID 或 provider 与模型名做唯一精确匹配;匹配失败时 +读取用户保存的 `primary`、`fast` 或具体配置 ID 绑定,仍不能唯一确定时保持不可用。固定目标形成“配置 ID + 运行配置 +内容摘要”的不可变绑定;`Inherit` 则只在 fresh 子任务创建时解析一次父会话已经选择的模型。provider、模型名、endpoint +或其他影响运行身份的配置变化都会生成新的审批决策。运行中的调用继续使用启动时租约固定的版本,不能静默回退。 + +#### 4.2.1 外部 Subagent 模型引用与显式绑定 + +当前生产链路已经按本节契约接通 OpenCode、Claude Code 与 Codex adapter、共享来源快照、现有偏好 owner、Desktop、 +Peer Host、Web 设置页、交互式 TUI 和 fresh child session 创建路径。本节解决的是“外部来源声明的模型如何绑定到用户 +实际配置”,而不是维护 Claude、GPT、GLM、DeepSeek 等厂商或 +型号目录。生产代码把外部模型名视为不透明引用,不按名称片段推断质量、速度、推理能力、成本或等价型号,也不在 Product +Domain、Assembly 或 UI 中维护跨厂商替换表。生态 adapter 只解释自身已验证的语法,并提交以下来源无关的模型请求: + +- `Default`:来源没有指定模型,使用 BitFun 已有 Subagent 默认选择; +- `Inherit`:仅当来源规范明确声明继承父会话模型时使用,不能由通用模块根据字符串猜测; +- `Reference`:保留 adapter 已解析的可选 provider 提示和原始模型引用,模型名保持不透明。 + +`Reference` 的解析顺序固定如下: + +1. 在当前执行域的已启用模型中按配置 ID,或按 provider 提示与 `model_name`,查找唯一精确匹配; +2. 精确匹配不存在时,读取用户对该外部模型引用保存的显式绑定; +3. 绑定目标可以是当前 BitFun `primary`、`fast` 选择器,或一个具体的已配置模型 ID;选择器仍在审批前解析为唯一 + 具体配置,不能把字符串原样带到执行入口; +4. 没有绑定、匹配歧义或绑定目标不可用时进入 `model_binding_required` 或对应的不可用状态,不自动回退。 + +显式绑定复用现有外部 Subagent 决策的范围与 revision 机制,不建立第二套偏好 owner。用户级来源的绑定限定于事实所在 +执行域;项目和工作区来源的绑定限定于对应工作区,工作区决定不能泄漏到其他项目或 Remote。决策身份至少包含生态、 +规范化外部模型引用、适用范围和执行域;同一决策身份影响的当前候选在管理界面聚合展示和一次选择,避免一个 Agent 包中 +几十个相同引用逐项询问。来源引用变化会产生新的决策身份,不能继承旧绑定。 + +`Inherit` 不在发现或审批阶段伪装成某个固定模型。外部 Subagent 注册将继承意图交给现有 Subagent 模型选择 owner, +在调用时使用当前父 Session 已明确选择的模型;审批 envelope 记录的是“继承父模型”这一行为,而不是某个偶然的父模型 ID。 +调用开始时只解析一次父模型,随后创建的 fresh 子 Session 仍保持 `ApprovedImmutable`,不会跟随父 Session 的后续模型切换; +调用时父模型不可用则返回明确失败,不回退到默认模型。`Default`、`primary`、`fast` 和具体模型绑定仍在激活前解析为 +具体模型配置,并携带运行配置指纹。该路径扩展现有外部 generation lease 的模型绑定形态,不建立第二套 Subagent Runtime。 + +Desktop、交互式 TUI 以及未来通过 Host 能力访问该状态的界面必须同时展示:来源请求、实际绑定、绑定方式和受影响候选数。 +例如“来源请求 `sonnet`;当前工作区由用户绑定到 Primary(实际为已配置模型 X);影响 71 个 Agent”。用户可以选择其他 +已配置模型、`primary`、`fast` 或保持相关候选禁用。界面不得把用户选择的替代模型描述成来源原始要求,也不得逐项重复确认 +同一绑定。当前只读 Server 继续只投影脱敏状态,不获得写入能力。 + +绑定目标的配置 ID 与 `model_runtime_binding_fingerprint` 进入既有激活审批 envelope。来源引用改变、绑定目标被删除或停用, +或者同一配置 ID 下的 provider、模型名、endpoint、认证来源及其他运行身份发生变化时,旧激活决定失效;进行中的调用继续 +使用启动时租约固定的绑定,后续调用在重新确认前保持不可用。Remote 必须使用 Remote 执行域的模型事实和独立决定,绝不 +回退到控制端本机的同名模型或本地绑定。 + +本切片明确不实现:内置厂商/型号别名表、按名称推断能力、模型质量评分、成本优化、自动跨 provider fallback、在线模型目录、 +模型下载或安装、Plugin Host Runtime、LSP,以及通用动态模型路由器。现有 capability 标签只用于验证模型是否具备调用所需 +的已声明功能,不能据此宣称两个模型在质量、成本、隐私或上下文行为上等价。 + +完成判定使用表驱动契约覆盖 Claude、GPT、GLM、DeepSeek 风格以及未知未来名称,证明生产解析不依赖任何厂商列表;同时 +覆盖无模型默认项、真实 `Inherit`、provider-qualified 与无 provider 精确匹配、歧义拒绝、显式绑定到选择器或具体模型、 +同一引用聚合、工作区优先级、并发 revision 冲突、绑定目标删除/停用/配置变更后的失效,以及 Remote 不回退本机。Assembly +测试固定来源请求到最终注册和审批 envelope 的端到端链路;Desktop 与 TUI focused test 验证来源请求和实际绑定同时可见、 +一次选择影响正确候选集合且失败后不残留乐观状态。 确认结果分成两层:来源级加载偏好按“来源限定身份 + 插件身份 + 执行域 + 更新策略”保存,并明确作用于当前项目 还是当前执行位置内所有已通过来源校验的项目;项目/工作区实例只用于重新检查有效来源、运行条件和已知贡献, @@ -275,7 +326,7 @@ OpenCode Subagent 属于 L2:adapter 只读取声明,不执行外部代码; | 能力 | OpenCode | Claude Code | Codex | 当前边界 | |---|---|---|---|---| | Prompt Command | JSON/JSONC、Markdown 的 prompt-only 与静态本地文本文件子集 | legacy `commands/**/*.md` 的同一静态本地文本文件子集;Skills 仍由 Skill 归属模块处理 | 没有稳定、独立于 Skills 的声明式 Command 来源,因此不伪造 provider | `$ARGUMENTS`/位置参数及模板内 workspace 相对 UTF-8 `@file` 可展开;shell、动态/绝对/越界文件、指定 Agent/模型等整体受限。 | -| Subagent | 用户/项目声明的安全子集 | 用户/项目 `agents/**/*.md` 的安全子集 | 用户/项目 `[agents]`、角色文件与安全配置层子集 | prompt、描述、精确模型和可表达工具请求进入既有归属模块;权限、私有 MCP/Hook、推理/并发等没有对应实现的字段会阻止激活。 | +| Subagent | 用户/项目声明的安全子集 | 用户/项目 `agents/**/*.md` 的安全子集 | 用户/项目 `[agents]`、角色文件与安全配置层子集 | prompt、描述、`Default`/真实继承/不透明模型引用和可表达工具请求进入既有归属模块;唯一精确匹配或用户显式绑定后才可激活。来源请求、实际模型和解析方式在 Web/TUI 可见。权限、私有 MCP/Hook、推理/并发等没有对应实现的字段会阻止激活。 | | MCP | 用户/显式目录/项目配置的安全子集 | user/project/local 原生层的安全子集 | 用户与项目 `config.toml` 原生层的安全子集 | 支持可表达的 stdio 与 HTTPS Streamable HTTP;发现不启动 Server,首次激活继续经 BitFun MCP 审批。OAuth、remote executor、per-tool policy 等不完整语义明确降级。 | | Standalone Tool | 已有单文件 JavaScript 子集 | 无稳定的 runtime-free standalone Tool 来源 | 无稳定的 runtime-free standalone Tool 来源 | TypeScript、package/plugin Tool 与动态工具注册依赖独立 Plugin Host,不在声明式 adapter 中猜测。 | | Skill | 由现有 Skill 加载模块发现 `.opencode` 标准根及 OpenCode 本地配置根 | 由现有 Skill 加载模块发现 `.claude` 标准根;目录名是调用身份,描述可回退正文首段,`when_to_use` 合入索引,声明参数可做纯文本命名展开 | 由现有 Skill 加载模块发现 `.codex`、`.agents` 标准根;`.codex` 缺少 `name` 时回退目录名 | OpenCode V1 `skills.paths`/当前本地字符串数组只经 `bitfun-core/external_sources` 组合边界投影根目录,递归、加载、覆盖、模式开关与执行仍由同一个 Skill 模块负责;URL 不加载。 | diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 0fb0a41ee9..8e35f2007a 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -107,7 +107,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 部分实现:用户/项目 `instructions` 的 JSON/JSONC | 可主要适配 | OC-R1 | 当前按原生顺序读取用户 `config.json`、`opencode.json`、`opencode.jsonc` 的最终本地 instructions,并保留项目根与 `.opencode` 本地文件/glob;完整配置 schema、环境变量与文件变量引用仍未实现 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | | 独立 `tui.json/jsonc` | 融合现有能力 + 转换参数 | 未实现 | 可完整适配 | OC-R1 | 按 global、`OPENCODE_TUI_CONFIG`、project、`.opencode` 独立顺序加载,不能复用主配置优先级 | [TUI 来源](opencode-config-assets-adapter-design.md#32-tui-独立来源顺序) | | Rules / Instructions | 转换参数 | 部分实现:用户/项目本地文件与 glob | 可完整适配 | OC-R1 | OpenCode adapter 已读取用户全局 `AGENTS.md`/Claude fallback 和最终本地 `instructions`;Product Assembly 在 Codex/Claude 用户来源与项目来源之前合成并去重。远程 URL、managed/organization policy、完整配置来源图和变化监听仍未实现 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集与 Agent-local 权限约束 | 可主要适配 | OC-R1 | 已支持当前生产 V1 `agent/prompt/disable/permission` 与 Core V2 `agents/system/disabled/permissions` 的已验证安全子集、全局/项目 Markdown 和 JSON/JSONC、subagent/all、description、精确模型和工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;V1 `disable` 保持 deep-merge,V2 `disabled` 保持 remove/re-add,不能混用生命周期语义。有序 V2 permission rules 与 V1 扁平精确 action map 会成为只可收紧的独立约束。primary/mode、root ambient permission、V1 action pattern/嵌套 resource map、跨路径与命令资源域的歧义 pattern、variant/options、采样与续接明确阻断或降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | +| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集、模型引用绑定与 Agent-local 权限约束 | 可主要适配 | OC-R1 | 已支持当前生产 V1 `agent/prompt/disable/permission` 与 Core V2 `agents/system/disabled/permissions` 的已验证安全子集、全局/项目 Markdown 和 JSON/JSONC、subagent/all、description、`Default`/不透明模型引用和工具映射,并接入唯一精确匹配、显式 BitFun 模型绑定、Web/TUI 可见性、审批、冲突、更新、撤下和 fresh single-run Task;不维护厂商别名、质量推断或自动 fallback。V1 `disable` 保持 deep-merge,V2 `disabled` 保持 remove/re-add,不能混用生命周期语义。有序 V2 permission rules 与 V1 扁平精确 action map 会成为只可收紧的独立约束。primary/mode、root ambient permission、V1 action pattern/嵌套 resource map、跨路径与命令资源域的歧义 pattern、variant/options、采样与续接明确阻断或降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | Skills | 转换参数 | 部分实现:标准根与本地配置根 | 可完整适配 | OC-R2 | 现有 Registry 除标准用户/项目根外,也通过 `bitfun-core/external_sources` 组合边界按 OpenCode 配置来源顺序累加 V1 `skills.paths` 与当前迁移后的本地字符串数组;仅接受项目根/用户目录内的本地目录并做有界递归发现。同 scope 配置根覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源。URL、下载/缓存、完整 allow/deny/ask 顺序及外部来源策略仍未实现 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | References | 融合现有能力 + 转换参数 | 部分实现:本地目录与既有 Workspace 消费点 | 可主要适配 | OC-R2 | 已按 OpenCode V2 `e4bd9757` 的独立来源顺序解析 `references`/旧 `reference` 的本地 path、description/hidden,相同 alias 后者覆盖;通过独立生命周期协调器与 BitFun 原生关联目录合成 native-first 有效快照,接入关联目录弹窗和既有 `@` 目录选择器。外部声明不自动进入 Prompt、不授予文件权限;Git、Remote、下载/缓存明确不支持且不做临时实现 | [References](opencode-config-assets-adapter-design.md#521-references) | | Commands | 补扩展接口 + 转换参数 | 部分实现:prompt-only + 本地文本文件子集 | 可完整适配 | OC-R2 | 已支持全局/项目 JSON、JSONC、Markdown 命令、参数展开、动态目录、刷新和显式冲突选择;模板中的静态 workspace 相对 `@file` 可在调用时有界读取,动态/绝对/越界文件引用、shell、Agent/model/variant/subtask 保持受限且不做部分执行 | [Commands](opencode-config-assets-adapter-design.md#53-commands) | diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 794f1516e7..4ea86e67ae 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -637,8 +637,8 @@ function externalSubagentEntry(symbol, owner, consumer, wireImpact = false) { owner, consumer, verification: - 'external subagent contract, coordinator, OpenCode adapter, product reconciliation, registry lease, TUI, Desktop, and Web tests', - p0: 'PR3 ecosystem-neutral fresh subagent activation and OpenCode agent vertical slice', + 'external subagent contract, coordinator, OpenCode, Claude Code, and Codex adapters, product reconciliation, registry lease, TUI, Desktop, and Web tests', + p0: 'ecosystem-neutral fresh subagent activation and declarative model binding vertical slice', contractSlice: contractSlices.externalSourceSubagentContract, wireImpact, rationale: @@ -853,6 +853,10 @@ export const externalSubagentContractPublicApiEntries = [ 'ExternalSubagentProviderIdentity', 'ExternalSubagentMode', 'ExternalSubagentModelRequest', + 'ExternalSubagentModelBindingTarget', + 'ExternalSubagentModelBindingMethod', + 'ExternalSubagentModelBindingOption', + 'ExternalSubagentModelBindingGroup', 'ExternalSubagentToolSelector', 'ExternalSubagentToolRequest', 'ExternalSubagentCompatibilityState', @@ -868,6 +872,7 @@ export const externalSubagentContractPublicApiEntries = [ 'external_subagent_candidate_id', 'external_subagent_approval_key', 'external_subagent_conflict_key', + 'external_subagent_model_binding_key', ].map((symbol) => externalSubagentEntry( symbol, @@ -1076,8 +1081,14 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalSubagentCompatibilityState', 'ExternalSubagentConflict', 'ExternalSubagentConflictCandidate', + 'ExternalSubagentModelBindingGroup', + 'ExternalSubagentModelBindingMethod', + 'ExternalSubagentModelBindingOption', + 'ExternalSubagentModelBindingTarget', + 'ExternalSubagentModelRequest', 'ExternalSubagentSummary', 'set_external_subagent_activation', + 'set_external_subagent_model_binding', 'choose_external_subagent_conflict', ].map((symbol) => externalSubagentEntry( diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 88353652ef..05cf115aee 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -75,13 +75,15 @@ use bitfun_core::external_sources::{ expand_external_prompt_command, external_source_conflict_choices, external_source_snapshot, get_external_source_control_snapshot, native_prompt_command_conflict_key, sanitize_external_source_operation_error, set_external_prompt_command_conflict_choice, - set_external_subagent_activation, set_external_tool_conflict_choice, - set_external_tool_target_decision, set_native_prompt_command_conflict_choice, - subscribe_external_source_updates, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, - ExternalSourceControlActionV1, ExternalSourceControlRequestV1, - ExternalSourceDiagnosticSeverity, ExternalSourceHostCapabilities, ExternalSourceOperationError, - ExternalSourceOperationErrorCode, ExternalSubagentActivationState, - ExternalSubagentCompatibilityState, ExternalToolActivationState, ExternalToolCapability, + set_external_subagent_activation, set_external_subagent_model_binding, + set_external_tool_conflict_choice, set_external_tool_target_decision, + set_native_prompt_command_conflict_choice, subscribe_external_source_updates, + ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, ExternalSourceControlActionV1, + ExternalSourceControlRequestV1, ExternalSourceDiagnosticSeverity, + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSubagentActivationState, ExternalSubagentCompatibilityState, + ExternalSubagentModelBindingMethod, ExternalSubagentModelBindingTarget, + ExternalSubagentModelRequest, ExternalToolActivationState, ExternalToolCapability, ExternalToolCatalogEntry, ExternalToolRuntimeKind, NativePromptCommandDescriptor, PromptCommandAvailability, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, }; diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index b589c9e2f3..b65b200e85 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -682,20 +682,31 @@ fn external_tool_pending_notice_key(snapshot: &ExternalSourceCatalogSnapshot) -> .map(|conflict| format!("conflict:{}", conflict.conflict_key)), ) .collect::>(); - decisions.extend(snapshot.diagnostics.iter().filter(|&diagnostic| matches!( - diagnostic.severity, - ExternalSourceDiagnosticSeverity::Warning | ExternalSourceDiagnosticSeverity::Error - )).map(|diagnostic| format!( - "diagnostic:{:?}:{}:{}:{}", - diagnostic.severity, - diagnostic.code, - diagnostic.message, - diagnostic - .source - .as_ref() - .map(|source| source.stable_key()) - .unwrap_or_default() - ))); + decisions.extend( + snapshot + .diagnostics + .iter() + .filter(|&diagnostic| { + matches!( + diagnostic.severity, + ExternalSourceDiagnosticSeverity::Warning + | ExternalSourceDiagnosticSeverity::Error + ) + }) + .map(|diagnostic| { + format!( + "diagnostic:{:?}:{}:{}:{}", + diagnostic.severity, + diagnostic.code, + diagnostic.message, + diagnostic + .source + .as_ref() + .map(|source| source.stable_key()) + .unwrap_or_default() + ) + }), + ); if decisions.is_empty() { return None; } @@ -946,6 +957,12 @@ enum ExternalAgentReviewAction { expected_subagent_generation: u64, expected_preference_revision: u64, }, + Bind { + binding_key: String, + target: Option, + expected_subagent_generation: u64, + expected_preference_revision: u64, + }, Choose { conflict_key: String, candidate_id: String, @@ -1099,8 +1116,55 @@ fn external_agent_compatibility_label(state: ExternalSubagentCompatibilityState) } } -fn external_agent_model_label(model: Option<&str>) -> &str { - model.unwrap_or("unavailable") +fn external_agent_model_label( + model: Option<&str>, + method: ExternalSubagentModelBindingMethod, +) -> &str { + model.unwrap_or(match method { + ExternalSubagentModelBindingMethod::Inherit => { + "resolved from the parent session when the task starts" + } + _ => "unavailable", + }) +} + +fn external_agent_model_request_label(request: &ExternalSubagentModelRequest) -> String { + match request { + ExternalSubagentModelRequest::Default => "BitFun default".to_string(), + ExternalSubagentModelRequest::Inherit => "parent session model".to_string(), + ExternalSubagentModelRequest::Reference { + provider_hint, + model_name, + } => provider_hint + .as_ref() + .map(|provider| format!("{provider}/{model_name}")) + .unwrap_or_else(|| model_name.clone()), + } +} + +fn external_agent_model_binding_method_label( + method: ExternalSubagentModelBindingMethod, +) -> &'static str { + match method { + ExternalSubagentModelBindingMethod::Default => "BitFun default", + ExternalSubagentModelBindingMethod::Inherit => "inherited from the parent session", + ExternalSubagentModelBindingMethod::Exact => "exact configured model", + ExternalSubagentModelBindingMethod::Explicit => "user binding", + ExternalSubagentModelBindingMethod::BindingRequired => "choose a BitFun model", + ExternalSubagentModelBindingMethod::BindingUnavailable => { + "saved BitFun model is unavailable" + } + } +} + +fn external_agent_model_binding_target_label( + target: &ExternalSubagentModelBindingTarget, +) -> &'static str { + match target { + ExternalSubagentModelBindingTarget::Primary => "primary model", + ExternalSubagentModelBindingTarget::Fast => "fast model", + ExternalSubagentModelBindingTarget::Model { .. } => "configured model", + } } fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) -> String { @@ -1127,6 +1191,69 @@ fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) append_external_source_issues(&mut lines, snapshot, ExternalIssueSurface::Agents); + lines.push(String::new()); + lines.push("Model bindings".to_string()); + if snapshot.subagent_model_binding_groups.is_empty() { + lines.push(" None".to_string()); + } else { + for (binding_index, binding) in snapshot.subagent_model_binding_groups.iter().enumerate() { + lines.push(format!( + " {}. {} - {}", + binding_index + 1, + external_agent_model_request_label(&binding.request), + external_agent_model_binding_method_label(binding.method) + )); + lines.push(format!( + " Affects {} agents; effective model: {}", + binding.affected_candidate_ids.len(), + external_agent_model_label( + binding.effective_model_label.as_deref(), + binding.method, + ) + )); + if binding.method == ExternalSubagentModelBindingMethod::Exact { + lines.push(" Matched automatically; no binding is needed.".to_string()); + continue; + } + lines.push(format!( + " 0. Automatic source matching{} - /agent bind {} 0", + if binding.selected_target.is_none() { + " [selected]" + } else { + "" + }, + binding_index + 1 + )); + for (choice_index, option) in snapshot.subagent_model_binding_options.iter().enumerate() + { + lines.push(format!( + " {}. {} ({}){} - /agent bind {} {}", + choice_index + 1, + option.effective_model_label, + external_agent_model_binding_target_label(&option.target), + if binding.selected_target.as_ref() == Some(&option.target) { + " [selected]" + } else { + "" + }, + binding_index + 1, + choice_index + 1 + )); + } + if let Some(target) = binding.selected_target.as_ref().filter(|target| { + !snapshot + .subagent_model_binding_options + .iter() + .any(|option| &option.target == *target) + }) { + lines.push(format!( + " Saved {} is unavailable; choose another entry or clear the binding.", + external_agent_model_binding_target_label(target) + )); + } + } + } + lines.push(String::new()); lines.push("Agents".to_string()); if snapshot.subagents.is_empty() { @@ -1151,9 +1278,20 @@ fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) agent.source_location_labels.join(", ") )); } + lines.push(format!( + " Requested model: {}", + external_agent_model_request_label(&agent.requested_model) + )); + lines.push(format!( + " Resolution: {}", + external_agent_model_binding_method_label(agent.model_binding_method) + )); lines.push(format!( " Model: {}", - external_agent_model_label(agent.effective_model_label.as_deref()) + external_agent_model_label( + agent.effective_model_label.as_deref(), + agent.model_binding_method, + ) )); lines.push(format!( " Tools: {}", @@ -1242,7 +1380,10 @@ fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) { lines.push(format!( " Model: {}", - external_agent_model_label(agent.effective_model_label.as_deref()) + external_agent_model_label( + agent.effective_model_label.as_deref(), + agent.model_binding_method, + ) )); lines.push(format!( " Tools: {}", @@ -1438,6 +1579,10 @@ fn merge_external_agent_mutation_snapshot( merged.subagent_generation = result.subagent_generation; merged.preference_revision = result.preference_revision; merged.subagents = std::mem::take(&mut result.subagents); + merged.subagent_model_binding_groups = + std::mem::take(&mut result.subagent_model_binding_groups); + merged.subagent_model_binding_options = + std::mem::take(&mut result.subagent_model_binding_options); merged.subagent_conflicts = std::mem::take(&mut result.subagent_conflicts); merged.pending_subagent_approvals = std::mem::take(&mut result.pending_subagent_approvals); merged @@ -1445,6 +1590,7 @@ fn merge_external_agent_mutation_snapshot( #[derive(Debug, Clone, PartialEq, Eq)] struct ExternalAgentAttention { + bindings: usize, confirmations: usize, conflicts: usize, unavailable: usize, @@ -1508,14 +1654,28 @@ fn external_agent_attention( }) .collect::>(); let confirmations = snapshot.pending_subagent_approvals.len(); + let binding_keys = snapshot + .subagent_model_binding_groups + .iter() + .filter(|binding| { + matches!( + binding.method, + ExternalSubagentModelBindingMethod::BindingRequired + | ExternalSubagentModelBindingMethod::BindingUnavailable + ) + }) + .map(|binding| binding.binding_key.as_str()) + .collect::>(); + let bindings = binding_keys.len(); let conflicts = unresolved.len(); let unavailable_count = unavailable.len(); let diagnostic_count = diagnostics.len(); - let key = if confirmations + conflicts + unavailable_count + diagnostic_count == 0 { + let key = if bindings + confirmations + conflicts + unavailable_count + diagnostic_count == 0 { None } else { Some(format!( - "approvals={};conflicts={};unavailable={};diagnostics={}", + "bindings={};approvals={};conflicts={};unavailable={};diagnostics={}", + binding_keys.join(","), pending_decisions.join(","), unresolved.join(","), unavailable.into_iter().collect::>().join(","), @@ -1523,6 +1683,7 @@ fn external_agent_attention( )) }; ExternalAgentAttention { + bindings, confirmations, conflicts, unavailable: unavailable_count, @@ -1596,6 +1757,55 @@ fn parse_external_agent_review_action( expected_preference_revision: snapshot.preference_revision, }); } + if command.eq_ignore_ascii_case("bind") { + let binding_index = parse_positive_index(parts.next(), "binding number")?; + let raw_choice = parts + .next() + .ok_or_else(|| "missing choice number".to_string())?; + let choice_number = raw_choice + .parse::() + .map_err(|_| "choice number must be zero or a positive number".to_string())?; + if parts.next().is_some() { + return Err("usage: /agent bind ".to_string()); + } + let binding = snapshot + .subagent_model_binding_groups + .get(binding_index) + .ok_or_else(|| { + "that model binding is no longer available; run /agent refresh".to_string() + })?; + if !matches!( + binding.method, + ExternalSubagentModelBindingMethod::BindingRequired + | ExternalSubagentModelBindingMethod::Explicit + | ExternalSubagentModelBindingMethod::BindingUnavailable + ) { + return Err(format!( + "model binding {} is automatic and cannot be changed", + binding_index + 1 + )); + } + let target = if choice_number == 0 { + None + } else { + Some( + snapshot + .subagent_model_binding_options + .get(choice_number - 1) + .ok_or_else(|| { + "that model choice is no longer available; run /agent refresh".to_string() + })? + .target + .clone(), + ) + }; + return Ok(ExternalAgentReviewAction::Bind { + binding_key: binding.binding_key.clone(), + target, + expected_subagent_generation: snapshot.subagent_generation, + expected_preference_revision: snapshot.preference_revision, + }); + } if command.eq_ignore_ascii_case("choose") { let conflict_index = parse_positive_index(parts.next(), "conflict number")?; let raw_candidate = parts @@ -1640,7 +1850,7 @@ fn parse_external_agent_review_action( expected_preference_revision: snapshot.preference_revision, }); } - Err("usage: /agent [refresh | enable | disable | choose ]".to_string()) + Err("usage: /agent [refresh | bind | enable | disable | choose ]".to_string()) } fn external_agent_mutation_result_label( @@ -1690,6 +1900,26 @@ fn external_agent_mutation_result_label( "Agent choices changed; run /agent refresh before choosing".to_string() } } + ExternalAgentReviewAction::Bind { + binding_key, + target, + .. + } => { + let binding = snapshot + .subagent_model_binding_groups + .iter() + .find(|binding| binding.binding_key == *binding_key); + if target.is_none() && binding.is_some_and(|binding| binding.selected_target.is_none()) + { + "Agent model binding cleared".to_string() + } else if binding + .is_some_and(|binding| binding.selected_target.as_ref() == target.as_ref()) + { + "Agent model binding saved".to_string() + } else { + "Agent model choices changed; run /agent refresh before choosing".to_string() + } + } ExternalAgentReviewAction::Show => "External agents".to_string(), } } diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index 0d55574db4..c5486a2e7d 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -673,7 +673,8 @@ impl ChatMode { return None; } self.external_agent_notice_key = next_key; - if attention.confirmations + if attention.bindings + + attention.confirmations + attention.conflicts + attention.unavailable + attention.diagnostics @@ -682,6 +683,9 @@ impl ChatMode { None } else { let mut details = Vec::new(); + if attention.bindings > 0 { + details.push(format!("{} model bindings", attention.bindings)); + } if attention.confirmations > 0 { details.push(format!("{} confirmations", attention.confirmations)); } @@ -745,6 +749,7 @@ impl ChatMode { approved: false, .. } => "Disabling external agent", ExternalAgentReviewAction::Choose { .. } => "Selecting agent source", + ExternalAgentReviewAction::Bind { .. } => "Saving agent model binding", ExternalAgentReviewAction::Show => unreachable!(), }; let task_action = action.clone(); @@ -788,6 +793,21 @@ impl ChatMode { ) .await } + ExternalAgentReviewAction::Bind { + binding_key, + target, + expected_subagent_generation, + expected_preference_revision, + } => { + set_external_subagent_model_binding( + Some(&workspace), + binding_key, + target.clone(), + *expected_subagent_generation, + *expected_preference_revision, + ) + .await + } ExternalAgentReviewAction::Show => unreachable!(), } .map_err(sanitize_external_source_operation_error); @@ -838,6 +858,17 @@ impl ChatMode { self.external_agent_notice_key = external_agent_pending_notice_key(Some(&snapshot), &snapshot); let confirmations = snapshot.pending_subagent_approvals.len(); + let bindings = snapshot + .subagent_model_binding_groups + .iter() + .filter(|binding| { + matches!( + binding.method, + ExternalSubagentModelBindingMethod::BindingRequired + | ExternalSubagentModelBindingMethod::BindingUnavailable + ) + }) + .count(); let conflicts = snapshot .subagent_conflicts .iter() @@ -845,11 +876,11 @@ impl ChatMode { .count(); let result_label = external_agent_mutation_result_label(&outcome.action, &snapshot); self.external_source_snapshot = Some(snapshot); - if confirmations + conflicts == 0 { + if bindings + confirmations + conflicts == 0 { chat_view.set_status(Some(result_label)); } else { chat_view.set_status(Some(format!( - "{result_label}; {confirmations} confirmations and {conflicts} conflicts remain - run /agent refresh" + "{result_label}; {bindings} model bindings, {confirmations} confirmations, and {conflicts} conflicts remain - run /agent refresh" ))); } } diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 9809c21fc1..64090d08c0 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -52,6 +52,7 @@ mod tests { }; use bitfun_events::{AgenticEvent, ToolEventData}; use bitfun_product_domains::external_sources::ExternalSourceScope; + use bitfun_product_domains::external_subagents::ExternalSubagentModelBindingTarget; use bitfun_runtime_ports::AgentContextReloadTarget; use std::collections::{BTreeMap, BTreeSet}; @@ -2112,6 +2113,13 @@ mod tests { }], "sourceLocationLabels": ["/.opencode/agents/review.md"], "sourceCount": 1, + "requestedModel": { + "kind": "reference", + "providerHint": "anthropic", + "modelName": "claude-sonnet-4" + }, + "modelBindingMethod": "binding_required", + "modelBindingKey": "external_subagent_model_binding:review", "effectiveModelLabel": "fast", "effectiveToolLabels": ["read", "search"], "supportsFollowUp": false, @@ -2120,6 +2128,27 @@ mod tests { "activationState": { "state": "approval_required" }, "decisionKey": "decision-v1" }], + "subagentModelBindingGroups": [{ + "bindingKey": "external_subagent_model_binding:review", + "request": { + "kind": "reference", + "providerHint": "anthropic", + "modelName": "claude-sonnet-4" + }, + "scope": "project", + "method": "binding_required", + "affectedCandidateIds": [ + "external_subagent:opencode:review:v1", + "external_subagent:claude:review:v1" + ] + }], + "subagentModelBindingOptions": [{ + "target": { "kind": "primary" }, + "effectiveModelLabel": "GPT-5" + }, { + "target": { "kind": "fast" }, + "effectiveModelLabel": "GLM-4.5-Air" + }], "subagentConflicts": [{ "conflictKey": "conflict-v1", "logicalId": "review", @@ -2146,6 +2175,10 @@ mod tests { assert!(summary.contains("one run only; no follow-up")); assert!(summary.contains("Model: fast")); + assert!(summary.contains("Requested model: anthropic/claude-sonnet-4")); + assert!(summary.contains("Resolution: choose a BitFun model")); + assert!(summary.contains("Affects 2 agents")); + assert!(summary.contains("/agent bind 1 2")); assert!(summary.contains("Tools: read, search")); assert!(summary.contains("/agent enable 1")); assert!(summary.contains("/agent choose 1 2")); @@ -2162,6 +2195,18 @@ mod tests { let mut unavailable = external_agent_review_snapshot(); unavailable.subagents[0].effective_model_label = None; assert!(external_agent_review_text(Some(&unavailable)).contains("Model: unavailable")); + + let mut inherited = external_agent_review_snapshot(); + inherited.subagents[0].requested_model = + bitfun_product_domains::external_subagents::ExternalSubagentModelRequest::Inherit; + inherited.subagents[0].model_binding_method = + bitfun_product_domains::external_subagents::ExternalSubagentModelBindingMethod::Inherit; + inherited.subagents[0].model_binding_key = None; + inherited.subagents[0].effective_model_label = None; + let inherited_summary = external_agent_review_text(Some(&inherited)); + assert!(inherited_summary + .contains("Model: resolved from the parent session when the task starts")); + assert!(!inherited_summary.contains("Model: unavailable")); } #[test] @@ -2287,6 +2332,24 @@ mod tests { expected_preference_revision: 7, } ); + assert_eq!( + parse_external_agent_review_action("bind 1 2", Some(&snapshot), None).unwrap(), + ExternalAgentReviewAction::Bind { + binding_key: "external_subagent_model_binding:review".to_string(), + target: Some(ExternalSubagentModelBindingTarget::Fast), + expected_subagent_generation: 4, + expected_preference_revision: 7, + } + ); + assert_eq!( + parse_external_agent_review_action("bind 1 0", Some(&snapshot), None).unwrap(), + ExternalAgentReviewAction::Bind { + binding_key: "external_subagent_model_binding:review".to_string(), + target: None, + expected_subagent_generation: 4, + expected_preference_revision: 7, + } + ); assert_eq!( parse_external_agent_review_action("choose 1 2", Some(&snapshot), None).unwrap(), ExternalAgentReviewAction::Choose { diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs index c598f7f6ae..7a11f5be94 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -7,11 +7,12 @@ use bitfun_core::external_sources::{ choose_external_subagent_conflict, external_source_snapshot, get_external_source_control_snapshot, set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, - set_external_subagent_activation, set_external_tool_conflict_choice, - set_external_tool_target_decision, update_external_integration_policy, - ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, - ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, - ExternalSourceOperationResult, ExternalSourcePublicSnapshot, + set_external_subagent_activation, set_external_subagent_model_binding, + set_external_tool_conflict_choice, set_external_tool_target_decision, + update_external_integration_policy, ExternalIntegrationPolicyMutation, + ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, + ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, + ExternalSubagentModelBindingTarget, }; use serde_json::Value; @@ -59,6 +60,22 @@ fn required_u64(request: &Value, key: &str) -> ExternalSourceOperationResult ExternalSourceOperationResult> { + match request.get(key) { + None | Some(Value::Null) => Ok(None), + Some(value) => serde_json::from_value(value.clone()) + .map(Some) + .map_err(|_| { + ExternalSourceOperationError::invalid_request(format!( + "'{key}' must be a valid external subagent model binding target" + )) + }), + } +} + async fn workspace_root( state: &PeerHostState, request: &Value, @@ -224,6 +241,16 @@ async fn dispatch_inner( ) .await } + "set_external_subagent_model_binding_command" => { + set_external_subagent_model_binding( + workspace, + &required_string(request, "bindingKey")?, + model_binding_target_field(request, "target")?, + required_u64(request, "expectedSubagentGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "choose_external_subagent_conflict_command" => { choose_external_subagent_conflict( workspace, @@ -346,4 +373,26 @@ mod tests { ExternalSourceControlActionV1::SetSafeMode { enabled: true } )); } + + #[test] + fn peer_model_binding_target_parser_accepts_set_and_clear_shapes() { + let set = serde_json::json!({ + "target": { "kind": "primary" } + }); + assert_eq!( + model_binding_target_field(&set, "target").unwrap(), + Some(ExternalSubagentModelBindingTarget::Primary) + ); + + let clear = serde_json::json!({ "target": null }); + assert_eq!(model_binding_target_field(&clear, "target").unwrap(), None); + + let invalid = serde_json::json!({ "target": { "kind": "automatic" } }); + assert_eq!( + model_binding_target_field(&invalid, "target") + .unwrap_err() + .code, + ExternalSourceOperationErrorCode::InvalidRequest + ); + } } diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index cbdd7953c4..5c31acbae0 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -48,6 +48,7 @@ pub(crate) async fn dispatch( | "set_external_tool_target_decision_command" | "set_external_tool_conflict_choice_command" | "set_external_subagent_activation_command" + | "set_external_subagent_model_binding_command" | "choose_external_subagent_conflict_command" | "set_external_mcp_server_decision_command" | "choose_external_mcp_conflict_command" diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 337f1a3149..144b3ed12a 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -7,12 +7,13 @@ use bitfun_core::external_sources::{ get_external_source_control_snapshot as core_get_external_source_control_snapshot, native_prompt_command_conflicts, set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, - set_external_subagent_activation, set_external_tool_conflict_choice, - set_external_tool_target_decision, set_native_prompt_command_conflict_choice, - update_external_integration_policy, workspace_reference_snapshot, ExpandedPromptCommand, - ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, - ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, - ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSourceSurfaceSnapshotV1, + set_external_subagent_activation, set_external_subagent_model_binding, + set_external_tool_conflict_choice, set_external_tool_target_decision, + set_native_prompt_command_conflict_choice, update_external_integration_policy, + workspace_reference_snapshot, ExpandedPromptCommand, ExternalIntegrationPolicyMutation, + ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, + ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, + ExternalSourceSurfaceSnapshotV1, ExternalSubagentModelBindingTarget, NativePromptCommandConflictSnapshot, NativePromptCommandDescriptor, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; @@ -149,6 +150,16 @@ pub struct SetExternalSubagentActivationRequest { pub decision_key: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalSubagentModelBindingRequest { + pub workspace_path: Option, + pub binding_key: String, + pub target: Option, + pub expected_subagent_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ChooseExternalSubagentConflictRequest { @@ -520,6 +531,23 @@ pub async fn set_external_subagent_activation_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_subagent_model_binding_command( + request: SetExternalSubagentModelBindingRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_subagent_model_binding( + workspace, + &request.binding_key, + request.target, + request.expected_subagent_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn choose_external_subagent_conflict_command( request: ChooseExternalSubagentConflictRequest, @@ -676,6 +704,36 @@ mod tests { )); } + #[test] + fn desktop_subagent_model_binding_request_keeps_the_target_typed_and_nullable() { + let set: SetExternalSubagentModelBindingRequest = + serde_json::from_value(serde_json::json!({ + "workspacePath": "D:/workspace/project", + "bindingKey": "external_subagent_model_binding:review", + "target": { "kind": "model", "modelId": "glm-project" }, + "expectedSubagentGeneration": 5, + "expectedPreferenceRevision": 8 + })) + .unwrap(); + assert_eq!( + set.target, + Some(ExternalSubagentModelBindingTarget::Model { + model_id: "glm-project".to_string(), + }) + ); + + let clear: SetExternalSubagentModelBindingRequest = + serde_json::from_value(serde_json::json!({ + "workspacePath": null, + "bindingKey": "external_subagent_model_binding:review", + "target": null, + "expectedSubagentGeneration": 5, + "expectedPreferenceRevision": 8 + })) + .unwrap(); + assert_eq!(clear.target, None); + } + #[test] fn desktop_prompt_expansion_request_requires_guarded_candidate_identity() { let request: ExpandExternalPromptCommandRequest = diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 452d70e836..b8e4a662f8 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1679,6 +1679,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_subagent_activation_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_subagent_model_binding_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_global_skill_disabled", RemoteWorkspacePolicy::WorkspaceAgnostic, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 44cec9957d..b7ddc7726b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1191,6 +1191,7 @@ pub async fn run() { set_external_tool_target_decision_command, set_external_tool_conflict_choice_command, set_external_subagent_activation_command, + set_external_subagent_model_binding_command, choose_external_subagent_conflict_command, set_external_mcp_server_decision_command, choose_external_mcp_conflict_command, diff --git a/src/crates/adapters/claude-code-adapter/src/agent_source.rs b/src/crates/adapters/claude-code-adapter/src/agent_source.rs index f0630d031b..30dc18ec1d 100644 --- a/src/crates/adapters/claude-code-adapter/src/agent_source.rs +++ b/src/crates/adapters/claude-code-adapter/src/agent_source.rs @@ -498,9 +498,9 @@ fn materialize_definition( }; let requested_model = match fields.get("model") { None => ExternalSubagentModelRequest::Default, - Some(Value::String(value)) if value == "inherit" => ExternalSubagentModelRequest::Default, + Some(Value::String(value)) if value == "inherit" => ExternalSubagentModelRequest::Inherit, Some(Value::String(value)) if !value.trim().is_empty() => { - ExternalSubagentModelRequest::Exact { + ExternalSubagentModelRequest::Reference { provider_hint: None, model_name: value.trim().to_string(), } diff --git a/src/crates/adapters/claude-code-adapter/tests/subagent_source.rs b/src/crates/adapters/claude-code-adapter/tests/subagent_source.rs index dc026ebd80..e2ff3379f8 100644 --- a/src/crates/adapters/claude-code-adapter/tests/subagent_source.rs +++ b/src/crates/adapters/claude-code-adapter/tests/subagent_source.rs @@ -92,7 +92,7 @@ fn nearest_project_agent_overrides_user_agent_without_field_merge() { assert_eq!(definition.provenance.len(), 3); assert_eq!( definition.requested_model, - ExternalSubagentModelRequest::Exact { + ExternalSubagentModelRequest::Reference { provider_hint: None, model_name: "claude-opus-4".to_string(), } @@ -108,6 +108,42 @@ fn nearest_project_agent_overrides_user_agent_without_field_merge() { ); } +#[test] +fn explicit_inherit_is_distinct_from_an_opaque_model_reference() { + let fixture = Fixture::new(); + write( + fixture.user_claude.join("agents/inherited.md"), + "---\nname: inherited\ndescription: Inherited\nmodel: inherit\ntools: [Read]\n---\nInherited prompt", + ); + write( + fixture.user_claude.join("agents/named-inherit.md"), + "---\nname: named-inherit\ndescription: Named\nmodel: vendor/inherit\ntools: [Read]\n---\nNamed prompt", + ); + + let snapshot = fixture.discover(BTreeSet::new()); + let inherited = snapshot + .definitions + .iter() + .find(|definition| definition.logical_id == "inherited") + .unwrap(); + assert_eq!( + inherited.requested_model, + ExternalSubagentModelRequest::Inherit + ); + let named = snapshot + .definitions + .iter() + .find(|definition| definition.logical_id == "named-inherit") + .unwrap(); + assert_eq!( + named.requested_model, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "vendor/inherit".to_string(), + } + ); +} + #[test] fn unsupported_behavior_blocks_while_color_is_display_only_degradation() { let fixture = Fixture::new(); diff --git a/src/crates/adapters/codex-adapter/src/agent_source.rs b/src/crates/adapters/codex-adapter/src/agent_source.rs index d0c9aeb805..10eb2fc41d 100644 --- a/src/crates/adapters/codex-adapter/src/agent_source.rs +++ b/src/crates/adapters/codex-adapter/src/agent_source.rs @@ -709,7 +709,7 @@ fn materialize_definition( .filter(|value| !value.is_empty()); let requested_model = match effective_behavior.model.as_deref().or(default_model) { None | Some("") => ExternalSubagentModelRequest::Default, - Some(model) => ExternalSubagentModelRequest::Exact { + Some(model) => ExternalSubagentModelRequest::Reference { provider_hint: None, model_name: model.trim().to_string(), }, diff --git a/src/crates/adapters/codex-adapter/tests/subagent_source.rs b/src/crates/adapters/codex-adapter/tests/subagent_source.rs index 3241ad7fb1..eb8449cf14 100644 --- a/src/crates/adapters/codex-adapter/tests/subagent_source.rs +++ b/src/crates/adapters/codex-adapter/tests/subagent_source.rs @@ -90,6 +90,33 @@ developer_instructions = "Project-only instructions" assert_eq!(error.code, "codex.agent.config_invalid"); } +#[test] +fn model_named_inherit_remains_an_opaque_codex_reference() { + let fixture = Fixture::new(); + write( + fixture.codex_home.join("config.toml"), + r#"[agents.named] +description = "Named model" +config_file = "./agents/named.toml" +"#, + ); + write( + fixture.codex_home.join("agents/named.toml"), + r#"developer_instructions = "Use the configured model" +model = "inherit" +"#, + ); + + let definition = &fixture.discover(BTreeSet::new()).definitions[0]; + assert_eq!( + definition.requested_model, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "inherit".to_string(), + } + ); +} + #[test] fn standalone_project_role_overlays_user_role_and_inherits_missing_description() { let fixture = Fixture::new(); @@ -124,7 +151,7 @@ model = "gpt-project" assert_eq!(definition.provenance.len(), 2); assert_eq!( definition.requested_model, - ExternalSubagentModelRequest::Exact { + ExternalSubagentModelRequest::Reference { provider_hint: None, model_name: "gpt-project".to_string(), } @@ -165,7 +192,7 @@ nickname_candidates = ["Atlas"] assert_eq!(definition.provenance.len(), 2); assert_eq!( definition.requested_model, - ExternalSubagentModelRequest::Exact { + ExternalSubagentModelRequest::Reference { provider_hint: None, model_name: "gpt-inherited".to_string(), } @@ -225,7 +252,7 @@ config_file = "./agents/prompt_only.toml" assert_eq!(model_only.prompt.expose(), "Inherited prompt"); assert_eq!( model_only.requested_model, - ExternalSubagentModelRequest::Exact { + ExternalSubagentModelRequest::Reference { provider_hint: None, model_name: "gpt-project".to_string(), } @@ -239,7 +266,7 @@ config_file = "./agents/prompt_only.toml" assert_eq!(prompt_only.prompt.expose(), "Project prompt"); assert_eq!( prompt_only.requested_model, - ExternalSubagentModelRequest::Exact { + ExternalSubagentModelRequest::Reference { provider_hint: None, model_name: "gpt-inherited".to_string(), } @@ -481,7 +508,7 @@ config_file = "./agents/reviewer.toml" assert!(definition.disabled); assert_eq!( definition.requested_model, - ExternalSubagentModelRequest::Exact { + ExternalSubagentModelRequest::Reference { provider_hint: None, model_name: "gpt-default".to_string(), }, diff --git a/src/crates/adapters/opencode-adapter/src/agent_source.rs b/src/crates/adapters/opencode-adapter/src/agent_source.rs index 6b6e7342d7..4777112d41 100644 --- a/src/crates/adapters/opencode-adapter/src/agent_source.rs +++ b/src/crates/adapters/opencode-adapter/src/agent_source.rs @@ -866,7 +866,7 @@ fn materialize_definition( .split_once('/') .map(|(provider, model_name)| (Some(provider.to_string()), model_name.to_string())) .unwrap_or_else(|| (None, model.to_string())); - ExternalSubagentModelRequest::Exact { + ExternalSubagentModelRequest::Reference { provider_hint, model_name, } diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs index c92bca8454..25e2b5570c 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs @@ -89,6 +89,38 @@ fn omo_oracle_flat_permissions_become_provider_neutral_constraints() { .contains(&"opencode_agent_permission_not_imported".to_string())); } +#[test] +fn model_named_inherit_remains_an_opaque_opencode_reference() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "named": { + "description": "Named model", + "prompt": "Use the configured model", + "mode": "subagent", + "model": "inherit" + } + } + }"#, + ) + .unwrap(); + + let definition = + &discover(&provider(&temp, &workspace), workspace, BTreeSet::new()).definitions[0]; + assert_eq!( + definition.requested_model, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "inherit".to_string(), + } + ); +} + #[test] fn current_opencode_agent_permissions_preserve_ordered_resource_rules() { let temp = TempDir::new().unwrap(); @@ -898,7 +930,7 @@ fn global_and_project_agent_fields_deep_merge_with_ordered_provenance() { assert_eq!(definition.mode, ExternalSubagentMode::Subagent); assert_eq!( definition.requested_model, - ExternalSubagentModelRequest::Exact { + ExternalSubagentModelRequest::Reference { provider_hint: Some("openrouter".to_string()), model_name: "anthropic/claude-sonnet-4".to_string(), } diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index d2b8cb50c6..d9420a220c 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -14,9 +14,31 @@ pub(crate) fn external_subagent_runtime_key(digest: &str) -> String { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExternalSubagentModelBinding { - pub model_id: String, - pub configuration_fingerprint: String, +pub enum ExternalSubagentModelBinding { + Fixed { + model_id: String, + configuration_fingerprint: String, + }, + InheritParent, +} + +impl ExternalSubagentModelBinding { + pub fn fixed_model_id(&self) -> Option<&str> { + match self { + Self::Fixed { model_id, .. } => Some(model_id), + Self::InheritParent => None, + } + } + + pub fn configuration_fingerprint(&self) -> Option<&str> { + match self { + Self::Fixed { + configuration_fingerprint, + .. + } => Some(configuration_fingerprint), + Self::InheritParent => None, + } + } } #[derive(Clone)] @@ -398,8 +420,12 @@ fn external_agent_info(entry: &ExternalSubagentGenerationEntry) -> AgentInfo { source: AgentSource::External, subagent_source: Some(SubAgentSource::External), path: None, - model: Some(entry.registration.model_binding.model_id.clone()), - model_is_explicit: Some(true), + model: entry + .registration + .model_binding + .fixed_model_id() + .map(str::to_string), + model_is_explicit: Some(entry.registration.model_binding.fixed_model_id().is_some()), visibility: Some(SubagentVisibilityPolicy::public().summary()), external_provider_label: Some(entry.registration.provider_label.clone()), supports_follow_up: false, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 0974647e75..817732e998 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -1161,7 +1161,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( runtime_key: runtime_v1.to_string(), logical_id: "Explore".to_string(), provider_label: "OpenCode".to_string(), - model_binding: super::ExternalSubagentModelBinding { + model_binding: super::ExternalSubagentModelBinding::Fixed { model_id: "inherit".to_string(), configuration_fingerprint: "model-config-v1".to_string(), }, @@ -1225,8 +1225,11 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( .as_ref() .expect("external binding keeps a generation lease") .model_binding(); - assert_eq!(leased_model.model_id, "inherit"); - assert_eq!(leased_model.configuration_fingerprint, "model-config-v1"); + assert_eq!(leased_model.fixed_model_id(), Some("inherit")); + assert_eq!( + leased_model.configuration_fingerprint(), + Some("model-config-v1") + ); let runtime_v2 = "external::candidate::behavior-v2"; let agent_v2: Arc = Arc::new(TestAgent { @@ -1238,7 +1241,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( runtime_key: runtime_v2.to_string(), logical_id: "Explore".to_string(), provider_label: "OpenCode".to_string(), - model_binding: super::ExternalSubagentModelBinding { + model_binding: super::ExternalSubagentModelBinding::Fixed { model_id: "inherit".to_string(), configuration_fingerprint: "model-config-v2".to_string(), }, @@ -1259,8 +1262,8 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( .as_ref() .expect("old generation remains leased") .model_binding() - .configuration_fingerprint, - "model-config-v1" + .configuration_fingerprint(), + Some("model-config-v1") ); registry.install_external_subagent_routes( diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index f0da586436..03a120b4c8 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -12,7 +12,7 @@ use super::{ turn_settlement::TurnSettlementTracker, BackgroundSubagentOutcomeStore, BackgroundSubagentWaitMode, BackgroundSubagentWaitResult, }; -use crate::agentic::agents::get_agent_registry; +use crate::agentic::agents::{get_agent_registry, ExternalSubagentModelBinding}; use crate::agentic::context_profile::ContextProfilePolicy; use crate::agentic::core::{ InternalReminderKind, Message, MessageContent, MessageSemanticKind, ProcessingPhase, Session, @@ -59,6 +59,7 @@ use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; use crate::service::config::global::GlobalConfigManager; +use crate::service::config::types::{model_runtime_binding_fingerprint, AIConfig}; use crate::service::config::{ get_global_config_service, AgentModelDefaultsConfig, SubagentModelSelection, }; @@ -221,6 +222,57 @@ async fn normalize_model_selection(model_id: &str) -> BitFunResult { } } +fn resolve_approved_immutable_model_binding( + binding: &ExternalSubagentModelBinding, + parent_model_selection: Option<&str>, + ai_config: &AIConfig, +) -> BitFunResult<(String, String)> { + let (model_id, expected_fingerprint) = match binding { + ExternalSubagentModelBinding::Fixed { + model_id, + configuration_fingerprint, + } => (model_id.clone(), Some(configuration_fingerprint.as_str())), + ExternalSubagentModelBinding::InheritParent => { + let parent_model_selection = parent_model_selection + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + BitFunError::Validation( + "Approved inherited subagent model has no parent model selection" + .to_string(), + ) + })?; + ( + ai_config + .resolve_model_selection(parent_model_selection) + .ok_or_else(|| { + BitFunError::Validation(format!( + "Parent model selection is unknown or disabled: {parent_model_selection}" + )) + })?, + None, + ) + } + }; + let model = ai_config + .models + .iter() + .find(|model| model.enabled && model.id == model_id) + .ok_or_else(|| { + BitFunError::Validation(format!( + "Approved subagent model configuration is unknown or disabled: {model_id}" + )) + })?; + let fingerprint = model_runtime_binding_fingerprint(model); + if expected_fingerprint.is_some_and(|expected| expected != fingerprint) { + return Err(BitFunError::Validation( + "Approved subagent model configuration changed; review the external agent again" + .to_string(), + )); + } + Ok((model_id, fingerprint)) +} + fn inherit_matching_parent_workspace_binding( parent_config: &SessionConfig, child_config: &mut SessionConfig, @@ -8286,6 +8338,38 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet normalize_model_selection(&model_selection).await } + async fn resolve_approved_external_model_binding( + &self, + binding: &ExternalSubagentModelBinding, + parent_session_id: &str, + ) -> BitFunResult<(String, String)> { + let config_service = get_global_config_service().await.map_err(|error| { + BitFunError::AIClient(format!( + "Failed to load AI configuration for approved subagent binding: {error}" + )) + })?; + let ai_config: AIConfig = config_service + .get_config(Some("ai")) + .await + .map_err(|error| { + BitFunError::AIClient(format!( + "Failed to read AI configuration for approved subagent binding: {error}" + )) + })?; + let parent_model_selection = + if matches!(binding, ExternalSubagentModelBinding::InheritParent) { + let defaults = Self::agent_model_defaults().await; + Some(self.parent_model_selection(parent_session_id, &defaults)?) + } else { + None + }; + resolve_approved_immutable_model_binding( + binding, + parent_model_selection.as_deref(), + &ai_config, + ) + } + async fn resolve_hidden_subagent_execution_request( &self, request: SubagentExecutionRequest, @@ -8431,7 +8515,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .to_string(), ) })?; - let resolved_model_id = if matches!( + let (resolved_model_id, immutable_model_fingerprint) = if matches!( request.model_binding_policy, SessionModelBindingPolicy::ApprovedImmutable ) { @@ -8440,24 +8524,31 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "An approved immutable subagent model cannot be overridden".to_string(), )); } - approved_model_binding - .as_ref() - .map(|binding| binding.model_id.clone()) - .ok_or_else(|| { - BitFunError::Validation( - "Approved immutable subagent generation has no concrete model binding" - .to_string(), - ) - })? + let binding = approved_model_binding.as_ref().ok_or_else(|| { + BitFunError::Validation( + "Approved immutable subagent generation has no model binding" + .to_string(), + ) + })?; + let resolved = self + .resolve_approved_external_model_binding( + binding, + &request.subagent_parent_info.session_id, + ) + .await?; + (resolved.0, Some(resolved.1)) } else { - self.resolve_fresh_subagent_model_id( - model_id.as_deref(), - inherit_parent_model, - &agent_type, - &workspace_path, - &request.subagent_parent_info.session_id, + ( + self.resolve_fresh_subagent_model_id( + model_id.as_deref(), + inherit_parent_model, + &agent_type, + &workspace_path, + &request.subagent_parent_info.session_id, + ) + .await?, + None, ) - .await? }; let logical_agent_type = logical_subagent_type_or_runtime( request.logical_subagent_type.as_deref(), @@ -8474,9 +8565,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ); session_config.continuation_policy = request.continuation_policy; session_config.model_binding_policy = request.model_binding_policy; - session_config.model_binding_fingerprint = approved_model_binding - .as_ref() - .map(|binding| binding.configuration_fingerprint.clone()); + session_config.model_binding_fingerprint = immutable_model_fingerprint; Ok(HiddenSubagentExecutionRequest { target_session_id: None, @@ -10988,6 +11077,7 @@ mod tests { ManualCompactionCommitGate, SessionMemoryMode, SessionReferenceLocator, SessionRelationshipKind, SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, }; + use crate::agentic::agents::ExternalSubagentModelBinding; use crate::agentic::coordination::coordination_store::{ BackgroundTaskRegistration, RegisteredBackgroundTask, }; @@ -11034,6 +11124,9 @@ mod tests { assert_eq!(summary.model_id.as_deref(), Some("fast")); } use crate::runtime_ownership::CoreRuntimeOwnership; + use crate::service::config::types::{ + model_runtime_binding_fingerprint, AIConfig, AIModelConfig, + }; use crate::service::config::{AgentModelDefaultsConfig, SubagentModelSelection}; use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; use crate::service::session::{ @@ -14046,6 +14139,70 @@ mod tests { assert_eq!(model_id, "primary"); } + #[test] + fn approved_external_inherit_resolves_parent_once_to_a_concrete_runtime_fingerprint() { + let model = AIModelConfig { + id: "model-primary".to_string(), + name: "Provider".to_string(), + provider: "provider".to_string(), + model_name: "model-name".to_string(), + enabled: true, + ..AIModelConfig::default() + }; + let mut config = AIConfig { + models: vec![model.clone()], + ..AIConfig::default() + }; + config.default_models.primary = Some(model.id.clone()); + + let resolved = super::resolve_approved_immutable_model_binding( + &ExternalSubagentModelBinding::InheritParent, + Some("primary"), + &config, + ) + .expect("inherit should materialize the current parent selection"); + assert_eq!(resolved.0, "model-primary"); + assert_eq!(resolved.1, model_runtime_binding_fingerprint(&model)); + + config.models[0].enabled = false; + assert!(super::resolve_approved_immutable_model_binding( + &ExternalSubagentModelBinding::InheritParent, + Some("primary"), + &config, + ) + .is_err()); + } + + #[test] + fn approved_external_fixed_binding_rejects_changed_runtime_configuration() { + let mut model = AIModelConfig { + id: "model-review".to_string(), + name: "Provider".to_string(), + provider: "provider".to_string(), + model_name: "model-name".to_string(), + enabled: true, + ..AIModelConfig::default() + }; + let fingerprint = model_runtime_binding_fingerprint(&model); + let binding = ExternalSubagentModelBinding::Fixed { + model_id: model.id.clone(), + configuration_fingerprint: fingerprint.clone(), + }; + let mut config = AIConfig { + models: vec![model.clone()], + ..AIConfig::default() + }; + + assert_eq!( + super::resolve_approved_immutable_model_binding(&binding, None, &config).unwrap(), + ("model-review".to_string(), fingerprint) + ); + + model.base_url = "https://changed.example/v1".to_string(); + config.models[0] = model; + assert!(super::resolve_approved_immutable_model_binding(&binding, None, &config).is_err()); + } + #[tokio::test] async fn fresh_subagent_inherits_matching_parent_worktree_binding() { let (coordinator, session_manager) = test_coordinator(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index c52dd5bbfb..e6727d61eb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -12,6 +12,13 @@ fn resolve_focused_review_model_selection( } } +fn external_subagent_model_override_requested( + model_id: Option<&str>, + inherit_parent_model: bool, +) -> bool { + model_id.is_some() || inherit_parent_model +} + fn build_deep_review_subagent_context( role: DeepReviewSubagentRole, subagent_type: Option<&str>, @@ -267,7 +274,12 @@ impl TaskTool { ))); } supports_follow_up = binding.supports_follow_up; - if !supports_follow_up && model_id.is_some() { + if !supports_follow_up + && external_subagent_model_override_requested( + model_id.as_deref(), + inherit_parent_model, + ) + { return Err(BitFunError::tool( "external_subagent_model_override_unsupported: external subagents use the approved model binding" .to_string(), @@ -1226,6 +1238,16 @@ mod target_context_tests { ); } + #[test] + fn external_subagent_rejects_fixed_and_inherited_caller_model_overrides() { + assert!(external_subagent_model_override_requested( + Some("caller-model"), + false + )); + assert!(external_subagent_model_override_requested(None, true)); + assert!(!external_subagent_model_override_requested(None, false)); + } + #[test] fn deep_review_child_context_preserves_target_evidence_for_tools() { let manifest = json!({ diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index c13ac866ec..9892598003 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -32,7 +32,9 @@ pub use bitfun_product_domains::external_sources::{ }; pub use bitfun_product_domains::external_subagents::{ ExternalSubagentActivationState, ExternalSubagentCompatibilityState, ExternalSubagentConflict, - ExternalSubagentConflictCandidate, ExternalSubagentSummary, + ExternalSubagentConflictCandidate, ExternalSubagentModelBindingGroup, + ExternalSubagentModelBindingMethod, ExternalSubagentModelBindingOption, + ExternalSubagentModelBindingTarget, ExternalSubagentModelRequest, ExternalSubagentSummary, }; use crate::external_mcp::{ @@ -497,6 +499,8 @@ struct ExternalSourcesConfig { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] subagent_conflict_lineage_current_keys: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + subagent_model_bindings: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] mcp_server_decisions: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] mcp_conflict_choices: BTreeMap, @@ -542,6 +546,7 @@ impl std::fmt::Debug for ExternalSourcesConfig { "subagent_conflict_lineage_current_keys", &self.subagent_conflict_lineage_current_keys, ) + .field("subagent_model_bindings", &self.subagent_model_bindings) .field("mcp_server_decisions", &self.mcp_server_decisions) .field("mcp_conflict_choices", &self.mcp_conflict_choices) .field("extensions", &self.extensions) @@ -1472,6 +1477,7 @@ impl WorkspaceExternalSourceService { declined_decisions: &preferences.declined_subagent_decisions, conflict_choices: &preferences.subagent_conflict_choices, conflict_lineage_current_keys: &preferences.subagent_conflict_lineage_current_keys, + model_bindings: &preferences.subagent_model_bindings, }, ) .await; @@ -1505,6 +1511,7 @@ impl WorkspaceExternalSourceService { conflict_choices: &preferences.subagent_conflict_choices, conflict_lineage_current_keys: &preferences .subagent_conflict_lineage_current_keys, + model_bindings: &preferences.subagent_model_bindings, }, ) .await; @@ -1691,6 +1698,7 @@ impl WorkspaceExternalSourceService { declined_decisions: &preferences.declined_subagent_decisions, conflict_choices: &preferences.subagent_conflict_choices, conflict_lineage_current_keys: &preferences.subagent_conflict_lineage_current_keys, + model_bindings: &preferences.subagent_model_bindings, }, ); merge_subagent_state( @@ -2976,6 +2984,30 @@ impl WorkspaceExternalSourceService { self.rebuild_product_snapshot(command_snapshot).await } + async fn set_subagent_model_binding( + &self, + binding_key: &str, + target: Option, + expected_subagent_generation: u64, + expected_preference_revision: u64, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let snapshot = self.snapshot(); + validate_subagent_model_binding_mutation( + &snapshot, + binding_key, + target.as_ref(), + expected_subagent_generation, + expected_preference_revision, + )?; + let preferences = + persist_subagent_model_binding(binding_key, target, expected_preference_revision) + .await?; + propagate_subagent_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + async fn choose_subagent_conflict( &self, conflict_key: &str, @@ -4128,6 +4160,8 @@ fn merge_subagent_state( snapshot.subagent_generation = coordinator_snapshot.generation; snapshot.preference_revision = preference_revision; snapshot.subagents = state.summaries.clone(); + snapshot.subagent_model_binding_groups = state.model_binding_groups.clone(); + snapshot.subagent_model_binding_options = state.model_binding_options.clone(); snapshot.subagent_conflicts = state.conflicts.clone(); snapshot.pending_subagent_approvals = state.pending_approvals.clone(); snapshot @@ -4415,6 +4449,99 @@ async fn persist_subagent_conflict_choice_with_store( }) } +fn validate_subagent_model_binding_mutation( + snapshot: &ExternalSourceCatalogSnapshot, + binding_key: &str, + target: Option<&ExternalSubagentModelBindingTarget>, + expected_subagent_generation: u64, + expected_preference_revision: u64, +) -> Result<(), String> { + if snapshot.subagent_generation != expected_subagent_generation + || snapshot.preference_revision != expected_preference_revision + { + return Err(stale_operation_error( + "External subagent catalog changed; refresh before retrying", + )); + } + let group = snapshot + .subagent_model_binding_groups + .iter() + .find(|group| group.binding_key == binding_key) + .ok_or_else(|| { + missing_candidate_error("External subagent model binding is no longer available") + })?; + if !matches!( + group.method, + ExternalSubagentModelBindingMethod::BindingRequired + | ExternalSubagentModelBindingMethod::Explicit + | ExternalSubagentModelBindingMethod::BindingUnavailable + ) { + return Err(unavailable_operation_error( + "External subagent model binding is read-only in its current state", + )); + } + if let Some(target) = target { + if !snapshot + .subagent_model_binding_options + .iter() + .any(|option| &option.target == target) + { + return Err(unavailable_operation_error( + "External subagent model binding target is unavailable", + )); + } + } + Ok(()) +} + +async fn persist_subagent_model_binding( + binding_key: &str, + target: Option, + expected_preference_revision: u64, +) -> Result { + let store = ExternalSourcePreferenceStore::global()?; + persist_subagent_model_binding_with_store( + &store, + binding_key, + target, + expected_preference_revision, + ) + .await +} + +async fn persist_subagent_model_binding_with_store( + store: &ExternalSourcePreferenceStore, + binding_key: &str, + target: Option, + expected_preference_revision: u64, +) -> Result { + let binding_key = binding_key.to_string(); + store + .update(move |config| { + if config.preference_revision != expected_preference_revision { + return false; + } + match target { + Some(target) => { + config.subagent_model_bindings.insert(binding_key, target); + } + None => { + config.subagent_model_bindings.remove(&binding_key); + } + } + config.preference_revision = config.preference_revision.saturating_add(1); + true + }) + .await + .and_then(|(applied, config)| { + applied.then_some(config).ok_or_else(|| { + stale_operation_error( + "External subagent preferences changed; refresh before retrying", + ) + }) + }) +} + async fn persist_mcp_server_decision( decision_key: &str, approved: bool, @@ -5660,6 +5787,24 @@ pub async fn set_external_subagent_activation( .await } +pub async fn set_external_subagent_model_binding( + workspace_root: Option<&Path>, + binding_key: &str, + target: Option, + expected_subagent_generation: u64, + expected_preference_revision: u64, +) -> Result { + service_for(workspace_root) + .await? + .set_subagent_model_binding( + binding_key, + target, + expected_subagent_generation, + expected_preference_revision, + ) + .await +} + pub async fn choose_external_subagent_conflict( workspace_root: Option<&Path>, conflict_key: &str, @@ -6301,6 +6446,193 @@ mod tests { assert!(!debug.contains("private-revision-secret")); } + #[tokio::test] + async fn external_subagent_model_bindings_share_the_existing_atomic_preference_owner() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let process_a = ExternalSourcePreferenceStore::new(path.clone()); + let process_b = ExternalSourcePreferenceStore::new(path); + let workspace_a = "external_subagent_model_binding:workspace-a"; + let workspace_b = "external_subagent_model_binding:workspace-b"; + + let first = persist_subagent_model_binding_with_store( + &process_a, + workspace_a, + Some(ExternalSubagentModelBindingTarget::Primary), + 0, + ) + .await + .unwrap(); + assert_eq!(first.preference_revision, 1); + assert_eq!( + first.subagent_model_bindings.get(workspace_a), + Some(&ExternalSubagentModelBindingTarget::Primary) + ); + + let merged = persist_subagent_model_binding_with_store( + &process_b, + workspace_b, + Some(ExternalSubagentModelBindingTarget::Model { + model_id: "glm-project".to_string(), + }), + first.preference_revision, + ) + .await + .unwrap(); + assert_eq!(merged.preference_revision, 2); + assert_eq!(merged.subagent_model_bindings.len(), 2); + + let error = persist_subagent_model_binding_with_store( + &process_a, + workspace_a, + Some(ExternalSubagentModelBindingTarget::Fast), + first.preference_revision, + ) + .await + .expect_err("a stale process must not overwrite a newer workspace binding"); + assert_eq!( + ExternalSourceOperationError::decode(&error) + .expect("stale binding writes use the typed error contract") + .code, + ExternalSourceOperationErrorCode::StaleRevision + ); + + let replaced = persist_subagent_model_binding_with_store( + &process_a, + workspace_a, + Some(ExternalSubagentModelBindingTarget::Fast), + merged.preference_revision, + ) + .await + .unwrap(); + assert_eq!(replaced.preference_revision, 3); + assert_eq!( + replaced.subagent_model_bindings.get(workspace_a), + Some(&ExternalSubagentModelBindingTarget::Fast) + ); + + let cleared = persist_subagent_model_binding_with_store( + &process_b, + workspace_a, + None, + replaced.preference_revision, + ) + .await + .unwrap(); + assert_eq!(cleared.preference_revision, 4); + assert!(!cleared.subagent_model_bindings.contains_key(workspace_a)); + assert!(cleared.subagent_model_bindings.contains_key(workspace_b)); + } + + #[test] + fn external_subagent_model_binding_mutation_fails_closed_against_the_current_snapshot() { + let binding_key = "external_subagent_model_binding:known"; + let mut snapshot = ExternalSourceCatalogSnapshot { + generation: 0, + discovery_pending: false, + sources: Vec::new(), + commands: Vec::new(), + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 0, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 7, + preference_revision: 11, + subagents: Vec::new(), + subagent_model_binding_groups: vec![ExternalSubagentModelBindingGroup { + binding_key: binding_key.to_string(), + request: ExternalSubagentModelRequest::Reference { + provider_hint: Some("openai".to_string()), + model_name: "gpt-project".to_string(), + }, + scope: ExternalSourceScope::Project, + method: ExternalSubagentModelBindingMethod::BindingRequired, + selected_target: None, + effective_model_label: None, + affected_candidate_ids: vec!["review".to_string()], + }], + subagent_model_binding_options: vec![ExternalSubagentModelBindingOption { + target: ExternalSubagentModelBindingTarget::Primary, + effective_model_label: "Primary model".to_string(), + }], + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + assert!(validate_subagent_model_binding_mutation( + &snapshot, + binding_key, + Some(&ExternalSubagentModelBindingTarget::Primary), + 7, + 11, + ) + .is_ok()); + assert!( + validate_subagent_model_binding_mutation(&snapshot, binding_key, None, 7, 11,).is_ok() + ); + + for (key, target, generation, revision, expected_code) in [ + ( + "external_subagent_model_binding:missing", + None, + 7, + 11, + ExternalSourceOperationErrorCode::NotFound, + ), + ( + binding_key, + Some(&ExternalSubagentModelBindingTarget::Fast), + 7, + 11, + ExternalSourceOperationErrorCode::Unavailable, + ), + ( + binding_key, + None, + 8, + 11, + ExternalSourceOperationErrorCode::StaleRevision, + ), + ( + binding_key, + None, + 7, + 12, + ExternalSourceOperationErrorCode::StaleRevision, + ), + ] { + let error = validate_subagent_model_binding_mutation( + &snapshot, key, target, generation, revision, + ) + .expect_err("invalid binding mutations must fail closed"); + assert_eq!( + ExternalSourceOperationError::decode(&error).unwrap().code, + expected_code + ); + } + + snapshot.subagent_model_binding_groups[0].method = + ExternalSubagentModelBindingMethod::Exact; + let error = validate_subagent_model_binding_mutation( + &snapshot, + binding_key, + Some(&ExternalSubagentModelBindingTarget::Primary), + 7, + 11, + ) + .expect_err("exact source matches are read-only"); + assert_eq!( + ExternalSourceOperationError::decode(&error).unwrap().code, + ExternalSourceOperationErrorCode::Unavailable + ); + } + #[test] fn only_model_configuration_events_refresh_external_model_bindings() { assert!(config_update_refreshes_external_model_bindings( @@ -6369,6 +6701,8 @@ mod tests { subagents: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), integration_policy: Default::default(), diagnostics: Vec::new(), }; @@ -6678,6 +7012,8 @@ mod tests { subagents: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), integration_policy: Default::default(), diagnostics: vec![ExternalSourceDiagnostic::warning( "future.tool.file_read_failed", @@ -6766,6 +7102,8 @@ mod tests { subagents: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), integration_policy: Default::default(), diagnostics: Vec::new(), }; diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index fc300ee1e6..3322318ff4 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -12,7 +12,7 @@ use crate::agentic::agents::{ }; use crate::agentic::tools::registry::get_all_registered_tools; use crate::external_sources::safe_external_source_location; -use crate::external_tools::resolve_external_tool_for_workspace; +use crate::external_tools::{resolve_external_tool_for_workspace, workspace_route_key}; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{model_runtime_binding_fingerprint, AIConfig, AIModelConfig}; use crate::service::config::SubagentModelSelection; @@ -22,9 +22,12 @@ use bitfun_product_domains::external_sources::EcosystemId; use bitfun_product_domains::external_sources::{ExternalSourceScope, ProviderId, SourceKey}; use bitfun_product_domains::external_subagents::{ external_subagent_approval_key, external_subagent_conflict_key, - ExternalSubagentActivationState, ExternalSubagentCompatibilityState, ExternalSubagentConflict, + external_subagent_model_binding_key, ExternalSubagentActivationState, + ExternalSubagentCompatibilityState, ExternalSubagentConflict, ExternalSubagentConflictCandidate, ExternalSubagentDefinition, - ExternalSubagentDiagnosticSummary, ExternalSubagentModelRequest, ExternalSubagentSummary, + ExternalSubagentDiagnosticSummary, ExternalSubagentModelBindingGroup, + ExternalSubagentModelBindingMethod, ExternalSubagentModelBindingOption, + ExternalSubagentModelBindingTarget, ExternalSubagentModelRequest, ExternalSubagentSummary, }; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -41,6 +44,7 @@ pub(super) struct ExternalSubagentDecisions<'a> { pub declined_decisions: &'a BTreeMap, pub conflict_choices: &'a BTreeMap, pub conflict_lineage_current_keys: &'a BTreeMap, + pub model_bindings: &'a BTreeMap, } #[derive(Default)] @@ -48,6 +52,8 @@ pub(super) struct ExternalSubagentProductState { pub summaries: Vec, pub conflicts: Vec, pub pending_approvals: Vec, + pub model_binding_groups: Vec, + pub model_binding_options: Vec, pub registrations: Vec, pub routes: BTreeMap, pub observed_conflict_lineage_current_keys: BTreeMap, @@ -82,9 +88,10 @@ struct ResolvedExternalCandidate { scope: ExternalSourceScope, source_keys: Vec, source_location_labels: Vec, - model_id: String, - model_label: String, - model_configuration_fingerprint: String, + model: ResolvedCandidateModel, + model_binding_method: ExternalSubagentModelBindingMethod, + model_binding_key: Option, + selected_model_binding_target: Option, tools: Vec, unavailable_tool_labels: Vec, readonly: bool, @@ -95,6 +102,37 @@ struct ResolvedExternalCandidate { compatibility: ExternalSubagentCompatibilityState, } +#[derive(Clone, Debug, PartialEq, Eq)] +enum ResolvedCandidateModel { + Fixed(ResolvedModelFact), + InheritParent, + Unavailable, +} + +impl ResolvedCandidateModel { + fn effective_label(&self) -> Option<&str> { + match self { + Self::Fixed(model) => Some(model.display_label.as_str()), + Self::InheritParent | Self::Unavailable => None, + } + } + + fn approval_identity(&self) -> (&str, &str) { + match self { + Self::Fixed(model) => (&model.runtime_id, &model.configuration_fingerprint), + Self::InheritParent => ("inherit_parent", "inherit_parent_v1"), + Self::Unavailable => ("unavailable", "unavailable"), + } + } +} + +struct ResolvedModelRequest { + model: ResolvedCandidateModel, + method: ExternalSubagentModelBindingMethod, + binding_key: Option, + selected_target: Option, +} + pub(super) async fn reconcile_external_subagents( workspace_root: Option<&Path>, execution_domain_id: &str, @@ -135,6 +173,7 @@ pub(super) fn project_external_subagents_read_only( &source_map, &snapshot.provider_labels, &facts, + decisions.model_bindings, ); let ecosystem_active = resolved.source_keys.iter().all(|source_key| { source_map @@ -396,14 +435,24 @@ fn resolve_exact_external_model( return None; } + if provider_hint.is_none() { + let mut id_matches = ai_config + .models + .iter() + .filter(|model| model.enabled && model.id == model_name); + if let Some(model) = id_matches.next() { + if id_matches.next().is_none() { + return Some(resolved_model_fact(model)); + } + return None; + } + } + let mut matches = ai_config .models .iter() .filter(|model| model.enabled) .filter(|model| { - if provider_hint.is_none() && model.id == model_name { - return true; - } if model.model_name != model_name { return false; } @@ -418,11 +467,90 @@ fn resolve_exact_external_model( if matches.next().is_some() { return None; } - Some(ResolvedModelFact { + Some(resolved_model_fact(model)) +} + +fn resolved_model_fact(model: &AIModelConfig) -> ResolvedModelFact { + ResolvedModelFact { runtime_id: model.id.clone(), display_label: model_display_label(model), configuration_fingerprint: model_runtime_binding_fingerprint(model), - }) + } +} + +fn resolve_model_binding_target( + target: &ExternalSubagentModelBindingTarget, + ai_config: &AIConfig, +) -> Option { + let runtime_id = match target { + ExternalSubagentModelBindingTarget::Primary => { + ai_config.resolve_model_selection("primary")? + } + ExternalSubagentModelBindingTarget::Fast => ai_config.resolve_model_selection("fast")?, + ExternalSubagentModelBindingTarget::Model { model_id } => { + ai_config.resolve_model_reference(model_id)? + } + }; + ai_config + .models + .iter() + .find(|model| model.enabled && model.id == runtime_id) + .map(resolved_model_fact) +} + +fn external_model_binding_options(ai_config: &AIConfig) -> Vec { + let mut options = Vec::new(); + for target in [ + ExternalSubagentModelBindingTarget::Primary, + ExternalSubagentModelBindingTarget::Fast, + ] { + if let Some(model) = resolve_model_binding_target(&target, ai_config) { + options.push(ExternalSubagentModelBindingOption { + target, + effective_model_label: model.display_label, + }); + } + } + let mut configured = ai_config + .models + .iter() + .filter(|model| model.enabled) + .map(|model| ExternalSubagentModelBindingOption { + target: ExternalSubagentModelBindingTarget::Model { + model_id: model.id.clone(), + }, + effective_model_label: model_display_label(model), + }) + .collect::>(); + let label_counts = + configured + .iter() + .fold(BTreeMap::::new(), |mut counts, option| { + *counts + .entry(option.effective_model_label.clone()) + .or_default() += 1; + counts + }); + for option in &mut configured { + if label_counts + .get(&option.effective_model_label) + .copied() + .unwrap_or_default() + > 1 + { + let ExternalSubagentModelBindingTarget::Model { model_id } = &option.target else { + continue; + }; + option.effective_model_label = format!("{} · {model_id}", option.effective_model_label); + } + } + configured.sort_by(|left, right| { + left.effective_model_label + .cmp(&right.effective_model_label) + .then(left.target.cmp(&right.target)) + }); + options.extend(configured); + options } fn resolve_bitfun_subagent_model( @@ -453,6 +581,95 @@ fn resolve_bitfun_subagent_model( } } +fn resolve_model_request( + definition: &ExternalSubagentDefinition, + ecosystem_id: Option<&EcosystemId>, + scope: ExternalSourceScope, + workspace_scope: &str, + execution_domain_id: &str, + ai_config: Option<&AIConfig>, + model_bindings: &BTreeMap, +) -> ResolvedModelRequest { + match &definition.requested_model { + ExternalSubagentModelRequest::Default => ResolvedModelRequest { + model: ai_config + .and_then(|config| resolve_bitfun_subagent_model(&definition.logical_id, config)) + .map(ResolvedCandidateModel::Fixed) + .unwrap_or(ResolvedCandidateModel::Unavailable), + method: ExternalSubagentModelBindingMethod::Default, + binding_key: None, + selected_target: None, + }, + ExternalSubagentModelRequest::Inherit => ResolvedModelRequest { + model: if ai_config.is_some() { + ResolvedCandidateModel::InheritParent + } else { + ResolvedCandidateModel::Unavailable + }, + method: ExternalSubagentModelBindingMethod::Inherit, + binding_key: None, + selected_target: None, + }, + ExternalSubagentModelRequest::Reference { + provider_hint, + model_name, + } => { + let binding_key = ecosystem_id.and_then(|ecosystem_id| { + external_subagent_model_binding_key( + ecosystem_id, + &definition.requested_model, + execution_domain_id, + scope, + workspace_scope, + ) + }); + if let Some(model) = ai_config.and_then(|config| { + resolve_exact_external_model(provider_hint.as_deref(), model_name, config) + }) { + return ResolvedModelRequest { + model: ResolvedCandidateModel::Fixed(model), + method: ExternalSubagentModelBindingMethod::Exact, + binding_key, + selected_target: None, + }; + } + let selected_target = binding_key + .as_ref() + .and_then(|key| model_bindings.get(key)) + .cloned(); + match (ai_config, selected_target.clone()) { + (Some(config), Some(target)) => match resolve_model_binding_target(&target, config) + { + Some(model) => ResolvedModelRequest { + model: ResolvedCandidateModel::Fixed(model), + method: ExternalSubagentModelBindingMethod::Explicit, + binding_key, + selected_target: Some(target), + }, + None => ResolvedModelRequest { + model: ResolvedCandidateModel::Unavailable, + method: ExternalSubagentModelBindingMethod::BindingUnavailable, + binding_key, + selected_target: Some(target), + }, + }, + (Some(_), None) => ResolvedModelRequest { + model: ResolvedCandidateModel::Unavailable, + method: ExternalSubagentModelBindingMethod::BindingRequired, + binding_key, + selected_target: None, + }, + (None, target) => ResolvedModelRequest { + model: ResolvedCandidateModel::Unavailable, + method: ExternalSubagentModelBindingMethod::BindingUnavailable, + binding_key, + selected_target: target, + }, + } + } + } +} + fn reconcile_with_facts( workspace_root: Option<&Path>, execution_domain_id: &str, @@ -467,6 +684,7 @@ fn reconcile_with_facts( .map(|entry| (entry.record.key.clone(), &entry.record)) .collect::>(); let mut state = ExternalSubagentProductState::default(); + let mut model_binding_groups = BTreeMap::::new(); let mut by_logical = BTreeMap::>::new(); for definition in &snapshot.definitions { @@ -477,6 +695,7 @@ fn reconcile_with_facts( &source_map, &snapshot.provider_labels, facts, + decisions.model_bindings, ); let ecosystem_active = resolved.source_keys.iter().all(|source_key| { source_map @@ -505,6 +724,7 @@ fn reconcile_with_facts( state.summaries.push(summary); continue; } + collect_model_binding_group(&resolved, &mut model_binding_groups); if matches!( resolved.compatibility, ExternalSubagentCompatibilityState::Blocked @@ -513,7 +733,7 @@ fn reconcile_with_facts( state.summaries.push(summary); continue; } - if has_configuration_unavailable_diagnostic(&resolved) { + if has_runtime_unavailable_diagnostic(&resolved) { state.summaries.push(summary); continue; } @@ -538,6 +758,7 @@ fn reconcile_with_facts( .cmp(&right.logical_id) .then(left.candidate_id.cmp(&right.candidate_id)) }); + finalize_model_binding_catalog(&mut state, model_binding_groups, facts.ai_config.as_ref()); return state; } @@ -615,9 +836,67 @@ fn reconcile_with_facts( }); state.pending_approvals.sort(); state.pending_approvals.dedup(); + finalize_model_binding_catalog(&mut state, model_binding_groups, facts.ai_config.as_ref()); state } +fn finalize_model_binding_catalog( + state: &mut ExternalSubagentProductState, + groups: BTreeMap, + ai_config: Option<&AIConfig>, +) { + state.model_binding_groups = finalized_model_binding_groups(groups); + if state.model_binding_groups.iter().any(|group| { + matches!( + group.method, + ExternalSubagentModelBindingMethod::BindingRequired + | ExternalSubagentModelBindingMethod::Explicit + | ExternalSubagentModelBindingMethod::BindingUnavailable + ) + }) { + state.model_binding_options = ai_config + .map(external_model_binding_options) + .unwrap_or_default(); + } +} + +fn collect_model_binding_group( + candidate: &ResolvedExternalCandidate, + groups: &mut BTreeMap, +) { + let Some(binding_key) = candidate.model_binding_key.as_ref() else { + return; + }; + let group = + groups + .entry(binding_key.clone()) + .or_insert_with(|| ExternalSubagentModelBindingGroup { + binding_key: binding_key.clone(), + request: candidate.definition.requested_model.clone(), + scope: candidate.scope, + method: candidate.model_binding_method, + selected_target: candidate.selected_model_binding_target.clone(), + effective_model_label: candidate.model.effective_label().map(str::to_string), + affected_candidate_ids: Vec::new(), + }); + group + .affected_candidate_ids + .push(candidate.definition.candidate_id.as_str().to_string()); +} + +fn finalized_model_binding_groups( + groups: BTreeMap, +) -> Vec { + groups + .into_values() + .map(|mut group| { + group.affected_candidate_ids.sort(); + group.affected_candidate_ids.dedup(); + group + }) + .collect() +} + fn resolve_external_candidate( workspace_root: Option<&Path>, execution_domain_id: &str, @@ -625,6 +904,7 @@ fn resolve_external_candidate( sources: &BTreeMap, provider_labels: &BTreeMap, facts: &ProductFacts, + model_bindings: &BTreeMap, ) -> ResolvedExternalCandidate { let mut compatibility = definition.compatibility; let mut diagnostics = definition @@ -639,41 +919,61 @@ fn resolve_external_candidate( ), }) .collect::>(); - let model = match facts.ai_config.as_ref() { - Some(ai_config) => match &definition.requested_model { - ExternalSubagentModelRequest::Default => { - resolve_bitfun_subagent_model(&definition.logical_id, ai_config) + let mut source_location_labels = Vec::new(); + let mut source_keys = Vec::new(); + let mut scope = ExternalSourceScope::UserGlobal; + let mut ecosystem_id = None; + for item in &definition.provenance { + if let Some(source) = sources.get(&item.contribution_id.source) { + ecosystem_id.get_or_insert_with(|| source.ecosystem_id.clone()); + if !source_keys.contains(&source.key) { + source_keys.push(source.key.clone()); + } + let location_label = + safe_external_source_location(source.scope, &source.location, workspace_root); + if !source_location_labels.contains(&location_label) { + source_location_labels.push(location_label); + } + if scope_rank(source.scope) >= scope_rank(scope) { + scope = source.scope; } - ExternalSubagentModelRequest::Exact { - provider_hint, - model_name, - } => resolve_exact_external_model(provider_hint.as_deref(), model_name, ai_config), - }, - None => { - diagnostics.push(ExternalSubagentDiagnosticSummary { - code: "external_subagent.configuration_unavailable".to_string(), - blocks_activation: true, - }); - None } - }; - let model = match model { - Some(model) => model, - None => { - if facts.ai_config.is_some() { - compatibility = ExternalSubagentCompatibilityState::Blocked; - diagnostics.push(ExternalSubagentDiagnosticSummary { - code: "external_subagent.model_unavailable".to_string(), - blocks_activation: true, - }); + } + let model_resolution = resolve_model_request( + definition, + ecosystem_id.as_ref(), + scope, + &workspace_scope_key(workspace_root), + execution_domain_id, + facts.ai_config.as_ref(), + model_bindings, + ); + if facts.ai_config.is_none() { + diagnostics.push(ExternalSubagentDiagnosticSummary { + code: "external_subagent.configuration_unavailable".to_string(), + blocks_activation: true, + }); + } else if matches!(model_resolution.model, ResolvedCandidateModel::Unavailable) { + let code = match model_resolution.method { + ExternalSubagentModelBindingMethod::BindingRequired => { + "external_subagent.model_binding_required" } - ResolvedModelFact { - runtime_id: "unavailable".to_string(), - display_label: "unavailable".to_string(), - configuration_fingerprint: "unavailable".to_string(), + ExternalSubagentModelBindingMethod::BindingUnavailable => { + "external_subagent.model_binding_unavailable" } + _ => "external_subagent.model_unavailable", + }; + if !matches!( + model_resolution.method, + ExternalSubagentModelBindingMethod::BindingUnavailable + ) { + compatibility = ExternalSubagentCompatibilityState::Blocked; } - }; + diagnostics.push(ExternalSubagentDiagnosticSummary { + code: code.to_string(), + blocks_activation: true, + }); + } let mut tools = Vec::new(); let mut unavailable_tool_labels = Vec::new(); @@ -713,13 +1013,15 @@ fn resolve_external_candidate( .iter() .map(|item| item.contribution_id.stable_key()) .collect::>(); + let (model_approval_id, model_approval_fingerprint) = + model_resolution.model.approval_identity(); let activation_envelope = stable_digest( [ execution_domain_id, definition.candidate_id.as_str(), &format!("{:?}", definition.mode), - model.runtime_id.as_str(), - model.configuration_fingerprint.as_str(), + model_approval_id, + model_approval_fingerprint, if definition.hidden { "hidden" } else { @@ -759,33 +1061,16 @@ fn resolve_external_candidate( .and_then(|provider_id| provider_labels.get(provider_id)) .cloned() .unwrap_or_else(|| "External AI app".to_string()); - let mut source_location_labels = Vec::new(); - let mut source_keys = Vec::new(); - let mut scope = ExternalSourceScope::UserGlobal; - for item in &definition.provenance { - if let Some(source) = sources.get(&item.contribution_id.source) { - if !source_keys.contains(&source.key) { - source_keys.push(source.key.clone()); - } - let location_label = - safe_external_source_location(source.scope, &source.location, workspace_root); - if !source_location_labels.contains(&location_label) { - source_location_labels.push(location_label); - } - if scope_rank(source.scope) >= scope_rank(scope) { - scope = source.scope; - } - } - } ResolvedExternalCandidate { definition: definition.clone(), provider_label, scope, source_keys, source_location_labels, - model_id: model.runtime_id, - model_label: model.display_label, - model_configuration_fingerprint: model.configuration_fingerprint, + model: model_resolution.model, + model_binding_method: model_resolution.method, + model_binding_key: model_resolution.binding_key, + selected_model_binding_target: model_resolution.selected_target, tools, unavailable_tool_labels, readonly, @@ -974,14 +1259,19 @@ fn install_active_candidate( candidate.readonly, candidate.definition.behavior_version.as_str().to_string(), )); + let model_binding = match &candidate.model { + ResolvedCandidateModel::Fixed(model) => ExternalSubagentModelBinding::Fixed { + model_id: model.runtime_id.clone(), + configuration_fingerprint: model.configuration_fingerprint.clone(), + }, + ResolvedCandidateModel::InheritParent => ExternalSubagentModelBinding::InheritParent, + ResolvedCandidateModel::Unavailable => return, + }; state.registrations.push(ExternalSubagentRegistration { runtime_key: runtime_key.clone(), logical_id: candidate.definition.logical_id.clone(), provider_label: candidate.provider_label.clone(), - model_binding: ExternalSubagentModelBinding { - model_id: candidate.model_id.clone(), - configuration_fingerprint: candidate.model_configuration_fingerprint.clone(), - }, + model_binding, hidden: candidate.definition.hidden, agent, }); @@ -1005,8 +1295,10 @@ fn summary_for( source_keys: candidate.source_keys.clone(), source_location_labels: candidate.source_location_labels.clone(), source_count: candidate.definition.provenance.len(), - effective_model_label: (candidate.model_id != "unavailable") - .then(|| candidate.model_label.clone()), + requested_model: candidate.definition.requested_model.clone(), + model_binding_method: candidate.model_binding_method, + model_binding_key: candidate.model_binding_key.clone(), + effective_model_label: candidate.model.effective_label().map(str::to_string), effective_tool_labels: candidate .tools .iter() @@ -1031,28 +1323,30 @@ fn initial_activation_state( ExternalSubagentCompatibilityState::Blocked | ExternalSubagentCompatibilityState::Invalid ) { ExternalSubagentActivationState::Blocked - } else if has_configuration_unavailable_diagnostic(candidate) { + } else if has_runtime_unavailable_diagnostic(candidate) { ExternalSubagentActivationState::Unavailable } else { ExternalSubagentActivationState::ApprovalRequired } } -fn has_configuration_unavailable_diagnostic(candidate: &ResolvedExternalCandidate) -> bool { +fn has_runtime_unavailable_diagnostic(candidate: &ResolvedExternalCandidate) -> bool { candidate.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "external_subagent.configuration_unavailable" - && diagnostic.blocks_activation + matches!( + diagnostic.code.as_str(), + "external_subagent.configuration_unavailable" + | "external_subagent.model_binding_unavailable" + ) && diagnostic.blocks_activation }) } fn workspace_scope_key(workspace_root: Option<&Path>) -> String { - workspace_root - .map(|path| { - path.to_string_lossy() - .replace('\\', "/") - .to_ascii_lowercase() - }) - .unwrap_or_else(|| "".to_string()) + let normalized = workspace_route_key(workspace_root).replace('\\', "/"); + if cfg!(windows) { + normalized.to_ascii_lowercase() + } else { + normalized + } } fn normalize_logical_id(value: &str) -> String { @@ -1116,6 +1410,12 @@ mod tests { BTreeSet::from([EcosystemId::new("fake").expect("valid test ecosystem")]) }) } + + fn empty_model_bindings() -> &'static BTreeMap { + static BINDINGS: std::sync::OnceLock> = + std::sync::OnceLock::new(); + BINDINGS.get_or_init(BTreeMap::new) + } use bitfun_product_domains::external_sources::{ EcosystemId, ExecutionDomainId, ExternalSourceCatalogEntry, ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceRecord, @@ -1123,8 +1423,9 @@ mod tests { use bitfun_product_domains::external_subagents::{ ExternalSubagentBehaviorVersion, ExternalSubagentCandidateId, ExternalSubagentContributionId, ExternalSubagentContributionRole, ExternalSubagentLocalId, - ExternalSubagentMode, ExternalSubagentProvenanceRef, ExternalSubagentToolRequest, - ExternalSubagentToolSelector, SecretText, + ExternalSubagentMode, ExternalSubagentModelBindingMethod, + ExternalSubagentModelBindingTarget, ExternalSubagentProvenanceRef, + ExternalSubagentToolRequest, ExternalSubagentToolSelector, SecretText, }; use bitfun_product_domains::tool_permissions::{ PermissionConstraintLayer, PermissionEffect, PermissionRule, @@ -1280,6 +1581,36 @@ mod tests { }; assert!(resolve_exact_external_model(None, "anthropic/claude-sonnet-4", &config).is_none()); + let options = external_model_binding_options(&config) + .into_iter() + .filter(|option| { + matches!( + option.target, + ExternalSubagentModelBindingTarget::Model { .. } + ) + }) + .collect::>(); + assert_eq!(options.len(), 2); + assert_ne!( + options[0].effective_model_label, options[1].effective_model_label, + "ambiguous configured models need distinguishable binding labels" + ); + } + + #[test] + fn exact_external_model_prefers_a_unique_config_id_before_provider_model_matching() { + let config = AIConfig { + models: vec![ + active_model("review-model", "OpenAI", "openai", "gpt-5"), + active_model("other", "Review Model", "review-model", "review-model"), + ], + ..AIConfig::default() + }; + + let resolved = resolve_exact_external_model(None, "review-model", &config) + .expect("a unique config id has priority"); + assert_eq!(resolved.runtime_id, "review-model"); + assert_eq!(resolved.display_label, "OpenAI · gpt-5"); } #[test] @@ -1314,6 +1645,255 @@ mod tests { assert!(resolve_bitfun_subagent_model("reviewer", &config).is_none()); } + #[test] + fn external_subagent_model_binding_groups_unknown_references_and_resolves_explicit_targets() { + let mut definition_snapshot = snapshot("behavior-v1", "catalog-v1"); + definition_snapshot.definitions[0].requested_model = + ExternalSubagentModelRequest::Reference { + provider_hint: Some("future-provider".to_string()), + model_name: "future-model".to_string(), + }; + let mut product_facts = facts(); + let ai_config = product_facts.ai_config.as_mut().unwrap(); + ai_config.default_models.primary = Some("model_fast".to_string()); + let empty_set = BTreeSet::new(); + let empty_decisions = BTreeMap::new(); + let empty_bindings = BTreeMap::new(); + + let unbound = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &empty_set, + declined_decisions: &empty_decisions, + conflict_choices: &empty_decisions, + conflict_lineage_current_keys: &empty_decisions, + model_bindings: &empty_bindings, + }, + &product_facts, + ); + + assert_eq!(unbound.model_binding_groups.len(), 1); + assert!(!unbound.model_binding_options.is_empty()); + let group = &unbound.model_binding_groups[0]; + assert_eq!( + group.method, + ExternalSubagentModelBindingMethod::BindingRequired + ); + assert_eq!( + group.affected_candidate_ids, + ["external_subagent:fake:reviewer:candidate"] + ); + assert_eq!( + unbound.summaries[0].model_binding_method, + ExternalSubagentModelBindingMethod::BindingRequired + ); + assert_eq!( + unbound.summaries[0].activation_state, + ExternalSubagentActivationState::Blocked + ); + + let inactive_ecosystems = BTreeSet::new(); + let inactive = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + active_ecosystems: &inactive_ecosystems, + approved_envelopes: &empty_set, + declined_decisions: &empty_decisions, + conflict_choices: &empty_decisions, + conflict_lineage_current_keys: &empty_decisions, + model_bindings: &empty_bindings, + }, + &product_facts, + ); + assert!(inactive.model_binding_groups.is_empty()); + assert!(inactive.model_binding_options.is_empty()); + + let bindings = BTreeMap::from([( + group.binding_key.clone(), + ExternalSubagentModelBindingTarget::Primary, + )]); + let bound = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &empty_set, + declined_decisions: &empty_decisions, + conflict_choices: &empty_decisions, + conflict_lineage_current_keys: &empty_decisions, + model_bindings: &bindings, + }, + &product_facts, + ); + + let group = &bound.model_binding_groups[0]; + assert_eq!(group.method, ExternalSubagentModelBindingMethod::Explicit); + assert_eq!( + group.selected_target, + Some(ExternalSubagentModelBindingTarget::Primary) + ); + assert_eq!( + group.effective_model_label.as_deref(), + Some("Fast provider · fast-model") + ); + assert_eq!( + bound.summaries[0].model_binding_method, + ExternalSubagentModelBindingMethod::Explicit + ); + assert_eq!( + bound.summaries[0].activation_state, + ExternalSubagentActivationState::ApprovalRequired + ); + + let unavailable_bindings = BTreeMap::from([( + group.binding_key.clone(), + ExternalSubagentModelBindingTarget::Model { + model_id: "removed-model".to_string(), + }, + )]); + let unavailable = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &empty_set, + declined_decisions: &empty_decisions, + conflict_choices: &empty_decisions, + conflict_lineage_current_keys: &empty_decisions, + model_bindings: &unavailable_bindings, + }, + &product_facts, + ); + assert_eq!( + unavailable.model_binding_groups[0].method, + ExternalSubagentModelBindingMethod::BindingUnavailable + ); + assert_eq!( + unavailable.summaries[0].activation_state, + ExternalSubagentActivationState::Unavailable + ); + } + + #[test] + fn external_subagent_model_binding_aggregates_matching_references_but_not_workspaces() { + let mut definition_snapshot = snapshot("behavior-v1", "catalog-v1"); + definition_snapshot.sources[0].record.scope = ExternalSourceScope::Project; + let request = ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "unknown-model".to_string(), + }; + definition_snapshot.definitions[0].requested_model = request.clone(); + let mut second = definition_snapshot.definitions[0].clone(); + second.candidate_id = + ExternalSubagentCandidateId::new("external_subagent:fake:second:candidate").unwrap(); + second.logical_id = "second".to_string(); + second.provenance[0].contribution_id.local_id = + ExternalSubagentLocalId::new("second").unwrap(); + second.requested_model = request; + definition_snapshot.definitions.push(second); + let empty_set = BTreeSet::new(); + let empty_decisions = BTreeMap::new(); + let empty_bindings = BTreeMap::new(); + let decisions = ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &empty_set, + declined_decisions: &empty_decisions, + conflict_choices: &empty_decisions, + conflict_lineage_current_keys: &empty_decisions, + model_bindings: &empty_bindings, + }; + + let first = reconcile_with_facts( + Some(Path::new("C:/repo-a")), + "local-user", + &definition_snapshot, + decisions, + &facts(), + ); + assert_eq!(first.model_binding_groups.len(), 1); + assert_eq!( + first.model_binding_groups[0].affected_candidate_ids.len(), + 2 + ); + + let second = reconcile_with_facts( + Some(Path::new("C:/repo-b")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &empty_set, + declined_decisions: &empty_decisions, + conflict_choices: &empty_decisions, + conflict_lineage_current_keys: &empty_decisions, + model_bindings: &empty_bindings, + }, + &facts(), + ); + assert_ne!( + first.model_binding_groups[0].binding_key, + second.model_binding_groups[0].binding_key + ); + } + + #[test] + fn external_subagent_inherit_registers_intent_without_a_fixed_model_label() { + let mut definition_snapshot = snapshot("behavior-v1", "catalog-v1"); + definition_snapshot.definitions[0].requested_model = ExternalSubagentModelRequest::Inherit; + let empty_set = BTreeSet::new(); + let empty_decisions = BTreeMap::new(); + let empty_bindings = BTreeMap::new(); + let preview = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &empty_set, + declined_decisions: &empty_decisions, + conflict_choices: &empty_decisions, + conflict_lineage_current_keys: &empty_decisions, + model_bindings: &empty_bindings, + }, + &facts(), + ); + assert_eq!( + preview.summaries[0].model_binding_method, + ExternalSubagentModelBindingMethod::Inherit + ); + assert_eq!(preview.summaries[0].effective_model_label, None); + assert!(preview.model_binding_groups.is_empty()); + assert!(preview.model_binding_options.is_empty()); + + let approved = BTreeSet::from([preview.summaries[0].decision_key.clone()]); + let active = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &approved, + declined_decisions: &empty_decisions, + conflict_choices: &empty_decisions, + conflict_lineage_current_keys: &empty_decisions, + model_bindings: &empty_bindings, + }, + &facts(), + ); + assert_eq!(active.registrations.len(), 1); + assert_eq!( + active.registrations[0].model_binding, + ExternalSubagentModelBinding::InheritParent + ); + } + #[test] fn source_location_labels_hide_absolute_user_and_workspace_paths() { assert_eq!( @@ -1374,6 +1954,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &healthy_facts, ); @@ -1391,6 +1972,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &unavailable_facts, ); @@ -1424,6 +2006,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &healthy_facts, ); @@ -1458,6 +2041,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &facts(), ); @@ -1499,6 +2083,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &facts(), ); @@ -1515,6 +2100,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &facts(), ); @@ -1543,6 +2129,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &facts(), ); @@ -1557,6 +2144,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &facts(), ); @@ -1581,6 +2169,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &facts(), ); @@ -1600,6 +2189,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &facts(), ); @@ -1621,6 +2211,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &facts(), ); @@ -1651,6 +2242,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &first_facts, ); @@ -1675,6 +2267,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &updated_facts, ); @@ -1705,6 +2298,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &first_facts, ); @@ -1725,6 +2319,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &updated_facts, ); @@ -1763,6 +2358,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &unavailable_facts, ); @@ -1800,6 +2396,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &facts, ); @@ -1832,6 +2429,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &choices, conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + model_bindings: empty_model_bindings(), }, &facts, ); @@ -1873,6 +2471,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &choices, conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + model_bindings: empty_model_bindings(), }, &product_facts, ); @@ -1904,6 +2503,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &choices, conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + model_bindings: empty_model_bindings(), }, &product_facts, ); @@ -1956,6 +2556,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &empty_map, conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), }, &product_facts, ); @@ -1983,6 +2584,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &choices, conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + model_bindings: empty_model_bindings(), }, &facts(), ); @@ -2022,6 +2624,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &choices, conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + model_bindings: empty_model_bindings(), }, &facts_with_local, ); @@ -2056,6 +2659,7 @@ mod tests { declined_decisions: &empty_map, conflict_choices: &choices, conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + model_bindings: empty_model_bindings(), }, &facts_with_local, ); diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs index 39efcd8f11..c7bf9713aa 100644 --- a/src/crates/assembly/external-sources/src/lib.rs +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -220,6 +220,8 @@ impl ExternalSourceCoordinator { subagent_generation: 0, preference_revision: 0, subagents: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), integration_policy: Default::default(), @@ -748,6 +750,8 @@ impl ExternalSourceCoordinator { subagent_generation: 0, preference_revision: 0, subagents: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), integration_policy: Default::default(), diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index 5d607ff8b9..bd0f627fc2 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -2127,6 +2127,12 @@ pub struct ExternalSourceCatalogSnapshot { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub subagents: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagent_model_binding_groups: + Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagent_model_binding_options: + Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub subagent_conflicts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pending_subagent_approvals: Vec, @@ -2254,6 +2260,12 @@ pub struct ExternalSourcePublicSnapshot { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub subagents: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagent_model_binding_groups: + Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagent_model_binding_options: + Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub subagent_conflicts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pending_subagent_approvals: Vec, @@ -2278,8 +2290,13 @@ impl ExternalSourcePublicSnapshot { } } for subagent in &mut self.subagents { + subagent.requested_model = Default::default(); + subagent.model_binding_method = Default::default(); + subagent.model_binding_key = None; subagent.unavailable_tool_labels.clear(); } + self.subagent_model_binding_groups.clear(); + self.subagent_model_binding_options.clear(); self } } @@ -2319,6 +2336,8 @@ impl From for ExternalSourcePublicSnapshot { subagent_generation: snapshot.subagent_generation, preference_revision: snapshot.preference_revision, subagents: snapshot.subagents, + subagent_model_binding_groups: snapshot.subagent_model_binding_groups, + subagent_model_binding_options: snapshot.subagent_model_binding_options, subagent_conflicts: snapshot.subagent_conflicts, pending_subagent_approvals: snapshot.pending_subagent_approvals, integration_policy: snapshot.integration_policy, diff --git a/src/crates/contracts/product-domains/src/external_subagents.rs b/src/crates/contracts/product-domains/src/external_subagents.rs index d4cd80ef2a..1afaa7c9ea 100644 --- a/src/crates/contracts/product-domains/src/external_subagents.rs +++ b/src/crates/contracts/product-domains/src/external_subagents.rs @@ -208,17 +208,119 @@ pub enum ExternalSubagentMode { Primary, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] pub enum ExternalSubagentModelRequest { + #[default] Default, - Exact { + Inherit, + Reference { #[serde(default, skip_serializing_if = "Option::is_none")] provider_hint: Option, model_name: String, }, } +fn is_default_model_request(request: &ExternalSubagentModelRequest) -> bool { + matches!(request, ExternalSubagentModelRequest::Default) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum ExternalSubagentModelBindingTarget { + Primary, + Fast, + Model { model_id: String }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSubagentModelBindingMethod { + #[default] + Default, + Inherit, + Exact, + Explicit, + BindingRequired, + BindingUnavailable, +} + +fn is_default_model_binding_method(method: &ExternalSubagentModelBindingMethod) -> bool { + matches!(method, ExternalSubagentModelBindingMethod::Default) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentModelBindingOption { + pub target: ExternalSubagentModelBindingTarget, + pub effective_model_label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentModelBindingGroup { + pub binding_key: String, + pub request: ExternalSubagentModelRequest, + pub scope: ExternalSourceScope, + pub method: ExternalSubagentModelBindingMethod, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_model_label: Option, + pub affected_candidate_ids: Vec, +} + +pub fn external_subagent_model_binding_key( + ecosystem_id: &EcosystemId, + request: &ExternalSubagentModelRequest, + execution_domain_id: &str, + scope: ExternalSourceScope, + workspace_scope: &str, +) -> Option { + let ExternalSubagentModelRequest::Reference { + provider_hint, + model_name, + } = request + else { + return None; + }; + let scope_identity = match scope { + ExternalSourceScope::UserGlobal | ExternalSourceScope::RemoteUser => "", + _ => workspace_scope, + }; + let scope_label = match scope { + ExternalSourceScope::UserGlobal => "user_global", + ExternalSourceScope::Project => "project", + ExternalSourceScope::WorkspaceLocal => "workspace_local", + ExternalSourceScope::RemoteUser => "remote_user", + ExternalSourceScope::RemoteProject => "remote_project", + }; + Some(format!( + "external_subagent_model_binding:{}", + stable_digest([ + ecosystem_id.as_str(), + provider_hint + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), + model_name, + execution_domain_id, + scope_label, + scope_identity, + ]) + )) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSubagentToolSelector { @@ -334,7 +436,7 @@ impl ExternalSubagentDefinition { "external subagent prompt", )); } - if let ExternalSubagentModelRequest::Exact { + if let ExternalSubagentModelRequest::Reference { provider_hint, model_name, } = &self.requested_model @@ -541,6 +643,12 @@ pub struct ExternalSubagentSummary { pub source_keys: Vec, pub source_location_labels: Vec, pub source_count: usize, + #[serde(default, skip_serializing_if = "is_default_model_request")] + pub requested_model: ExternalSubagentModelRequest, + #[serde(default, skip_serializing_if = "is_default_model_binding_method")] + pub model_binding_method: ExternalSubagentModelBindingMethod, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_binding_key: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub effective_model_label: Option, pub effective_tool_labels: Vec, diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index 9c86f2d7c1..287699b17d 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -34,11 +34,13 @@ use bitfun_product_domains::external_sources::{ }; use bitfun_product_domains::external_subagents::{ external_subagent_approval_key, external_subagent_candidate_id, external_subagent_conflict_key, - ExternalSubagentBehaviorVersion, ExternalSubagentCandidateId, - ExternalSubagentCompatibilityState, ExternalSubagentContributionId, - ExternalSubagentContributionRole, ExternalSubagentDefinition, ExternalSubagentDiscoveryInput, - ExternalSubagentLocalId, ExternalSubagentMode, ExternalSubagentModelRequest, - ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, + external_subagent_model_binding_key, ExternalSubagentBehaviorVersion, + ExternalSubagentCandidateId, ExternalSubagentCompatibilityState, + ExternalSubagentContributionId, ExternalSubagentContributionRole, ExternalSubagentDefinition, + ExternalSubagentDiscoveryInput, ExternalSubagentLocalId, ExternalSubagentMode, + ExternalSubagentModelBindingGroup, ExternalSubagentModelBindingMethod, + ExternalSubagentModelBindingOption, ExternalSubagentModelBindingTarget, + ExternalSubagentModelRequest, ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, ExternalSubagentProviderSnapshot, ExternalSubagentToolRequest, ExternalSubagentToolSelector, SecretText, }; @@ -409,7 +411,7 @@ fn external_subagent_identity_preserves_ordered_provenance_and_separate_revision assert!(!format!("{definition:?}").contains("C:/sensitive/private")); let mut invalid_model = definition.clone(); - invalid_model.requested_model = ExternalSubagentModelRequest::Exact { + invalid_model.requested_model = ExternalSubagentModelRequest::Reference { provider_hint: Some("fake\nprovider".to_string()), model_name: "model".to_string(), }; @@ -568,6 +570,146 @@ fn external_subagent_identity_preserves_ordered_provenance_and_separate_revision assert_eq!(input.suppressed_sources.len(), 1); } +#[test] +fn external_subagent_model_contract_preserves_control_and_opaque_reference_semantics() { + let requests = [ + ExternalSubagentModelRequest::Default, + ExternalSubagentModelRequest::Inherit, + ExternalSubagentModelRequest::Reference { + provider_hint: Some("openrouter".to_string()), + model_name: "anthropic/claude-sonnet-4".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "gpt-5.6-codex".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "glm-5".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "deepseek-v4".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "future-model-that-does-not-exist-yet".to_string(), + }, + ]; + + for request in requests { + let encoded = serde_json::to_value(&request).unwrap(); + if let ExternalSubagentModelRequest::Reference { + provider_hint, + model_name, + } = &request + { + assert_eq!(encoded["modelName"], model_name.as_str()); + assert!(encoded.get("model_name").is_none()); + if let Some(provider_hint) = provider_hint { + assert_eq!(encoded["providerHint"], provider_hint.as_str()); + assert!(encoded.get("provider_hint").is_none()); + } + } + let decoded: ExternalSubagentModelRequest = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, request); + } + + assert_ne!( + ExternalSubagentModelRequest::Inherit, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "inherit".to_string(), + } + ); +} + +#[test] +fn external_subagent_model_binding_contract_groups_only_matching_scope_identity() { + let ecosystem = EcosystemId::new("opencode").unwrap(); + let request = ExternalSubagentModelRequest::Reference { + provider_hint: Some("openrouter".to_string()), + model_name: "vendor/model".to_string(), + }; + let global_a = external_subagent_model_binding_key( + &ecosystem, + &request, + "local-user", + ExternalSourceScope::UserGlobal, + "D:/workspace/a", + ) + .unwrap(); + let global_b = external_subagent_model_binding_key( + &ecosystem, + &request, + "local-user", + ExternalSourceScope::UserGlobal, + "D:/workspace/b", + ) + .unwrap(); + assert_eq!( + global_a, global_b, + "user bindings belong to the execution domain" + ); + + let project_a = external_subagent_model_binding_key( + &ecosystem, + &request, + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .unwrap(); + let project_b = external_subagent_model_binding_key( + &ecosystem, + &request, + "local-user", + ExternalSourceScope::Project, + "D:/workspace/b", + ) + .unwrap(); + assert_ne!( + project_a, project_b, + "project bindings stay workspace-scoped" + ); + assert_ne!( + global_a, project_a, + "global and project bindings never alias" + ); + let remote_global = external_subagent_model_binding_key( + &ecosystem, + &request, + "remote:user@example", + ExternalSourceScope::RemoteUser, + "D:/workspace/a", + ) + .unwrap(); + assert_ne!( + global_a, remote_global, + "remote and local execution domains never share bindings" + ); + + let option = ExternalSubagentModelBindingOption { + target: ExternalSubagentModelBindingTarget::Primary, + effective_model_label: "Provider / Model".to_string(), + }; + let group = ExternalSubagentModelBindingGroup { + binding_key: project_a, + request, + scope: ExternalSourceScope::Project, + method: ExternalSubagentModelBindingMethod::Explicit, + selected_target: Some(option.target.clone()), + effective_model_label: Some(option.effective_model_label.clone()), + affected_candidate_ids: vec!["candidate-a".to_string(), "candidate-b".to_string()], + }; + let encoded = serde_json::to_value((&option, &group)).unwrap(); + let decoded: ( + ExternalSubagentModelBindingOption, + ExternalSubagentModelBindingGroup, + ) = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, (option, group)); +} + #[test] fn external_subagent_decision_keys_bind_behavior_but_not_catalog_copy() { let candidate = ExternalSubagentCandidateId::new("candidate-v1").unwrap(); @@ -715,6 +857,13 @@ fn legacy_public_snapshot_downprojects_new_tool_review_variants() { "sourceKeys": [], "sourceLocationLabels": [], "sourceCount": 1, + "requestedModel": { + "kind": "reference", + "providerHint": "anthropic", + "modelName": "claude-sonnet-4" + }, + "modelBindingMethod": "binding_required", + "modelBindingKey": "external_subagent_model_binding:review", "effectiveToolLabels": ["Read"], "unavailableToolLabels": ["Shell"], "supportsFollowUp": false, @@ -725,6 +874,17 @@ fn legacy_public_snapshot_downprojects_new_tool_review_variants() { }], "activationState": { "state": "blocked" }, "decisionKey": "agent-decision-v1" + }], + "subagentModelBindingGroups": [{ + "bindingKey": "external_subagent_model_binding:review", + "request": { "kind": "reference", "modelName": "claude-sonnet-4" }, + "scope": "project", + "method": "binding_required", + "affectedCandidateIds": ["external-review"] + }], + "subagentModelBindingOptions": [{ + "target": { "kind": "fast" }, + "effectiveModelLabel": "Fast" }] })) .expect("new public snapshot"); @@ -736,6 +896,11 @@ fn legacy_public_snapshot_downprojects_new_tool_review_variants() { assert!(legacy["subagents"][0] .get("unavailableToolLabels") .is_none()); + assert!(legacy["subagents"][0].get("requestedModel").is_none()); + assert!(legacy["subagents"][0].get("modelBindingMethod").is_none()); + assert!(legacy["subagents"][0].get("modelBindingKey").is_none()); + assert!(legacy.get("subagentModelBindingGroups").is_none()); + assert!(legacy.get("subagentModelBindingOptions").is_none()); } #[test] @@ -1458,6 +1623,22 @@ fn public_snapshot_never_exposes_executable_prompt_templates() { subagent_generation: 0, preference_revision: 0, subagents: Vec::new(), + subagent_model_binding_groups: vec![ExternalSubagentModelBindingGroup { + binding_key: "external_subagent_model_binding:review".to_string(), + request: ExternalSubagentModelRequest::Reference { + provider_hint: Some("anthropic".to_string()), + model_name: "claude-sonnet-4".to_string(), + }, + scope: ExternalSourceScope::Project, + method: ExternalSubagentModelBindingMethod::BindingRequired, + selected_target: None, + effective_model_label: None, + affected_candidate_ids: vec!["opencode-review".to_string()], + }], + subagent_model_binding_options: vec![ExternalSubagentModelBindingOption { + target: ExternalSubagentModelBindingTarget::Fast, + effective_model_label: "GLM-4.5-Air".to_string(), + }], subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), integration_policy: Default::default(), @@ -1471,6 +1652,14 @@ fn public_snapshot_never_exposes_executable_prompt_templates() { assert!(encoded["commands"][0]["definition"] .get("template") .is_none()); + assert_eq!( + encoded["subagentModelBindingGroups"][0]["bindingKey"], + "external_subagent_model_binding:review" + ); + assert_eq!( + encoded["subagentModelBindingOptions"][0]["effectiveModelLabel"], + "GLM-4.5-Air" + ); } #[test] @@ -1498,6 +1687,8 @@ fn control_projection_keeps_lifecycle_facts_orthogonal() { subagent_generation: 3, preference_revision: 11, subagents: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), integration_policy: Default::default(), @@ -1577,6 +1768,8 @@ fn control_projection_does_not_infer_review_facts_from_runtime_activation() { subagent_generation: 1, preference_revision: 1, subagents: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), integration_policy: Default::default(), diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts index a8969b7c72..5742323e26 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts @@ -521,6 +521,89 @@ describe('ExternalSourcesAPI', () => { ); }); + it('normalizes omitted subagent model-binding collections at the API boundary', async () => { + invokeMock.mockResolvedValue(surface({ + generation: 2, + discoveryPending: false, + sources: [], + commands: [], + subagents: [{ + candidateId: 'opencode-review', + logicalId: 'review', + displayName: 'Review', + description: 'Review changes', + providerLabel: 'OpenCode', + scope: 'project', + sourceKeys: [], + sourceLocationLabels: [], + sourceCount: 1, + effectiveToolLabels: [], + unavailableToolLabels: [], + supportsFollowUp: false, + compatibilityState: 'ready', + diagnostics: [], + activationState: { state: 'approval_required' }, + decisionKey: 'decision-v1', + }], + })); + + const result = await externalSourcesAPI.getSnapshot(); + + expect(result.subagentModelBindingGroups).toEqual([]); + expect(result.subagentModelBindingOptions).toEqual([]); + expect(result.subagents?.[0]).toMatchObject({ + requestedModel: { kind: 'default' }, + modelBindingMethod: 'default', + }); + }); + + it('sends typed subagent model bindings and clears them through the same command', async () => { + await externalSourcesAPI.setSubagentModelBinding( + 'D:/workspace/project', + 'external_subagent_model_binding:review', + { kind: 'model', modelId: 'anthropic/claude-sonnet-4' }, + 5, + 8, + ); + + expect(invokeMock).toHaveBeenNthCalledWith( + 1, + 'set_external_subagent_model_binding_command', + { + request: { + workspacePath: 'D:/workspace/project', + bindingKey: 'external_subagent_model_binding:review', + target: { kind: 'model', modelId: 'anthropic/claude-sonnet-4' }, + expectedSubagentGeneration: 5, + expectedPreferenceRevision: 8, + }, + }, + ); + + invokeMock.mockClear(); + await externalSourcesAPI.setSubagentModelBinding( + 'D:/workspace/project', + 'external_subagent_model_binding:review', + undefined, + 6, + 9, + ); + + expect(invokeMock).toHaveBeenNthCalledWith( + 1, + 'set_external_subagent_model_binding_command', + { + request: { + workspacePath: 'D:/workspace/project', + bindingKey: 'external_subagent_model_binding:review', + target: undefined, + expectedSubagentGeneration: 6, + expectedPreferenceRevision: 9, + }, + }, + ); + }); + it('restores omitted empty MCP collections at the API boundary', async () => { invokeMock.mockResolvedValue(surface({ generation: 3, diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index f0158e9b97..4216b521b3 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -179,6 +179,8 @@ export interface ExternalSourceCatalogSnapshot { subagentGeneration?: number; preferenceRevision?: number; subagents?: ExternalSubagentSummary[]; + subagentModelBindingGroups?: ExternalSubagentModelBindingGroup[]; + subagentModelBindingOptions?: ExternalSubagentModelBindingOption[]; subagentConflicts?: ExternalSubagentConflict[]; pendingSubagentApprovals?: string[]; integrationPolicy: ExternalIntegrationPolicySnapshot; @@ -205,6 +207,39 @@ export type ExternalSubagentActivation = | { state: 'blocked' } | { state: 'unavailable' }; +export type ExternalSubagentModelRequest = + | { kind: 'default' } + | { kind: 'inherit' } + | { kind: 'reference'; providerHint?: string; modelName: string }; + +export type ExternalSubagentModelBindingTarget = + | { kind: 'primary' } + | { kind: 'fast' } + | { kind: 'model'; modelId: string }; + +export type ExternalSubagentModelBindingMethod = + | 'default' + | 'inherit' + | 'exact' + | 'explicit' + | 'binding_required' + | 'binding_unavailable'; + +export interface ExternalSubagentModelBindingOption { + target: ExternalSubagentModelBindingTarget; + effectiveModelLabel: string; +} + +export interface ExternalSubagentModelBindingGroup { + bindingKey: string; + request: ExternalSubagentModelRequest; + scope: ExternalSourceScope; + method: ExternalSubagentModelBindingMethod; + selectedTarget?: ExternalSubagentModelBindingTarget; + effectiveModelLabel?: string; + affectedCandidateIds: string[]; +} + export interface ExternalSubagentSummary { candidateId: string; logicalId: string; @@ -215,6 +250,9 @@ export interface ExternalSubagentSummary { sourceKeys: Array<{ providerId: string; sourceId: string }>; sourceLocationLabels: string[]; sourceCount: number; + requestedModel: ExternalSubagentModelRequest; + modelBindingMethod: ExternalSubagentModelBindingMethod; + modelBindingKey?: string; effectiveModelLabel?: string; effectiveToolLabels: string[]; unavailableToolLabels: string[]; @@ -854,12 +892,23 @@ function normalizeSnapshot(value: unknown): ExternalSourceCatalogSnapshot { })), subagents: normalizeOptionalArray(candidate.subagents).map((subagent) => ({ ...subagent, + requestedModel: subagent.requestedModel ?? { kind: 'default' }, + modelBindingMethod: subagent.modelBindingMethod ?? 'default', sourceKeys: normalizeOptionalArray(subagent.sourceKeys), sourceLocationLabels: normalizeOptionalArray(subagent.sourceLocationLabels), effectiveToolLabels: normalizeOptionalArray(subagent.effectiveToolLabels), unavailableToolLabels: normalizeOptionalArray(subagent.unavailableToolLabels), diagnostics: normalizeOptionalArray(subagent.diagnostics), })), + subagentModelBindingGroups: normalizeOptionalArray( + candidate.subagentModelBindingGroups, + ).map((group) => ({ + ...group, + affectedCandidateIds: normalizeOptionalArray(group.affectedCandidateIds), + })), + subagentModelBindingOptions: normalizeOptionalArray( + candidate.subagentModelBindingOptions, + ), subagentConflicts: normalizeOptionalArray(candidate.subagentConflicts).map((conflict) => ({ ...conflict, candidates: normalizeOptionalArray(conflict.candidates), @@ -1420,6 +1469,24 @@ export const externalSourcesAPI = { }); }, + setSubagentModelBinding( + workspacePath: string | undefined, + bindingKey: string, + target: ExternalSubagentModelBindingTarget | undefined, + expectedSubagentGeneration: number, + expectedPreferenceRevision: number, + ) { + return invokeSnapshot('set_external_subagent_model_binding_command', { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + bindingKey, + target, + expectedSubagentGeneration, + expectedPreferenceRevision, + }, + }); + }, + chooseSubagentConflict( workspacePath: string | undefined, conflictKey: string, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 3246872038..27518c283a 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -13,6 +13,7 @@ const setConflictChoiceMock = vi.hoisted(() => vi.fn()); const setToolTargetDecisionMock = vi.hoisted(() => vi.fn()); const setToolConflictChoiceMock = vi.hoisted(() => vi.fn()); const setSubagentActivationMock = vi.hoisted(() => vi.fn()); +const setSubagentModelBindingMock = vi.hoisted(() => vi.fn()); const chooseSubagentConflictMock = vi.hoisted(() => vi.fn()); const setMcpServerDecisionMock = vi.hoisted(() => vi.fn()); const chooseMcpConflictMock = vi.hoisted(() => vi.fn()); @@ -74,6 +75,7 @@ vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ setToolTargetDecision: setToolTargetDecisionMock, setToolConflictChoice: setToolConflictChoiceMock, setSubagentActivation: setSubagentActivationMock, + setSubagentModelBinding: setSubagentModelBindingMock, chooseSubagentConflict: chooseSubagentConflictMock, setMcpServerDecision: setMcpServerDecisionMock, chooseMcpConflict: chooseMcpConflictMock, @@ -267,6 +269,7 @@ describe('ExternalSourcesConfig', () => { setToolTargetDecisionMock.mockResolvedValue(snapshot); setToolConflictChoiceMock.mockResolvedValue(snapshot); setSubagentActivationMock.mockResolvedValue(snapshot); + setSubagentModelBindingMock.mockResolvedValue(snapshot); chooseSubagentConflictMock.mockResolvedValue(snapshot); setMcpServerDecisionMock.mockResolvedValue(snapshot); chooseMcpConflictMock.mockResolvedValue(snapshot); @@ -1516,6 +1519,153 @@ describe('ExternalSourcesConfig', () => { ); }); + it('shows one model binding for matching agent requests and applies a typed target', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + const bindingSnapshot = { + ...snapshot, + subagentGeneration: 5, + preferenceRevision: 8, + subagents: [{ + candidateId: 'opencode-review', + logicalId: 'review', + displayName: 'OpenCode Review', + description: 'Review changes', + providerLabel: 'OpenCode', + scope: 'project', + sourceKeys: [], + sourceLocationLabels: [], + sourceCount: 1, + requestedModel: { + kind: 'reference', + providerHint: 'anthropic', + modelName: 'claude-sonnet-4', + }, + modelBindingMethod: 'binding_required', + modelBindingKey: 'external_subagent_model_binding:review', + effectiveToolLabels: ['Read'], + unavailableToolLabels: [], + supportsFollowUp: false, + compatibilityState: 'blocked', + diagnostics: [], + activationState: { state: 'unavailable' }, + decisionKey: 'decision-v1', + }], + subagentModelBindingGroups: [{ + bindingKey: 'external_subagent_model_binding:review', + request: { + kind: 'reference', + providerHint: 'anthropic', + modelName: 'claude-sonnet-4', + }, + scope: 'project', + method: 'binding_required', + affectedCandidateIds: ['opencode-review', 'claude-review'], + }], + subagentModelBindingOptions: [{ + target: { kind: 'primary' }, + effectiveModelLabel: 'GPT-5', + }, { + target: { kind: 'fast' }, + effectiveModelLabel: 'GLM-4.5-Air', + }], + subagentConflicts: [], + pendingSubagentApprovals: [], + }; + const updatedSnapshot = { + ...bindingSnapshot, + subagentGeneration: 6, + preferenceRevision: 9, + subagentModelBindingGroups: [{ + ...bindingSnapshot.subagentModelBindingGroups[0], + method: 'explicit', + selectedTarget: { kind: 'fast' }, + effectiveModelLabel: 'GLM-4.5-Air', + }], + }; + getSnapshotMock + .mockResolvedValueOnce(bindingSnapshot) + .mockResolvedValue(updatedSnapshot); + setSubagentModelBindingMock.mockResolvedValue(updatedSnapshot); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('agentModelBindings.title'); + expect(container.textContent).toContain('anthropic/claude-sonnet-4'); + expect(container.textContent).toContain('agentModelBindings.affectedAgents:{"count":2}'); + expect(container.textContent).toContain('agentModelBindings.method.binding_required'); + + const trigger = container.querySelector( + '[aria-label^="agentModelBindings.selectLabel"]', + ); + await act(async () => trigger?.click()); + const fastOption = Array.from(document.querySelectorAll('[role="option"]')) + .find((option) => option.textContent?.includes('GLM-4.5-Air')); + await act(async () => fastOption?.click()); + + expect(setSubagentModelBindingMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'external_subagent_model_binding:review', + { kind: 'fast' }, + 5, + 8, + ); + expect(container.textContent).toContain('agentModelBindings.method.explicit'); + }); + + it('shows inherited models as resolved when the task starts instead of unavailable', async () => { + getSnapshotMock.mockResolvedValue({ + ...snapshot, + commandConflicts: [], + subagentGeneration: 5, + preferenceRevision: 8, + subagents: [{ + candidateId: 'claude-review', + logicalId: 'review', + displayName: 'Claude Review', + description: 'Review changes', + providerLabel: 'Claude Code', + scope: 'project', + sourceKeys: [], + sourceLocationLabels: [], + sourceCount: 1, + requestedModel: { kind: 'inherit' }, + modelBindingMethod: 'inherit', + effectiveToolLabels: ['Read'], + unavailableToolLabels: [], + supportsFollowUp: false, + compatibilityState: 'ready', + diagnostics: [], + activationState: { state: 'approval_required' }, + decisionKey: 'decision-inherit-v1', + }], + subagentModelBindingGroups: [], + subagentModelBindingOptions: [], + subagentConflicts: [], + pendingSubagentApprovals: ['claude-review'], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + const details = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('common.details')); + await act(async () => details?.click()); + + expect(container.textContent).toContain( + 'agents.model:{"model":"agents.modelResolvedFromParentAtTaskStart"}', + ); + expect(container.textContent).not.toContain( + 'agents.model:{"model":"agents.modelUnavailable"}', + ); + }); + it('shows model-setting read failures as temporarily unavailable', async () => { getSnapshotMock.mockResolvedValue({ ...snapshot, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index ccb13027f1..112c5a748d 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -33,6 +33,10 @@ import { type ExternalIntegrationPolicyMutation, type ExternalSourceCatalogSnapshot, type ExternalSourceRecoveryAction, + type ExternalSubagentModelBindingGroup, + type ExternalSubagentModelBindingMethod, + type ExternalSubagentModelBindingTarget, + type ExternalSubagentModelRequest, type ExternalSubagentSummary, type ExternalToolCatalogEntry, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; @@ -165,6 +169,42 @@ function externalAgentModelLabel(model: string | undefined, t: TFunction): strin return model || t('agents.modelUnavailable'); } +function externalAgentEffectiveModelLabel( + model: string | undefined, + method: ExternalSubagentModelBindingMethod, + t: TFunction, +): string { + if (!model && method === 'inherit') { + return t('agents.modelResolvedFromParentAtTaskStart'); + } + return externalAgentModelLabel(model, t); +} + +function externalAgentRequestedModelLabel( + request: ExternalSubagentModelRequest | undefined, + t: TFunction, +): string { + if (!request) return t('agentModelBindings.request.default'); + if (request.kind === 'default') return t('agentModelBindings.request.default'); + if (request.kind === 'inherit') return t('agentModelBindings.request.inherit'); + return request.providerHint + ? `${request.providerHint}/${request.modelName}` + : request.modelName; +} + +function externalAgentBindingTargetKey(target: ExternalSubagentModelBindingTarget): string { + if (target.kind === 'model') return `model:${target.modelId}`; + return target.kind; +} + +function externalAgentBindingTargetFallbackLabel( + target: ExternalSubagentModelBindingTarget, + t: TFunction, +): string { + if (target.kind === 'model') return target.modelId; + return t(`agentModelBindings.target.${target.kind}`); +} + function executionLocationLabel(t: TFunction, executionDomainId?: string): string { if (executionDomainId?.startsWith('local')) return t('executionLocation.local'); if (executionDomainId?.startsWith('remote')) return t('executionLocation.remote'); @@ -354,6 +394,8 @@ const ExternalSourcesConfig: React.FC = () => { subagentGeneration: next.subagentGeneration, preferenceRevision: next.preferenceRevision, subagents: next.subagents, + subagentModelBindingGroups: next.subagentModelBindingGroups, + subagentModelBindingOptions: next.subagentModelBindingOptions, subagentConflicts: next.subagentConflicts, pendingSubagentApprovals: next.pendingSubagentApprovals, }; @@ -777,6 +819,29 @@ const ExternalSourcesConfig: React.FC = () => { return accepted; }, [loadSnapshot, runMutation, snapshot, t, workspacePath]); + const setAgentModelBinding = useCallback(async ( + group: ExternalSubagentModelBindingGroup, + target: ExternalSubagentModelBindingTarget | undefined, + ) => { + const current = snapshotRef.current; + if (!current) return; + const accepted = await runMutation( + group.bindingKey, + () => externalSourcesAPI.setSubagentModelBinding( + workspacePath, + group.bindingKey, + target, + current.subagentGeneration ?? 0, + current.preferenceRevision ?? 0, + ), + true, + 'subagents', + t('actions.modelBindingUpdated'), + 'canApproveRuntime', + ); + if (accepted) await loadSnapshot(true, false); + }, [loadSnapshot, runMutation, t, workspacePath]); + const chooseAgentConflict = useCallback(async ( conflictKey: string, candidateId: string, @@ -2256,6 +2321,83 @@ const ExternalSourcesConfig: React.FC = () => { ) : null} + {(snapshot?.subagentModelBindingGroups?.length ?? 0) > 0 ? ( + + {snapshot?.subagentModelBindingGroups?.map((group) => { + const targetOptions = snapshot.subagentModelBindingOptions ?? []; + const targetsByKey = new Map(targetOptions.map((option) => [ + externalAgentBindingTargetKey(option.target), + option.target, + ])); + const selectedKey = group.selectedTarget + ? externalAgentBindingTargetKey(group.selectedTarget) + : 'source'; + const selectedUnavailable = group.selectedTarget + && !targetsByKey.has(selectedKey); + const canEdit = group.method !== 'exact'; + const effective = externalAgentModelLabel(group.effectiveModelLabel, t); + return ( + + {canEdit ? ( +