diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 6a6ba9264c..ad16b00f8b 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -260,7 +260,8 @@ flowchart LR 稳定决策如下: - 不启动完整 OpenCode Runtime,也不依赖用户安装 OpenCode CLI;BitFun 实现自己的监督、适配和 Rust 转发层。 - 固定版本 Bun 只负责脚本执行,依赖按冻结 OpenCode 版本的 npm/Arborist 语义准备。 + 当前 standalone Tool 子集通过受监督的 Node.js worker 执行且不安装依赖;未来只有冻结的 package plugin 样例证明 + 确有需要时,才单独裁决 Bun、依赖准备和版本兼容方案。 - 用户全局和项目来源自动发现;低风险内容默认无感应用并显示可撤销摘要,可执行来源首次启用或能力扩大时等待 非阻塞确认。确认前不得 import module、启动 worker、读取凭据或产生直接脚本副作用。 - 激活后的本地 target 默认按 OpenCode 语义运行,允许当前用户通常拥有的文件、网络、进程和环境能力;用户、 @@ -322,10 +323,10 @@ flowchart LR 产品形态由产品组装决定,不由插件配置、单个 Cargo feature 或生态适配器临时决定。 -| 产品形态 | 当前 P0 插件能力 | 入口行为 | +| 产品形态 | 当前扩展能力 | 入口行为 | |---|---|---| -| Desktop / product-full | 生产入口仍直接依赖 `bitfun-core/product-full`;当前没有 managed-plugin 管理或 OpenCode 静态预览的生产 UI/调用方 | 共享代码可编译不等于 Desktop 已消费插件能力 | -| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;只为 BitFun 原生包提供来源审核、启用预览、精确内容确认和停用 | 本地 Agent 与 Peer Host 路径选择 `DeliveryProfile::Cli`,校验必需 Runtime Service 注册并消费同一 Runtime Parts/SDK;SDK 缺口由单一 Core 兼容门面转发。Peer Host 不再构造第二套调度、持久化或事件 owner。部分注册仍是 compatibility marker,不代表实时探活;插件 binding 明确禁用,不执行 OpenCode 插件代码 | +| Desktop / product-full | 生产入口仍依赖 `bitfun-core/product-full` 作为兼容组装层;“外部 AI 应用”设置已消费 OpenCode Command、standalone Tool 和 Subagent 的统一来源快照、审批、冲突选择与诊断 | 仅本地执行域支持当前三条纵向切片;受管 package plugin 仍只有静态预览,不能据设置页接入宣称完整 OpenCode 插件运行时 | +| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,并通过 `/external-tools`、`/external-agents` 消费与 Desktop 相同的审批、冲突和刷新状态 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;非交互入口和 Remote 未接入时不得借本机 TUI 路径代执行 | | HarmonyOS PC 原生 CLI/TUI | 未来平台目标,当前未实现 | 目标、问题和风险见平台规约;具体适配另立专题,HAP、手机 Remote App 与远端代执行均不替代 | | HarmonyOS PC GUI | 完整 HarmonyOS PC 支持的另一目标形态,当前未实现 | 与 CLI/TUI 共享稳定能力和 Runtime 语义,但独立设计宿主、界面与发布验证;Web、Remote 或现有 Tauri Desktop 均不能替代 | | HarmonyOS 手机 Remote App | `src/apps/mobile/harmonyos` 是 phone-only ArkTS 远程入口,不持有本地 Rust Agent Runtime | 保持当前能力并按移动端专题独立演进;本轮不提前设计移动 Runtime/TUI/GUI,也不能据此宣称 HarmonyOS PC 本地能力 | diff --git a/docs/plans/opencode-extension-compatibility-plan.md b/docs/plans/opencode-extension-compatibility-plan.md index 0fa0aa197a..2d5262c20b 100644 --- a/docs/plans/opencode-extension-compatibility-plan.md +++ b/docs/plans/opencode-extension-compatibility-plan.md @@ -245,6 +245,26 @@ PR3 已为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 选择与执行仍由现有会话/Subagent owner 决定;adapter 不能替换 BitFun Agent Kernel。外部 agent 当前只支持 fresh 单次调用,前台结果不返回续接入口,历史 external runtime session 的 follow-up 会被类型化拒绝。 +### 5.1 已实现路径的稳定性收敛 + +本轮不新增扩展类型,只修复会误导用户或掩盖真实故障的两处问题,并校正文档事实: + +- `NodeScriptToolRuntime` 在共享外部 Tool 运行时首次初始化时解析 Node.js 可执行文件,并在当前进程内持有结果。 + 本轮保持该生命周期,不增加运行时热替换;首次初始化时未找到 Node.js 后,Desktop 与 TUI 必须明确提示安装或修复后 + 重启 BitFun。刷新只重新发现来源,不能描述为会重新读取进程 `PATH`。运行时状态不得把尚未实际启动的可执行文件写成“已验证”。 +- Subagent owner 读取 BitFun 模型配置失败时必须 fail closed,并生成与“模型不存在”不同的通用诊断。GUI/TUI + 提示用户先确认 BitFun 模型设置能够正常读取和保存,再刷新;日志只保留经过脱敏的失败阶段和错误类别。不得用 `AIConfig::default()` + 把配置服务异常转换成候选模型不匹配,也不得把原始错误或绝对配置路径投影到普通快照。临时故障期间不得改写已持久化的 + 审批或同名冲突选择,配置恢复且扩展行为未变化时继续复用原决定。 +- 外部 Tool 的首次确认继续明确展示代码来源、工作目录、文件/网络/进程/环境访问,以及“当前用户权限、无 OS + 沙箱、子进程可能继续运行”的残余风险。该提示是知情确认,不等于沙箱实现。 +- Desktop 与交互式 TUI 使用相同诊断 code 和激活阻断事实,各自负责适合宿主的文案;Remote 继续返回明确不支持, + 不读取或执行本机同名来源。上述变化不修改 system prompt,也不新增跨界面渲染契约。 + +明确延期到独立、由证据驱动的后续工作:OS/容器沙箱和进程树硬限制、worker/prompt 全局预算、运行中 Tool +代次租约、通用 watcher 事件限流、偏好记录压缩、完整 metrics/打点平台。它们分别涉及平台执行、安全控制面、 +通用服务或数据保留策略,不能以“稳定性修复”为名并入本轮。 + ## 6. 暂停条件 出现以下情况时停止扩面并先修复架构: diff --git a/docs/plans/product-architecture-evolution-plan.md b/docs/plans/product-architecture-evolution-plan.md index 68b8698407..8c067d3a3d 100644 --- a/docs/plans/product-architecture-evolution-plan.md +++ b/docs/plans/product-architecture-evolution-plan.md @@ -7,7 +7,7 @@ [OpenCode 兼容](opencode-extension-compatibility-plan.md);能力 Provider、SDK 和外部宿主双向集成边界见 [能力装配与宿主集成](../architecture/extensions/capability-runtime-integration-design.md)。专项文档不能用自己的阶段编号扩大本计划范围。 -本轮对照的上游基线为 `b22bbdda7`(2026-07-17);本文所在提交记录 +本轮对照的上游基线为 `da027e2e8`(2026-07-18);本文所在提交记录 本轮实现事实。后续事实变化必须随代码显式更新,只有代码、入口消费和对应验证同时成立的项目才标记为完成。 ## 1. 裁决原则 @@ -30,7 +30,7 @@ | 编译依赖 | `assembly/core -> apps/relay-server` 已移除;通用检查覆盖 normal/build/dev 依赖及 optional/target 变体 | 后续反向依赖和未知 crate 层级直接失败 | | 公开面 | `bitfun-core` 仍有迁移期 re-export;CLI 只完成部分 Runtime SDK 接入 | 按入口逐项迁移,不做全仓逐 symbol 台账或批量删除 | | CLI/TUI | `ShortcutsConfig` 已加载但真实按键分发仍硬编码;Slash、Palette、帮助和执行不是同一来源 | 先统一宿主 action 声明和键位解析,不重写 renderer | -| OpenCode | 只有来源确认和静态工具名预览,没有 JS/TS `execute` 或真实工具注册 | 先做一个无外部依赖、遵循官方公开契约的 standalone custom tool 端到端样例 | +| OpenCode | Prompt Command、受支持的单文件 JavaScript Tool 和 Subagent 安全子集已分别通过能力专属 provider 接入;受管 package plugin 仍只有静态预览 | 先收敛三条已交付路径的诊断、运行时提示和配置失败语义,再按真实阻塞样例评估下一能力切片 | | HarmonyOS PC | 未来平台目标,当前未实现 | 目标、问题、风险和旧设计闭环见平台规约;具体工作后续分别立项 | | 入口迁移 | CLI 已消费 Runtime Parts;Desktop 主交互消费由现有 owner 构造的窄口径 Runtime SDK 门面,完整 Desktop Runtime Parts 尚未组装;CLI/ACP/Desktop 仍按需保留 `bitfun-core/product-full` 兼容 owner | 保持单一 owner,按真实端口逐项迁移,不批量删除兼容门面或用桩服务提前声明能力 | @@ -82,16 +82,18 @@ Help/dispatch 元数据;终端异常路径仍能恢复。 [HarmonyOS PC 平台规约](../architecture/platform-portability-design.md);具体工作后续分别立项,现有手机 Remote App 保持不变。 -## 6. 工作流四:OpenCode 从一个真实工具开始 +## 6. 工作流四:OpenCode 纵向切片与稳定性收敛 -执行顺序由[OpenCode 兼容计划](opencode-extension-compatibility-plan.md)定义: +执行顺序由[OpenCode 兼容计划](opencode-extension-compatibility-plan.md)定义。当前已经完成三个互相隔离的纵向切片: -1. 固定稳定版本、官方 custom tool 文档/源码和当前静态预览事实,并增加一个无外部依赖的契约样例; -2. 直接发现官方 `.opencode/tools/` 来源,真实加载该样例的 `execute`,接入现有 Tool Runtime,并验证参数、结果、 - ToolContext 身份/路径字段、`abort` 取消、期限、异常和诊断;`metadata`/`ask` 在定义现有 owner 映射前明确不支持; -3. 官方 import 型 tool 或真实 package plugin 首次受阻时,才增加样例需要的依赖解析、loader 和最小 client; -4. Hook 和 TUI contribution 只按真实阻塞样例逐项加入。command/slash/key 复用工作流二的 action registry;toast - 必须等待 CLI 拥有类型化状态/通知 owner,不能借用 GUI 本地服务;原始 OpenTUI/Solid renderer 保持不支持。 +1. Prompt Command 直接发现标准用户/项目来源,通过统一冲突决策后在交互式 TUI 中提交展开后的 prompt; +2. standalone Tool 只加载受支持的单文件 JavaScript 子集,经来源确认后接入现有 Tool Runtime; +3. Subagent 安全子集经模型、工具和同名冲突确认后接入现有 Subagent owner,仅支持 fresh single-run。 + +近期先修复已实现路径中的事实偏移和类型化失败:运行时依赖在当前进程首次检查时不可用时给出真实的重启建议;读取 BitFun +模型配置失败时阻止外部 Subagent 激活并显示独立原因,不能伪装成“请求模型不存在”。随后只有官方 import 型 tool、 +package plugin、Hook 或 TUI contribution 的真实样例证明当前 owner/契约不足时,才增加对应的最小切片。原始 +OpenTUI/Solid renderer、完整 package runtime 和 Remote 执行仍保持不支持。 工具实际加载并取得有效定义和 `execute` 后才能显示为可用。静态名称、可解析模块或进程启动成功都不等于工具 可调用。Remote 和 HarmonyOS PC 原生 CLI/TUI 未通过同一冻结样例前必须明确不支持,不能借 Desktop 代执行; @@ -138,8 +140,8 @@ native/translated/degraded;未实现能力保持 unsupported/experimental; | 工作 | 必须等待 | 可以并行 | |---|---|---| | Relay 共享 owner / 反向边修复 | 已完成;embedded 宿主归位待后续 | OpenCode fixture | -| CLI action/快捷键 | 当前 CLI 行为和配置 fixture | OpenCode standalone tool、入口 API 迁移 | -| OpenCode standalone tool | OpenCode adapter 内的单一 source resolver、冻结版本/样例 | CLI action | +| CLI action/快捷键 | 当前 CLI 行为和配置 fixture | OpenCode 已实现切片收敛、入口 API 迁移 | +| OpenCode 已实现切片收敛 | Command/Tool/Subagent 三条生产路径和聚焦 fixture | CLI action、入口迁移 | | OpenCode package/Hook/TUI | 前一切片稳定且有真实阻塞样例;TUI action 另等 action registry | 入口迁移 | | Desktop 主交互迁移 | 已完成窄口径 SDK 门面接入;完整 Desktop profile 与剩余入口需分别证明服务可用和行为等价 | ACP 与其他非扩展架构工作 | | 一个能力对外复用 | 现有能力 owner、具名试点/用例/验收 owner、冻结宿主版本和最小权限/取消语义 | OpenCode standalone tool、单入口迁移 | @@ -154,7 +156,7 @@ native/translated/degraded;未实现能力保持 unsupported/experimental; | Cargo 方向 | metadata fixture 覆盖各 dependency kind;已知债务只能减少 | | Relay | standalone/embedded 启动、路由、关闭和错误等价 | | CLI action | 无配置/旧配置/冲突配置、真实输入 dispatch、Help/Palette/Slash 一致、终端恢复 | -| OpenCode tool | 冻结无外部依赖契约样例的 load/execute/context/cancel/timeout/error 端到端;静态预览不会进入工具集合 | +| OpenCode 扩展 | Command 展开与冲突、Tool 的 load/execute/cancel/timeout、Subagent 的配置/模型/工具/Generation 失败语义;静态预览不会进入可调用集合 | | HarmonyOS PC | 本计划只检查平台规约没有被实现文档提前展开;各专题启动后独立定义验证。HAP、`hdc shell`、移动 Remote App 与远端代执行不替代 | | 入口迁移 | 单入口生产消费、行为等价、旧转发删除和 focused test | | 能力对外复用 | 一个外部消费者的注册/安装、启停、请求/结果、权限、取消、Generation、事件损失、宿主降级、卸载和恢复端到端验证 | diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index dc6510b76d..52978cc03c 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -46,8 +46,13 @@ before product-definition, TUI layout, branding, packaging, runtime, or plugin a external-source approval, conflict, Tool Runtime, and script-worker owners. CLI/TUI consumes typed snapshots and actions; it must not import modules, spawn tool workers, bypass a pending decision, or implement a second approval - store. TypeScript, dependency loading, package plugins, hooks, and subagents - remain non-executable until their own reviewed capability slice lands. + store. TypeScript, dependency loading, package plugins, and hooks remain + non-executable until their own reviewed capability slice lands. +- OpenCode external subagents may execute only through the shared source + decision and existing Subagent owner. TUI consumes typed summaries and + generation-checked actions; it must not parse agent files, inject source + prompts directly, invent model fallbacks, or offer follow-up for the current + fresh single-run compatibility slice. - The managed-package OpenCode adapter remains a static-preview path. Other OpenCode plugin capabilities, Codex, and Claude remain import/reference sources unless their own reviewed adapter design explicitly changes. Never copy diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index afd4a5aecc..b6f8cd45c4 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -283,7 +283,7 @@ fn external_tool_next_step(activation: &ExternalToolActivationState) -> &'static "Change the code to a single JavaScript file supported by BitFun, then refresh." } ExternalToolActivationState::RuntimeUnavailable { .. } => { - "Restore the required JavaScript environment, then refresh." + "Please install or repair Node.js, then restart BitFun." } ExternalToolActivationState::LoadFailed { .. } => { "Refresh to retry. If it still fails, fix the source code or keep these tools disabled." @@ -895,7 +895,12 @@ fn external_agent_diagnostic_lines( blocks_activation: bool, indent: &str, ) -> Vec { - let (reason, next_step) = if code.contains("model_unavailable") { + let (reason, next_step) = if code.contains("configuration_unavailable") { + ( + "BitFun could not read its model settings.", + "Open BitFun model settings and confirm they load. If not, restart BitFun. If the problem continues, check that BitFun can read and save its settings; then refresh.", + ) + } else if code.contains("model_unavailable") { ( "The requested model is not available in BitFun.", "Choose an available model in the source application, or set a fixed Sub-Agent model in BitFun, then refresh.", diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index eb22b5c1f2..4f1d2be78c 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -228,6 +228,19 @@ mod tests { assert!(!summary.contains("PR2")); } + #[test] + fn external_tool_runtime_recovery_explains_that_bitfun_must_restart() { + let mut snapshot = external_tool_review_snapshot(); + snapshot.tools[0].activation = ExternalToolActivationState::RuntimeUnavailable { + reason: "BitFun could not find Node.js for external tools".to_string(), + }; + + let summary = external_tool_review_text(Some(&snapshot)); + assert!(summary.contains("install or repair Node.js, then restart BitFun")); + assert!(!summary.contains("environment, then refresh")); + assert!(!summary.contains("when BitFun started")); + } + #[test] fn external_tool_review_commands_resolve_indices_to_stable_keys() { let snapshot = external_tool_review_snapshot(); @@ -877,5 +890,15 @@ mod tests { external_agent_diagnostic_lines("opencode_agent_definition_type_invalid", true, "") .join(" "); assert!(invalid.contains("invalid or missing required value")); + + let config = external_agent_diagnostic_lines( + "external_subagent.configuration_unavailable", + true, + "", + ) + .join(" "); + assert!(config.contains("could not read its model settings")); + assert!(config.contains("can read and save its settings")); + assert!(!config.contains("requested model is not available")); } } diff --git a/src/crates/adapters/AGENTS-CN.md b/src/crates/adapters/AGENTS-CN.md index 6e776299d5..055dfa9d1d 100644 --- a/src/crates/adapters/AGENTS-CN.md +++ b/src/crates/adapters/AGENTS-CN.md @@ -9,7 +9,7 @@ | Crate | 职责 | 本地文档 | |---|---|---| | `ai-adapters` | AI provider 请求/响应 adapter 与 stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | -| `opencode-adapter` | 当前 OpenCode 静态来源探测与诊断;目标为来源协调器和生态适配器 | [AGENTS.md](opencode-adapter/AGENTS.md) | +| `opencode-adapter` | OpenCode Command、standalone Tool 和 Subagent 实时 provider 的生态语义;受管包静态预览 | [AGENTS.md](opencode-adapter/AGENTS.md) | | `transport` | Event transport emitter 与宿主 transport adapter | [AGENTS.md](transport/AGENTS.md) | | `webdriver` | Embedded WebDriver protocol 与浏览器自动化 adapter | [AGENTS.md](webdriver/AGENTS.md) | diff --git a/src/crates/adapters/AGENTS.md b/src/crates/adapters/AGENTS.md index e0636a5779..cf5b125ef2 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -12,7 +12,7 @@ services. | Crate | Responsibility | Local doc | |---|---|---| | `ai-adapters` | AI provider request/response adapters and stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | -| `opencode-adapter` | Current static OpenCode source inspection/diagnostics; target source coordinator and ecosystem adapter | [AGENTS.md](opencode-adapter/AGENTS.md) | +| `opencode-adapter` | OpenCode source semantics for the live Command, standalone Tool, and Subagent providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | | `transport` | Event transport emitters and host transport adapters | [AGENTS.md](transport/AGENTS.md) | | `webdriver` | Embedded WebDriver protocol and browser automation adapter | [AGENTS.md](webdriver/AGENTS.md) | diff --git a/src/crates/adapters/opencode-adapter/AGENTS-CN.md b/src/crates/adapters/opencode-adapter/AGENTS-CN.md index fa95aa580f..6997cccd09 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS-CN.md +++ b/src/crates/adapters/opencode-adapter/AGENTS-CN.md @@ -11,8 +11,9 @@ - 当前 `load_opencode_package_adapter` 在 OC-R1/OC-R2 替换其生产角色前仍只做静态预览;不得把这一 P0 入口继续扩成另一种 OpenCode 受管包格式。 -- 目标流程把 OpenCode 标准配置、全局/项目插件目录、工具目录和软件包 spec 作为实时来源。源文件保持只读, - 且无需导入 BitFun。低风险声明式结果按用户的自动应用/先询问偏好处理;可执行来源首次 import 前按来源/target +- 当前已将 OpenCode Command、standalone Tool 和 Subagent 的标准配置与目录作为只读实时来源;完整插件目录和 + package spec 仍是后续目标,不是可执行的生产来源。源文件无需导入 BitFun。低风险声明式结果按用户的自动应用/ + 先询问偏好处理;可执行来源首次 import 前按来源/target 决策。import 前执行包络扩大和 import 后贡献扩大是两个独立门槛,不对每个内部生命周期状态重复审批。代码 更新只有在来源身份/完整性、来源更新策略和当前执行包络仍允许时才能自动准备。 - 全局来源偏好按来源/target/执行域去重,但每个项目/工作区执行实例必须重新计算有效来源图、工作目录/环境、 @@ -51,6 +52,9 @@ ## 验证 - `cargo test -p bitfun-opencode-adapter --test opencode_source_adapter` +- `cargo test -p bitfun-opencode-adapter --test opencode_command_adapter` +- `cargo test -p bitfun-opencode-adapter --test tool_source_contracts` +- `cargo test -p bitfun-opencode-adapter --test opencode_subagent_adapter` - `cargo test -p bitfun-opencode-adapter p0_c2_fixture` - `cargo test -p bitfun-opencode-adapter host_path_projects_trusted_custom_tool_candidate_with_permission_prompt` - `node scripts/check-core-boundaries.mjs` diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index 4f4f42cc97..7e9c1f5c6a 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -15,9 +15,10 @@ Product-source boundary: - The current `load_opencode_package_adapter` entry remains static-preview only until OC-R1/OC-R2 replace its production role. Do not extend this P0 entry into another managed OpenCode package format. -- In the target flow, standard OpenCode config, global/project plugin directories, - tool directories, and package specs are live sources. Source files stay - read-only and need no BitFun import. Low-risk declarative results follow the +- Standard OpenCode Command, standalone Tool, and Subagent config and directories + are current read-only live sources. Full plugin directories and package specs + remain target work rather than executable production sources. Source files need + no BitFun import. Low-risk declarative results follow the user's auto-apply/ask preference; executable sources require a source/target decision before first import. Pre-import execution-envelope expansion and post-import contribution expansion are separate gates, not repeated approval @@ -87,6 +88,9 @@ Product-source boundary: ## Verification - `cargo test -p bitfun-opencode-adapter --test opencode_source_adapter` +- `cargo test -p bitfun-opencode-adapter --test opencode_command_adapter` +- `cargo test -p bitfun-opencode-adapter --test tool_source_contracts` +- `cargo test -p bitfun-opencode-adapter --test opencode_subagent_adapter` - `cargo test -p bitfun-opencode-adapter p0_c2_fixture` - `cargo test -p bitfun-opencode-adapter host_path_projects_trusted_custom_tool_candidate_with_permission_prompt` - `node scripts/check-core-boundaries.mjs` diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index a7a5d8af73..0d2bf4fb38 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -16,6 +16,7 @@ use crate::external_tools::resolve_external_tool_for_workspace; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{model_runtime_binding_fingerprint, AIConfig, AIModelConfig}; use crate::service::config::SubagentModelSelection; +use crate::util::BitFunError; use bitfun_external_sources::ExternalSubagentCoordinatorSnapshot; use bitfun_product_domains::external_sources::{ExternalSourceScope, ProviderId, SourceKey}; use bitfun_product_domains::external_subagents::{ @@ -27,9 +28,11 @@ use bitfun_product_domains::external_subagents::{ use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; pub(super) const DISABLED_SUBAGENT_CONFLICT_CHOICE: &str = "__bitfun_disabled__"; +static MODEL_CONFIG_UNAVAILABLE_LOGGED: AtomicBool = AtomicBool::new(false); pub(super) struct ExternalSubagentDecisions<'a> { pub approved_envelopes: &'a BTreeSet, @@ -66,7 +69,7 @@ struct LocalCandidateFact { #[derive(Default)] struct ProductFacts { - ai_config: AIConfig, + ai_config: Option, tools: BTreeMap, locals: BTreeMap, } @@ -110,11 +113,20 @@ async fn gather_product_facts( definitions: &[ExternalSubagentDefinition], ) -> ProductFacts { let ai_config = match GlobalConfigManager::get_service().await { - Ok(service) => service - .get_config::(Some("ai")) - .await - .unwrap_or_default(), - Err(_) => AIConfig::default(), + Ok(service) => match service.get_config::(Some("ai")).await { + Ok(config) => { + MODEL_CONFIG_UNAVAILABLE_LOGGED.store(false, Ordering::Relaxed); + Some(config) + } + Err(error) => { + log_model_config_unavailable("config_read", &error); + None + } + }, + Err(error) => { + log_model_config_unavailable("config_service", &error); + None + } }; let requested_names = definitions @@ -178,15 +190,20 @@ async fn gather_product_facts( // offering a candidate that the Local route could not execute. continue; } - let model_selection = registry - .get_explicit_subagent_model_selection(&info.id, workspace_root) - .unwrap_or_else(|| { - ai_config - .agent_model_defaults - .builtin_subagent_selection(&info.id) - }); - let model = - serde_json::to_string(&model_selection).unwrap_or_else(|_| "unavailable".to_string()); + let model = match ai_config.as_ref() { + Some(ai_config) => { + let model_selection = registry + .get_explicit_subagent_model_selection(&info.id, workspace_root) + .unwrap_or_else(|| { + ai_config + .agent_model_defaults + .builtin_subagent_selection(&info.id) + }); + serde_json::to_string(&model_selection) + .unwrap_or_else(|_| "unavailable".to_string()) + } + None => "configuration-unavailable".to_string(), + }; locals.insert(logical_key, local_candidate_fact(&info, &model)); } @@ -197,6 +214,45 @@ async fn gather_product_facts( } } +fn log_model_config_unavailable(stage: &str, error: &BitFunError) { + if claim_model_config_outage_log(&MODEL_CONFIG_UNAVAILABLE_LOGGED) { + log::warn!( + "External subagent model configuration unavailable: stage={}, category={}", + stage, + model_config_error_category(error) + ); + } +} + +fn claim_model_config_outage_log(logged: &AtomicBool) -> bool { + !logged.swap(true, Ordering::Relaxed) +} + +fn model_config_error_category(error: &BitFunError) -> &'static str { + match error { + BitFunError::Configuration(message) if message.contains("not initialized") => { + "service_not_initialized" + } + BitFunError::Configuration(message) if message.contains("service is None") => { + "service_missing" + } + BitFunError::Configuration(message) + if message.contains("Failed to deserialize config value") => + { + "config_deserialization_failed" + } + BitFunError::Configuration(message) if message.contains("Config path") => { + "config_path_unavailable" + } + BitFunError::Configuration(_) => "configuration_error", + BitFunError::Deserialization(_) => "deserialization_error", + BitFunError::Serialization(_) => "serialization_error", + BitFunError::Io(_) => "io_error", + BitFunError::Service(_) => "service_error", + _ => "other_error", + } +} + fn local_candidate_fact(info: &AgentInfo, model: &str) -> LocalCandidateFact { let mut tools = info.default_tools.clone(); tools.sort(); @@ -361,6 +417,16 @@ fn reconcile_with_facts( facts, ); let summary = summary_for(&resolved, initial_activation_state(&resolved)); + if facts.ai_config.is_none() { + if !resolved.definition.disabled { + state.routes.insert( + resolved.definition.logical_id.clone(), + ExternalSubagentRoute::Unavailable, + ); + } + state.summaries.push(summary); + continue; + } if resolved.definition.disabled { state.summaries.push(summary); continue; @@ -373,12 +439,34 @@ fn reconcile_with_facts( state.summaries.push(summary); continue; } + if has_configuration_unavailable_diagnostic(&resolved) { + state.summaries.push(summary); + continue; + } by_logical .entry(normalize_logical_id(&definition.logical_id)) .or_default() .push(resolved); } + if facts.ai_config.is_none() { + for lineage in decisions.conflict_lineage_current_keys.keys() { + if let Some((domain, scope, logical_id)) = parse_conflict_lineage(lineage) { + if domain == execution_domain_id && scope == workspace_scope { + state + .routes + .insert(logical_id.to_string(), ExternalSubagentRoute::Unavailable); + } + } + } + state.summaries.sort_by(|left, right| { + left.logical_id + .cmp(&right.logical_id) + .then(left.candidate_id.cmp(&right.candidate_id)) + }); + return state; + } + let tracked_logical_ids = decisions .conflict_lineage_current_keys .keys() @@ -477,23 +565,34 @@ fn resolve_external_candidate( ), }) .collect::>(); - let model = match &definition.requested_model { - ExternalSubagentModelRequest::Default => { - resolve_bitfun_subagent_model(&definition.logical_id, &facts.ai_config) + 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) + } + 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 } - ExternalSubagentModelRequest::Exact { - provider_hint, - model_name, - } => resolve_exact_external_model(provider_hint.as_deref(), model_name, &facts.ai_config), }; let model = match model { Some(model) => model, None => { - compatibility = ExternalSubagentCompatibilityState::Blocked; - diagnostics.push(ExternalSubagentDiagnosticSummary { - code: "external_subagent.model_unavailable".to_string(), - blocks_activation: true, - }); + if facts.ai_config.is_some() { + compatibility = ExternalSubagentCompatibilityState::Blocked; + diagnostics.push(ExternalSubagentDiagnosticSummary { + code: "external_subagent.model_unavailable".to_string(), + blocks_activation: true, + }); + } ResolvedModelFact { runtime_id: "unavailable".to_string(), display_label: "unavailable".to_string(), @@ -850,11 +949,20 @@ fn initial_activation_state( ExternalSubagentCompatibilityState::Blocked | ExternalSubagentCompatibilityState::Invalid ) { ExternalSubagentActivationState::Blocked + } else if has_configuration_unavailable_diagnostic(candidate) { + ExternalSubagentActivationState::Unavailable } else { ExternalSubagentActivationState::ApprovalRequired } } +fn has_configuration_unavailable_diagnostic(candidate: &ResolvedExternalCandidate) -> bool { + candidate.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "external_subagent.configuration_unavailable" + && diagnostic.blocks_activation + }) +} + fn workspace_scope_key(workspace_root: Option<&Path>) -> String { workspace_root .map(|path| { @@ -1014,7 +1122,7 @@ mod tests { }; ai_config.default_models.fast = Some("model_fast".to_string()); ProductFacts { - ai_config, + ai_config: Some(ai_config), tools: BTreeMap::from([( "Read".to_string(), ResolvedToolFact { @@ -1156,6 +1264,95 @@ mod tests { ); } + #[test] + fn unavailable_bitfun_model_config_is_recoverable_without_claiming_model_mismatch() { + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let definition_snapshot = snapshot("behavior-v1", "catalog-v1"); + let healthy_facts = facts(); + let preview = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &healthy_facts, + ); + let approved = BTreeSet::from([preview.summaries[0].decision_key.clone()]); + let mut unavailable_facts = facts(); + unavailable_facts.ai_config = None; + + let state = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &unavailable_facts, + ); + + assert_eq!( + state.summaries[0].activation_state, + ExternalSubagentActivationState::Unavailable + ); + assert_eq!( + state.summaries[0].compatibility_state, + ExternalSubagentCompatibilityState::Ready + ); + assert!(state.summaries[0].diagnostics.iter().any(|diagnostic| { + diagnostic.code == "external_subagent.configuration_unavailable" + && diagnostic.blocks_activation + })); + assert!(!state.summaries[0] + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "external_subagent.model_unavailable")); + assert!(state.pending_approvals.is_empty()); + assert!(state.registrations.is_empty()); + + let recovered = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &healthy_facts, + ); + assert_eq!( + recovered.summaries[0].activation_state, + ExternalSubagentActivationState::Active + ); + assert_eq!(recovered.registrations.len(), 1); + } + + #[test] + fn model_config_outage_logging_is_deduplicated_and_does_not_expose_error_values() { + let logged = AtomicBool::new(false); + assert!(claim_model_config_outage_log(&logged)); + assert!(!claim_model_config_outage_log(&logged)); + logged.store(false, Ordering::Relaxed); + assert!(claim_model_config_outage_log(&logged)); + + let error = BitFunError::config( + "Failed to deserialize config value at 'ai': invalid value 'sk-sensitive'", + ); + let category = model_config_error_category(&error); + assert_eq!(category, "config_deserialization_failed"); + assert!(!category.contains("sk-sensitive")); + } + #[test] fn catalog_only_update_reuses_behavior_approval() { let first = snapshot("behavior-v1", "catalog-v1"); @@ -1255,13 +1452,14 @@ mod tests { let approved = BTreeSet::from([first.summaries[0].decision_key.clone()]); let mut updated_facts = facts(); - updated_facts.ai_config.models = vec![active_model( + let updated_config = updated_facts.ai_config.as_mut().unwrap(); + updated_config.models = vec![active_model( "model_new", "New provider", "fake", "new-model", )]; - updated_facts.ai_config.default_models.fast = Some("model_new".to_string()); + updated_config.default_models.fast = Some("model_new".to_string()); let updated = reconcile_with_facts( Some(Path::new("C:/repo")), "local-user", @@ -1306,9 +1504,10 @@ mod tests { let approved = BTreeSet::from([first.summaries[0].decision_key.clone()]); let mut updated_facts = facts(); - updated_facts.ai_config.models[0].provider = "other-provider".to_string(); - updated_facts.ai_config.models[0].model_name = "replacement-model".to_string(); - updated_facts.ai_config.models[0].base_url = "https://models.example/v2".to_string(); + let updated_config = updated_facts.ai_config.as_mut().unwrap(); + updated_config.models[0].provider = "other-provider".to_string(); + updated_config.models[0].model_name = "replacement-model".to_string(); + updated_config.models[0].base_url = "https://models.example/v2".to_string(); let updated = reconcile_with_facts( Some(Path::new("C:/repo")), "local-user", @@ -1340,6 +1539,8 @@ mod tests { let mut unavailable_facts = facts(); unavailable_facts .ai_config + .as_mut() + .unwrap() .agent_model_defaults .subagents .default_selection = SubagentModelSelection::Inherit; @@ -1424,6 +1625,95 @@ mod tests { )); } + #[test] + fn model_config_outage_preserves_conflict_choice_for_recovery() { + for select_external in [false, true] { + let (preview, mut product_facts) = conflict_preview_with_local(); + let conflict = &preview.conflicts[0]; + let external_id = conflict + .candidates + .iter() + .find(|candidate| candidate.external) + .unwrap() + .candidate_id + .clone(); + let selected_id = if select_external { + external_id + } else { + "local_subagent:reviewer".to_string() + }; + let choices = BTreeMap::from([(conflict.conflict_key.clone(), selected_id.clone())]); + let approved = BTreeSet::from([preview.summaries[0].decision_key.clone()]); + let empty_map = BTreeMap::new(); + let healthy_ai_config = product_facts.ai_config.take(); + + let unavailable = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &choices, + conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + }, + &product_facts, + ); + + assert_eq!( + unavailable.routes.get("reviewer"), + Some(&ExternalSubagentRoute::Unavailable) + ); + assert!(unavailable + .summaries + .iter() + .all(|summary| summary.activation_state + == ExternalSubagentActivationState::Unavailable)); + assert!(unavailable.conflicts.is_empty()); + assert!(unavailable + .observed_conflict_lineage_current_keys + .is_empty()); + assert!(unavailable.pending_approvals.is_empty()); + assert!(unavailable.registrations.is_empty()); + + product_facts.ai_config = healthy_ai_config; + let recovered = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &choices, + conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + }, + &product_facts, + ); + + assert_eq!( + recovered.conflicts[0].selected_candidate_id.as_deref(), + Some(selected_id.as_str()) + ); + assert_eq!( + recovered.observed_conflict_lineage_current_keys, + preview.observed_conflict_lineage_current_keys + ); + if select_external { + assert!(matches!( + recovered.routes.get("reviewer"), + Some(ExternalSubagentRoute::External(_)) + )); + assert_eq!(recovered.registrations.len(), 1); + } else { + assert_eq!( + recovered.routes.get("reviewer"), + Some(&ExternalSubagentRoute::Local) + ); + assert!(recovered.registrations.is_empty()); + } + } + } + fn conflict_preview_with_local() -> (ExternalSubagentProductState, ProductFacts) { let mut product_facts = facts(); product_facts.locals.insert( diff --git a/src/crates/services/services-integrations/src/script_tool.rs b/src/crates/services/services-integrations/src/script_tool.rs index 057aff1026..04de9f9bef 100644 --- a/src/crates/services/services-integrations/src/script_tool.rs +++ b/src/crates/services/services-integrations/src/script_tool.rs @@ -793,10 +793,11 @@ impl ScriptToolRuntime for NodeScriptToolRuntime { match &self.executable { Some(executable) => ScriptToolRuntimeAvailability::Available { executable: executable.to_string_lossy().into_owned(), - version: "verified when first enabled".to_string(), + version: "not checked".to_string(), }, None => ScriptToolRuntimeAvailability::Unavailable { - reason: "Node.js was not found; discovered tools remain disabled".to_string(), + reason: "BitFun could not find Node.js for external tools; install or repair Node.js, then restart BitFun" + .to_string(), }, } } diff --git a/src/crates/services/services-integrations/tests/script_tool_runtime.rs b/src/crates/services/services-integrations/tests/script_tool_runtime.rs index 4a373c055d..d47a500988 100644 --- a/src/crates/services/services-integrations/tests/script_tool_runtime.rs +++ b/src/crates/services/services-integrations/tests/script_tool_runtime.rs @@ -58,6 +58,15 @@ fn named_invoke_request(operation_id: &str, revision: &str) -> ScriptToolInvokeR request } +#[tokio::test] +async fn runtime_availability_does_not_claim_an_unchecked_node_version() { + let runtime = NodeScriptToolRuntime::discover(); + + if let ScriptToolRuntimeAvailability::Available { version, .. } = runtime.availability().await { + assert_eq!(version, "not checked"); + } +} + #[tokio::test] async fn node_worker_loads_invokes_updates_and_disposes_a_target() { let runtime = NodeScriptToolRuntime::discover(); 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 5b4d7aa81e..5de6023c8a 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -407,6 +407,50 @@ describe('ExternalSourcesConfig', () => { ); }); + it('shows model-setting read failures as temporarily unavailable', async () => { + getSnapshotMock.mockResolvedValue({ + ...snapshot, + commandConflicts: [], + subagentGeneration: 4, + preferenceRevision: 7, + subagents: [{ + candidateId: 'external-agent-review-v1', + logicalId: 'review', + displayName: 'External Review', + description: 'Review a change', + providerLabel: 'Future AI', + scope: 'project', + sourceKeys: [{ providerId: 'future.agents', sourceId: 'review' }], + sourceLocationLabels: ['/.future/agents/review.md'], + sourceCount: 1, + effectiveToolLabels: ['Read'], + supportsFollowUp: false, + compatibilityState: 'ready', + diagnostics: [{ + code: 'external_subagent.configuration_unavailable', + blocksActivation: true, + }], + activationState: { state: 'unavailable' }, + decisionKey: 'agent-decision-unavailable', + }], + subagentConflicts: [], + pendingSubagentApprovals: [], + }); + + 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('agentState.unavailable'); + expect(container.textContent).not.toContain('agentState.blocked'); + expect(container.textContent).toContain('agentDiagnostics.configurationUnavailable.reason'); + expect(container.textContent).not.toContain('agents.enable'); + }); + it('non-blockingly reports when an enabled external agent becomes unavailable', async () => { const activeAgent = { candidateId: 'external-agent-review-v1', diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 63a1af67b4..3260c364f2 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -36,6 +36,7 @@ function matchesToolSource( } function agentDiagnosticCategory(code: string, blocksActivation: boolean): string { + if (code.includes('configuration_unavailable')) return 'configurationUnavailable'; if (code.includes('model_unavailable')) return 'modelUnavailable'; if (code.includes('tool_unavailable')) return 'toolUnavailable'; if (code.includes('type_invalid') || code.includes('definition_invalid') diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index 5afc3061ff..bad5be3f19 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -93,6 +93,11 @@ "agentDiagnostics": { "activationBlocked": "This agent cannot be enabled yet.", "degradedOnly": "Some settings will not apply in BitFun. Review the details to confirm the resulting behavior is acceptable.", + "configurationUnavailable": { + "reason": "BitFun could not read its model settings.", + "impact": "{{impact}}", + "nextStep": "Open BitFun model settings and confirm they load. If not, restart BitFun. If the problem continues, check that BitFun can read and save its settings; then refresh." + }, "modelUnavailable": { "reason": "The requested model is not available in BitFun.", "impact": "{{impact}}", @@ -182,7 +187,7 @@ "active": "No action needed. Disable these tools to stop using this source's tools.", "conflict": "Choose which tool to use below, or leave them disabled.", "unsupported": "Change the source to a single JavaScript file supported by BitFun, then refresh.", - "runtime_unavailable": "Restore the required JavaScript environment, then refresh.", + "runtime_unavailable": "Install or repair Node.js, then restart BitFun to check again.", "load_failed": "Fix the source and refresh, or keep these tools disabled." }, "toolConflicts": { diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index bb3d9393a3..2c18d75bcd 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -93,6 +93,11 @@ "agentDiagnostics": { "activationBlocked": "暂时无法启用此 Agent。", "degradedOnly": "部分设置在 BitFun 中不会生效。请查看说明,确认实际结果符合预期。", + "configurationUnavailable": { + "reason": "BitFun 无法读取模型设置。", + "impact": "{{impact}}", + "nextStep": "请打开 BitFun 的模型设置并确认可以正常加载;如无法加载,请重启 BitFun。问题仍存在时,请确认 BitFun 可以正常读取和保存设置,然后刷新。" + }, "modelUnavailable": { "reason": "该 Agent 请求的模型在 BitFun 中不可用。", "impact": "{{impact}}", @@ -182,7 +187,7 @@ "active": "无需操作。如需停止使用该来源的全部工具,请停用这些工具。", "conflict": "请在下方选择要使用的工具,或继续保持停用。", "unsupported": "请将来源改为 BitFun 当前支持的单文件 JavaScript 格式,然后刷新。", - "runtime_unavailable": "请恢复所需的 JavaScript 运行环境,然后刷新。", + "runtime_unavailable": "请安装或修复 Node.js,然后重启 BitFun 重新检查。", "load_failed": "请修复来源后刷新,或继续停用这些工具。" }, "toolConflicts": { diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 81e1421cde..25ae9f8d69 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -93,6 +93,11 @@ "agentDiagnostics": { "activationBlocked": "暫時無法啟用此 Agent。", "degradedOnly": "部分設定在 BitFun 中不會生效。請查看說明,確認實際結果符合預期。", + "configurationUnavailable": { + "reason": "BitFun 無法讀取模型設定。", + "impact": "{{impact}}", + "nextStep": "請開啟 BitFun 的模型設定並確認可以正常載入;若無法載入,請重新啟動 BitFun。問題仍存在時,請確認 BitFun 可以正常讀取與儲存設定,然後重新整理。" + }, "modelUnavailable": { "reason": "此 Agent 要求的模型在 BitFun 中不可用。", "impact": "{{impact}}", @@ -182,7 +187,7 @@ "active": "無需操作。若要停止使用該來源的所有工具,請停用這些工具。", "conflict": "請在下方選擇要使用的工具,或繼續保持停用。", "unsupported": "請將來源改為 BitFun 目前支援的單一 JavaScript 檔案格式,然後重新整理。", - "runtime_unavailable": "請恢復所需的 JavaScript 執行環境,然後重新整理。", + "runtime_unavailable": "請安裝或修復 Node.js,然後重新啟動 BitFun 以再次檢查。", "load_failed": "請修正來源後重新整理,或繼續停用這些工具。" }, "toolConflicts": {