From 27a28f9c59bbef4220038ca2c9bcda0da9a43092 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 23 Aug 2026 01:44:11 -0700 Subject: [PATCH] fix(agent): make BitFun self-control work in CLI --- docs/interactive-capabilities/README.md | 6 +- .../capabilities.json | 38 +- .../capabilities/setting.tools.execution.md | 4 +- .../technical/tauri-command-map.json | 2 +- .../technical/ui-interaction-inventory.json | 2 +- scripts/generate-interactive-capabilities.mjs | 15 +- src/apps/desktop/src/bitfun_control_host.rs | 279 +------ .../agentic/tools/bitfun_control_config.rs | 423 +++++++++++ .../implementations/bitfun_control_tool.rs | 698 ++++++++++++++---- .../assembly/core/src/agentic/tools/mod.rs | 1 + .../generated/product-control-catalog.json | 41 +- .../product-domains/src/product_control.rs | 90 ++- .../interactive-capabilities/catalog.json | 12 +- .../generated/interactive-capabilities.json | 41 +- 14 files changed, 1222 insertions(+), 430 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/tools/bitfun_control_config.rs diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 9725babc4e..13b1291fea 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -23,12 +23,12 @@ Docs, website, search, and agents see only features, settings, and documented su - 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **49**、委托 **61**、需交互 **205**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;Agent 不接触原始 Tauri Command。 - `BitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 315 项子能力都不会写入 system prompt。 -- 目录发现与契约读取不依赖 React 或可见窗口;Desktop 原生启动时注册状态读取与修改 Provider,只有导航类动作等待界面握手。CLI、Detached Dispatch 等表面仍可发现目录,但必须明确返回该表面缺少控制适配器,禁止静默回退本机。只读 Agent 只能发现和读取目录。 +- 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读 Agent 只能发现和读取目录。 - Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **49 direct**, **61 delegated**, **205 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. - `BitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 315 documented items enters the system prompt. -- Discovery and contract lookup do not depend on React or a visible window. Desktop installs state providers during native startup and waits for the UI handshake only for presentation actions. Headless surfaces can still discover the catalog but must return explicit adapter unavailability without local fallback; read-only agents may only discover and inspect entries. +- Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates @@ -36,6 +36,7 @@ Docs, website, search, and agents see only features, settings, and documented su - 所有设置子视图必须有同源的子能力直达目标,失效的页签 ID 会阻断生成。 - 每个功能和设置都必须完整列出其真实子能力,并具备中英文、稳定 ID 与源码证据;门禁不以凑数阈值代替完整性审查。 - 每项子能力必须声明控制类型;所有 operation/option 必须被说明书条目引用,静态值必须符合 schema,任何未绑定 Provider 都会在 Rust 契约测试中失败。 +- 每个共享配置 option 必须从默认配置可读、绑定到类型化 GlobalConfig,并能通过真实 ConfigService 写入后回读;Desktop 原生 Provider 也必须逐项绑定,新增但漏接的 handler 会阻断 CI。 - Operation 可声明结构化参数,但必须拒绝未知字段;无参数的 UI 动作若误声明参数,生成会失败。 - Tauri 模块命令数和用户可见交互源码摘要均为 reviewed contract;变化会让 `capabilities:check` 失败并给出新的摘要值。 - 维护者必须先核对功能清单与证据,再只在本语义源中更新 reviewed count/digest,随后运行 `pnpm run capabilities:generate`。 @@ -44,6 +45,7 @@ Docs, website, search, and agents see only features, settings, and documented su - Every settings subview needs a same-source item destination; stale view IDs fail generation. - Every feature and setting must enumerate its real sub-capabilities with bilingual text, stable IDs, and source evidence; the gate does not substitute padding quotas for completeness review. - Every item must declare its control class. Every operation/option must be referenced by a manual item, static values must satisfy their schema, and Rust contract tests reject provider bindings without an implementation. +- Every shared config option must be readable from defaults, bind to typed GlobalConfig, and round-trip through the real ConfigService. Desktop native providers are exhaustively bound as well, so a new but unwired handler fails CI. - Operations may declare structured arguments but must reject unknown fields. Argument declarations on parameterless UI actions fail generation. - Reviewed Tauri module counts and the user-visible interaction-source digest fail `capabilities:check` on drift. - Maintainers review the inventories and evidence first, update the reviewed count/digest only in this semantic source, then run `pnpm run capabilities:generate`. diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index de01f8e068..ad7f8c7672 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "BitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c743edc2a5b5103230aba095264d371c81999653fad41f89f0e46878d5f9c91a", + "digest": "3c78642ff2ca49ce070288d4e9dc22c9615f731f46a56d8135fea25cf5c474bf", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -83,6 +83,30 @@ "capabilityId": "feature.pages", "itemId": "publish" } + }, + { + "id": "deferred-tools-chinese", + "query": "延迟加载工具", + "expectedFirstCapabilityId": "setting.tools.execution", + "expectedCapabilityIds": [ + "setting.tools.execution" + ], + "expectedItem": { + "capabilityId": "setting.tools.execution", + "itemId": "deferred-tools" + } + }, + { + "id": "deferred-tools-english", + "query": "deferred tool loading", + "expectedFirstCapabilityId": "setting.tools.execution", + "expectedCapabilityIds": [ + "setting.tools.execution" + ], + "expectedItem": { + "capabilityId": "setting.tools.execution", + "itemId": "deferred-tools" + } } ], "counts": { @@ -8127,6 +8151,7 @@ "keywordsZh": [ "执行控制", "工具权限", + "延迟加载工具", "审批", "并行", "超时", @@ -8137,6 +8162,7 @@ "keywordsEn": [ "execution control", "tool permissions", + "deferred tool loading", "approval", "parallel", "timeout", @@ -8201,8 +8227,8 @@ }, { "id": "deferred-tools", - "titleZh": "按需延迟加载大型工具定义以减少 Agent 初始上下文", - "titleEn": "Load large tool definitions on demand to reduce initial agent context", + "titleZh": "按需延迟加载工具定义以减少 Agent 初始上下文", + "titleEn": "Deferred tool loading on demand to reduce initial agent context", "control": { "kind": "direct", "operations": [], @@ -8457,6 +8483,7 @@ "AI & collaboration", "执行控制", "工具权限", + "延迟加载工具", "审批", "并行", "超时", @@ -8465,6 +8492,7 @@ "JSON 修复", "execution control", "tool permissions", + "deferred tool loading", "approval", "parallel", "timeout", @@ -8485,8 +8513,8 @@ "Create, reorder, and save global or project-level Allow, Ask, and Deny rules", "查看、撤销或清空项目中记住的权限授权与审计记录", "Review, revoke, or clear remembered project permission grants and audit records", - "按需延迟加载大型工具定义以减少 Agent 初始上下文", - "Load large tool definitions on demand to reduce initial agent context", + "按需延迟加载工具定义以减少 Agent 初始上下文", + "Deferred tool loading on demand to reduce initial agent context", "设置子 Agent 与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具执行超时", diff --git a/docs/interactive-capabilities/capabilities/setting.tools.execution.md b/docs/interactive-capabilities/capabilities/setting.tools.execution.md index d82302de78..a164254f2c 100644 --- a/docs/interactive-capabilities/capabilities/setting.tools.execution.md +++ b/docs/interactive-capabilities/capabilities/setting.tools.execution.md @@ -25,8 +25,8 @@ Manage agent tool permissions, concurrency, timeouts, deferred loading, Computer - Create, reorder, and save global or project-level Allow, Ask, and Deny rules - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 查看、撤销或清空项目中记住的权限授权与审计记录 - Review, revoke, or clear remembered project permission grants and audit records -- **Agent 可直接控制 / Direct Agent control** · 按需延迟加载大型工具定义以减少 Agent 初始上下文 - - Load large tool definitions on demand to reduce initial agent context +- **Agent 可直接控制 / Direct Agent control** · 按需延迟加载工具定义以减少 Agent 初始上下文 + - Deferred tool loading on demand to reduce initial agent context - **Agent 可直接控制 / Direct Agent control** · 设置子 Agent 与 Swarm 最大并发及安全、强制并行或串行策略 - Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy - **Agent 可直接控制 / Direct Agent control** · 设置或取消单次工具执行超时 diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 5ac5d0deb1..6ea8da2b82 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "c743edc2a5b5103230aba095264d371c81999653fad41f89f0e46878d5f9c91a", + "catalogDigest": "3c78642ff2ca49ce070288d4e9dc22c9615f731f46a56d8135fea25cf5c474bf", "commandCount": 649, "coverage": { "commandCount": 649, diff --git a/docs/interactive-capabilities/technical/ui-interaction-inventory.json b/docs/interactive-capabilities/technical/ui-interaction-inventory.json index 45f75a54bf..2a9d36a515 100644 --- a/docs/interactive-capabilities/technical/ui-interaction-inventory.json +++ b/docs/interactive-capabilities/technical/ui-interaction-inventory.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "c743edc2a5b5103230aba095264d371c81999653fad41f89f0e46878d5f9c91a", + "catalogDigest": "3c78642ff2ca49ce070288d4e9dc22c9615f731f46a56d8135fea25cf5c474bf", "roots": [ "src/web-ui/src" ], diff --git a/scripts/generate-interactive-capabilities.mjs b/scripts/generate-interactive-capabilities.mjs index c4867fef04..be2cc72369 100644 --- a/scripts/generate-interactive-capabilities.mjs +++ b/scripts/generate-interactive-capabilities.mjs @@ -604,7 +604,14 @@ function validateSource(source) { )) { throw new Error(`${capability.id}.${option.id} has an invalid provider option handler`); } - if (!['config', 'mergeConfig', 'appearanceSelection', 'language', 'provider'].includes(option.handler.kind)) { + if (![ + 'config', + 'mergeConfig', + 'appearanceSelection', + 'language', + 'flowChatPermissionModeControl', + 'provider', + ].includes(option.handler.kind)) { throw new Error(`${capability.id}.${option.id} has an unsupported option handler`); } } @@ -1236,12 +1243,12 @@ Docs, website, search, and agents see only features, settings, and documented su - 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **${catalog.counts.controlCoverage.direct}**、委托 **${catalog.counts.controlCoverage.delegated}**、需交互 **${catalog.counts.controlCoverage.interactive}**、不支持 **${catalog.counts.controlCoverage.unsupported}**。 - 稳定行为声明为带 JSON 输入契约的 \`operations\` 或 \`options\`,并绑定原生产品控制 Provider;Agent 不接触原始 Tauri Command。 - \`BitFunControl list\` 和 \`search\` 都返回带 \`nextCursor\` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 ${catalog.counts.documentedItems} 项子能力都不会写入 system prompt。 -- 目录发现与契约读取不依赖 React 或可见窗口;Desktop 原生启动时注册状态读取与修改 Provider,只有导航类动作等待界面握手。CLI、Detached Dispatch 等表面仍可发现目录,但必须明确返回该表面缺少控制适配器,禁止静默回退本机。只读 Agent 只能发现和读取目录。 +- 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读 Agent 只能发现和读取目录。 - Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **${catalog.counts.controlCoverage.direct} direct**, **${catalog.counts.controlCoverage.delegated} delegated**, **${catalog.counts.controlCoverage.interactive} interactive**, and **${catalog.counts.controlCoverage.unsupported} unsupported**. - Stable behavior becomes a typed \`operation\` or \`option\` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. - \`BitFunControl list\` and \`search\` return compact pages with a \`nextCursor\`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its ${catalog.counts.documentedItems} documented items enters the system prompt. -- Discovery and contract lookup do not depend on React or a visible window. Desktop installs state providers during native startup and waits for the UI handshake only for presentation actions. Headless surfaces can still discover the catalog but must return explicit adapter unavailability without local fallback; read-only agents may only discover and inspect entries. +- Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates @@ -1249,6 +1256,7 @@ Docs, website, search, and agents see only features, settings, and documented su - 所有设置子视图必须有同源的子能力直达目标,失效的页签 ID 会阻断生成。 - 每个功能和设置都必须完整列出其真实子能力,并具备中英文、稳定 ID 与源码证据;门禁不以凑数阈值代替完整性审查。 - 每项子能力必须声明控制类型;所有 operation/option 必须被说明书条目引用,静态值必须符合 schema,任何未绑定 Provider 都会在 Rust 契约测试中失败。 +- 每个共享配置 option 必须从默认配置可读、绑定到类型化 GlobalConfig,并能通过真实 ConfigService 写入后回读;Desktop 原生 Provider 也必须逐项绑定,新增但漏接的 handler 会阻断 CI。 - Operation 可声明结构化参数,但必须拒绝未知字段;无参数的 UI 动作若误声明参数,生成会失败。 - Tauri 模块命令数和用户可见交互源码摘要均为 reviewed contract;变化会让 \`capabilities:check\` 失败并给出新的摘要值。 - 维护者必须先核对功能清单与证据,再只在本语义源中更新 reviewed count/digest,随后运行 \`pnpm run capabilities:generate\`。 @@ -1257,6 +1265,7 @@ Docs, website, search, and agents see only features, settings, and documented su - Every settings subview needs a same-source item destination; stale view IDs fail generation. - Every feature and setting must enumerate its real sub-capabilities with bilingual text, stable IDs, and source evidence; the gate does not substitute padding quotas for completeness review. - Every item must declare its control class. Every operation/option must be referenced by a manual item, static values must satisfy their schema, and Rust contract tests reject provider bindings without an implementation. +- Every shared config option must be readable from defaults, bind to typed GlobalConfig, and round-trip through the real ConfigService. Desktop native providers are exhaustively bound as well, so a new but unwired handler fails CI. - Operations may declare structured arguments but must reject unknown fields. Argument declarations on parameterless UI actions fail generation. - Reviewed Tauri module counts and the user-visible interaction-source digest fail \`capabilities:check\` on drift. - Maintainers review the inventories and evidence first, update the reviewed count/digest only in this semantic source, then run \`pnpm run capabilities:generate\`. diff --git a/src/apps/desktop/src/bitfun_control_host.rs b/src/apps/desktop/src/bitfun_control_host.rs index abebbeef88..2d48fdc97c 100644 --- a/src/apps/desktop/src/bitfun_control_host.rs +++ b/src/apps/desktop/src/bitfun_control_host.rs @@ -9,13 +9,14 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; +use bitfun_core::agentic::tools::bitfun_control_config::{ + configure_config_backed_option, read_config_backed_option, +}; use bitfun_core::agentic::tools::bitfun_control_host::{ set_bitfun_control_port, BitFunControlHostRequest, ProductControlAction, ProductControlPort, }; use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; -use bitfun_core::service::config::types::{ - AIExperienceConfig, AgentCompanionPetSelection, MemoriesConfig, -}; +use bitfun_core::service::config::types::{AIExperienceConfig, AgentCompanionPetSelection}; use bitfun_product_domains::product_control::{ capability as product_capability, inspect_contract, validate_open_target, validate_operation_arguments, validate_option_value, ProductCapabilityOperationHandler, @@ -97,97 +98,15 @@ async fn dispatch_surface_request(request: BitFunControlHostRequest) -> Result(value: &'a Value, field: &str) -> Option<&'a Value> { - let mut current = value; - for segment in field.split('.') { - current = current.as_object()?.get(segment)?; - } - Some(current) -} - -fn set_nested_value(value: &mut Value, field: &str, next_value: Value) -> Result<(), String> { - let segments: Vec<&str> = field - .split('.') - .filter(|segment| !segment.is_empty()) - .collect(); - let Some((last, parents)) = segments.split_last() else { - return Err("A product-control config field cannot be empty".to_string()); - }; - let mut current = value; - for segment in parents { - if !current.is_object() { - *current = Value::Object(Map::new()); - } - current = current - .as_object_mut() - .expect("object was initialized") - .entry((*segment).to_string()) - .or_insert_with(|| Value::Object(Map::new())); - } - if !current.is_object() { - *current = Value::Object(Map::new()); - } - current - .as_object_mut() - .expect("object was initialized") - .insert((*last).to_string(), next_value); - Ok(()) -} - -fn validate_config_semantics(path: &str, value: &Value) -> Result<(), String> { - if path == "memories" { - let memories: MemoriesConfig = serde_json::from_value(value.clone()) - .map_err(|error| format!("Invalid memory settings: {error}"))?; - if memories.max_rollout_age_days > memories.max_unused_days { - return Err("Memory rollout age must not exceed unused-memory retention".to_string()); - } - } - Ok(()) -} - async fn current_option_value( app: &AppHandle, state: &AppState, option: &ProductCapabilityOption, ) -> Result { + if let Some(value) = read_config_backed_option(&state.config_service, option).await? { + return Ok(value); + } match &option.handler { - ProductCapabilityOptionHandler::Config { path } => state - .config_service - .get_config(Some(path)) - .await - .map_err(|error| error.to_string()), - ProductCapabilityOptionHandler::MergeConfig { path, fields } => { - let current: Value = state - .config_service - .get_config(Some(path)) - .await - .map_err(|error| error.to_string())?; - let values: Vec = fields - .iter() - .map(|field| { - read_nested_value(¤t, field) - .cloned() - .unwrap_or(Value::Null) - }) - .collect(); - if values.len() == 1 || values.windows(2).all(|pair| pair[0] == pair[1]) { - Ok(values.into_iter().next().unwrap_or(Value::Null)) - } else { - Ok(Value::Object( - fields.iter().cloned().zip(values).collect::>(), - )) - } - } - ProductCapabilityOptionHandler::AppearanceSelection => state - .config_service - .get_config(Some("appearance.selection")) - .await - .map_err(|error| error.to_string()), - ProductCapabilityOptionHandler::Language => state - .config_service - .get_config(Some("app.language")) - .await - .map_err(|error| error.to_string()), ProductCapabilityOptionHandler::Provider { provider_id, option_id, @@ -206,6 +125,7 @@ async fn current_option_value( "Product-control provider option is not registered: {provider_id}:{option_id}" )), }, + _ => Err("Shared product-control config handler did not return a value".to_string()), } } @@ -353,65 +273,27 @@ async fn configure_desktop( .ok_or_else(|| format!("Unknown option for {capability_id}: {option_id}"))?; validate_option_value(&option.value_schema, value)?; let state = app.state::(); - let (changed_path, notify_settings) = match &option.handler { - ProductCapabilityOptionHandler::Config { path } => { - state - .config_service - .set_config(path, value) - .await - .map_err(|error| error.to_string())?; - (path.clone(), true) - } - ProductCapabilityOptionHandler::MergeConfig { path, fields } => { - let mut current: Value = state - .config_service - .get_config(Some(path)) - .await - .map_err(|error| error.to_string())?; - for field in fields { - set_nested_value(&mut current, field, value.clone())?; - } - validate_config_semantics(path, ¤t)?; - state - .config_service - .set_config(path, current) - .await - .map_err(|error| error.to_string())?; - (path.clone(), true) - } - ProductCapabilityOptionHandler::AppearanceSelection => { - state - .config_service - .set_config("appearance.selection", value) - .await - .map_err(|error| error.to_string())?; - ("appearance.selection".to_string(), true) - } - ProductCapabilityOptionHandler::Language => { - state - .config_service - .set_config("app.language", value) - .await - .map_err(|error| error.to_string())?; - ("app.language".to_string(), true) - } - ProductCapabilityOptionHandler::Provider { - provider_id, - option_id, - } => { - let provider = desktop_provider_option(provider_id, option_id).ok_or_else(|| { - format!( - "Product-control provider option is not registered: {provider_id}:{option_id}" - ) - })?; - configure_desktop_provider_option(app, provider, value).await?; - (provider.changed_path().to_string(), false) - } + let (changed_path, effective_value, notify_settings) = if let Some(applied) = + configure_config_backed_option(&state.config_service, option, value).await? + { + (applied.changed_path, applied.effective_value, true) + } else if let ProductCapabilityOptionHandler::Provider { + provider_id, + option_id, + } = &option.handler + { + let provider = desktop_provider_option(provider_id, option_id).ok_or_else(|| { + format!("Product-control provider option is not registered: {provider_id}:{option_id}") + })?; + configure_desktop_provider_option(app, provider, value).await?; + let effective_value = current_option_value(app, &state, option).await?; + (provider.changed_path().to_string(), effective_value, false) + } else { + return Err("Product-control option has no executable handler".to_string()); }; if notify_settings { crate::api::remote_connect_api::notify_settings_changed(); } - let effective_value = current_option_value(app, &state, option).await?; let presentation_sync = emit_applied( capability_id, None, @@ -767,81 +649,6 @@ pub(crate) async fn report_bitfun_control_result( #[cfg(test)] mod tests { use super::*; - use bitfun_core::service::config::types::GlobalConfig; - use bitfun_product_domains::product_control::{ - ProductControlValueSchema, ProductControlValueType, - }; - - fn writable_samples(schema: &ProductControlValueSchema, current: Option<&Value>) -> Vec { - let mut samples = Vec::new(); - if let Some(values) = &schema.r#enum { - samples.extend(values.iter().cloned()); - } - match schema.value_type { - ProductControlValueType::Boolean => { - samples.push(Value::Bool(true)); - samples.push(Value::Bool(false)); - } - ProductControlValueType::String => samples.push(Value::String( - "x".repeat(schema.min_length.unwrap_or(1).max(1)), - )), - ProductControlValueType::Integer => { - samples.push(Value::from(schema.minimum.unwrap_or(1.0).ceil() as i64)); - if let Some(maximum) = schema.maximum { - samples.push(Value::from(maximum.floor() as i64)); - } - } - ProductControlValueType::Number => { - samples.push(Value::from(schema.minimum.unwrap_or(1.0))); - if let Some(maximum) = schema.maximum { - samples.push(Value::from(maximum)); - } - } - ProductControlValueType::Object => samples.push(json!({})), - ProductControlValueType::Array => samples.push(json!([])), - } - if schema.nullable { - samples.push(Value::Null); - } - if let Some(current) = current { - samples.push(current.clone()); - } - samples.retain(|sample| validate_option_value(schema, sample).is_ok()); - samples.dedup(); - samples - } - - fn assert_config_binding(root: &Value, path: &str, schema: &ProductControlValueSchema) { - let current = read_nested_value(root, path); - if let Some(current) = current { - assert!( - validate_option_value(schema, current).is_ok(), - "default value at {path} does not satisfy its product-control schema: {current}" - ); - } - - // GlobalConfig intentionally omits default-valued and None fields when - // serialized. Inject several valid non-default samples, deserialize to - // the typed config, and require one to survive serialization. Unknown - // serde fields are discarded, so this proves the catalog path is owned - // by the typed config without making production upgrades intolerant of - // newer fields. - let samples = writable_samples(schema, current); - for sample in &samples { - let mut candidate = root.clone(); - set_nested_value(&mut candidate, path, sample.clone()).unwrap(); - let Ok(typed) = serde_json::from_value::(candidate) else { - continue; - }; - let serialized = serde_json::to_value(typed).unwrap(); - if read_nested_value(&serialized, path) == Some(sample) { - return; - } - } - panic!( - "product-control config path is not consumed by typed GlobalConfig: {path}; valid samples: {samples:?}" - ); - } #[test] fn every_provider_operation_in_the_catalog_has_a_desktop_binding() { @@ -884,40 +691,4 @@ mod tests { } } } - - #[test] - fn every_catalog_config_option_binds_to_the_typed_global_config() { - let catalog = bitfun_product_domains::product_control::catalog().unwrap(); - let root = serde_json::to_value(GlobalConfig::default()).unwrap(); - for option in catalog - .capabilities - .iter() - .flat_map(|capability| &capability.options) - { - match &option.handler { - ProductCapabilityOptionHandler::Config { path } => { - assert_config_binding(&root, path, &option.value_schema); - } - ProductCapabilityOptionHandler::MergeConfig { path, fields } => { - for field in fields { - assert_config_binding( - &root, - &format!("{path}.{field}"), - &option.value_schema, - ); - } - let current = read_nested_value(&root, path) - .unwrap_or_else(|| panic!("product-control merge path is absent: {path}")); - validate_config_semantics(path, current).unwrap(); - } - ProductCapabilityOptionHandler::AppearanceSelection => { - assert_config_binding(&root, "appearance.selection", &option.value_schema); - } - ProductCapabilityOptionHandler::Language => { - assert_config_binding(&root, "app.language", &option.value_schema); - } - ProductCapabilityOptionHandler::Provider { .. } => {} - } - } - } } diff --git a/src/crates/assembly/core/src/agentic/tools/bitfun_control_config.rs b/src/crates/assembly/core/src/agentic/tools/bitfun_control_config.rs new file mode 100644 index 0000000000..ae0210d91d --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/bitfun_control_config.rs @@ -0,0 +1,423 @@ +//! Shared, config-backed BitFun product control. +//! +//! Product surfaces may provide extra adapters for presentation and native +//! providers, but ordinary BitFun settings are owned by the shared +//! [`ConfigService`]. Keeping their read/write behavior here lets Desktop, +//! CLI, and other headless product hosts execute the same catalog handlers. + +use crate::service::config::types::{GlobalConfig, MemoriesConfig}; +use crate::service::config::ConfigService; +use bitfun_product_domains::product_control::{ + validate_option_value, ProductCapabilityOption, ProductCapabilityOptionHandler, +}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, PartialEq)] +pub struct AppliedProductConfigOption { + pub changed_path: String, + pub effective_value: Value, +} + +fn read_nested_value<'a>(value: &'a Value, field: &str) -> Option<&'a Value> { + let mut current = value; + for segment in field.split('.') { + current = current.as_object()?.get(segment)?; + } + Some(current) +} + +fn set_nested_value(value: &mut Value, field: &str, next_value: Value) -> Result<(), String> { + let segments: Vec<&str> = field + .split('.') + .filter(|segment| !segment.is_empty()) + .collect(); + let Some((last, parents)) = segments.split_last() else { + return Err("A product-control config field cannot be empty".to_string()); + }; + let mut current = value; + for segment in parents { + if !current.is_object() { + *current = Value::Object(Map::new()); + } + current = current + .as_object_mut() + .expect("object was initialized") + .entry((*segment).to_string()) + .or_insert_with(|| Value::Object(Map::new())); + } + if !current.is_object() { + *current = Value::Object(Map::new()); + } + current + .as_object_mut() + .expect("object was initialized") + .insert((*last).to_string(), next_value); + Ok(()) +} + +fn validate_config_semantics(path: &str, value: &Value) -> Result<(), String> { + if path == "memories" { + let memories: MemoriesConfig = serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid memory settings: {error}"))?; + if memories.max_rollout_age_days > memories.max_unused_days { + return Err("Memory rollout age must not exceed unused-memory retention".to_string()); + } + } + Ok(()) +} + +/// Read an option implemented by the shared BitFun configuration service. +/// +/// `Ok(None)` means the semantic catalog deliberately routed the option to a +/// product-host provider instead of shared config. +pub async fn read_config_backed_option( + config_service: &ConfigService, + option: &ProductCapabilityOption, +) -> Result, String> { + let value = match &option.handler { + ProductCapabilityOptionHandler::Config { path } => config_service + .get_config(Some(path)) + .await + .map_err(|error| error.to_string())?, + ProductCapabilityOptionHandler::MergeConfig { path, fields } => { + let current: Value = config_service + .get_config(Some(path)) + .await + .map_err(|error| error.to_string())?; + let values: Vec = fields + .iter() + .map(|field| { + read_nested_value(¤t, field) + .cloned() + .unwrap_or(Value::Null) + }) + .collect(); + if values.len() == 1 || values.windows(2).all(|pair| pair[0] == pair[1]) { + values.into_iter().next().unwrap_or(Value::Null) + } else { + Value::Object(fields.iter().cloned().zip(values).collect::>()) + } + } + ProductCapabilityOptionHandler::AppearanceSelection => config_service + .get_config(Some("appearance.selection")) + .await + .map_err(|error| error.to_string())?, + ProductCapabilityOptionHandler::Language => config_service + .get_config(Some("app.language")) + .await + .map_err(|error| error.to_string())?, + ProductCapabilityOptionHandler::FlowChatPermissionModeControl => { + let config: GlobalConfig = config_service + .get_config(None) + .await + .map_err(|error| error.to_string())?; + Value::Bool(config.app.flow_chat.show_permission_mode_control) + } + ProductCapabilityOptionHandler::Provider { .. } => return Ok(None), + }; + Ok(Some(value)) +} + +/// Apply an option implemented by the shared BitFun configuration service and +/// read the persisted effective value back through the same handler. +/// +/// `Ok(None)` means a product-host provider owns the option. +pub async fn configure_config_backed_option( + config_service: &ConfigService, + option: &ProductCapabilityOption, + value: &Value, +) -> Result, String> { + validate_option_value(&option.value_schema, value)?; + let changed_path = match &option.handler { + ProductCapabilityOptionHandler::Config { path } => { + config_service + .set_config(path, value.clone()) + .await + .map_err(|error| error.to_string())?; + path.clone() + } + ProductCapabilityOptionHandler::MergeConfig { path, fields } => { + let mut current: Value = config_service + .get_config(Some(path)) + .await + .map_err(|error| error.to_string())?; + for field in fields { + set_nested_value(&mut current, field, value.clone())?; + } + validate_config_semantics(path, ¤t)?; + config_service + .set_config(path, current) + .await + .map_err(|error| error.to_string())?; + path.clone() + } + ProductCapabilityOptionHandler::AppearanceSelection => { + config_service + .set_config("appearance.selection", value.clone()) + .await + .map_err(|error| error.to_string())?; + "appearance.selection".to_string() + } + ProductCapabilityOptionHandler::Language => { + config_service + .set_config("app.language", value.clone()) + .await + .map_err(|error| error.to_string())?; + "app.language".to_string() + } + ProductCapabilityOptionHandler::FlowChatPermissionModeControl => { + config_service + .set_config("app.flow_chat.show_permission_mode_control", value.clone()) + .await + .map_err(|error| error.to_string())?; + "app.flow_chat.show_permission_mode_control".to_string() + } + ProductCapabilityOptionHandler::Provider { .. } => return Ok(None), + }; + let effective_value = read_config_backed_option(config_service, option) + .await? + .ok_or_else(|| "Shared config option unexpectedly became provider-backed".to_string())?; + Ok(Some(AppliedProductConfigOption { + changed_path, + effective_value, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::PathManager; + use crate::service::config::types::GlobalConfig; + use crate::service::config::{ConfigManagerSettings, ConfigService}; + use bitfun_product_domains::product_control::{ + capability as product_capability, catalog, ProductControlValueSchema, + ProductControlValueType, + }; + use std::sync::Arc; + + async fn test_service(name: &str) -> (ConfigService, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(dir.path().join(name))); + let service = ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(path_manager), + auto_save: true, + backup_count: 0, + }) + .await + .expect("config service"); + (service, dir) + } + + fn writable_samples(schema: &ProductControlValueSchema, current: Option<&Value>) -> Vec { + let mut samples = Vec::new(); + if let Some(values) = &schema.r#enum { + samples.extend(values.iter().cloned()); + } + match schema.value_type { + ProductControlValueType::Boolean => { + samples.push(Value::Bool(true)); + samples.push(Value::Bool(false)); + } + ProductControlValueType::String => samples.push(Value::String( + "x".repeat(schema.min_length.unwrap_or(1).max(1)), + )), + ProductControlValueType::Integer => { + samples.push(Value::from(schema.minimum.unwrap_or(1.0).ceil() as i64)); + if let Some(maximum) = schema.maximum { + samples.push(Value::from(maximum.floor() as i64)); + } + } + ProductControlValueType::Number => { + samples.push(Value::from(schema.minimum.unwrap_or(1.0))); + if let Some(maximum) = schema.maximum { + samples.push(Value::from(maximum)); + } + } + ProductControlValueType::Object => samples.push(serde_json::json!({})), + ProductControlValueType::Array => samples.push(serde_json::json!([])), + } + if schema.nullable { + samples.push(Value::Null); + } + if let Some(current) = current { + samples.push(current.clone()); + } + samples.retain(|sample| validate_option_value(schema, sample).is_ok()); + samples.dedup(); + samples + } + + fn assert_config_binding(root: &Value, path: &str, schema: &ProductControlValueSchema) { + let current = read_nested_value(root, path); + if let Some(current) = current { + assert!( + validate_option_value(schema, current).is_ok(), + "default value at {path} does not satisfy its product-control schema: {current}" + ); + } + + let samples = writable_samples(schema, current); + for sample in &samples { + let mut candidate = root.clone(); + set_nested_value(&mut candidate, path, sample.clone()).unwrap(); + let Ok(typed) = serde_json::from_value::(candidate) else { + continue; + }; + let serialized = serde_json::to_value(typed).unwrap(); + if read_nested_value(&serialized, path) == Some(sample) { + return; + } + } + panic!( + "product-control config path is not consumed by typed GlobalConfig: {path}; valid samples: {samples:?}" + ); + } + + #[tokio::test] + async fn config_handler_round_trips_through_the_shared_service() { + let (service, _dir) = test_service("product-control-round-trip").await; + let capability = product_capability("setting.tools.execution").unwrap(); + let option = capability + .options + .iter() + .find(|option| option.id == "deferred-tool-loading") + .unwrap(); + + let applied = configure_config_backed_option(&service, option, &Value::Bool(false)) + .await + .unwrap() + .expect("config-backed option"); + assert_eq!(applied.changed_path, "ai.enable_deferred_tool_loading"); + assert_eq!(applied.effective_value, Value::Bool(false)); + assert_eq!( + read_config_backed_option(&service, option).await.unwrap(), + Some(Value::Bool(false)) + ); + } + + #[tokio::test] + async fn every_catalog_config_option_is_readable_from_default_config() { + let (service, _dir) = test_service("product-control-default-readback").await; + for option in catalog() + .unwrap() + .capabilities + .iter() + .flat_map(|capability| &capability.options) + { + if matches!( + option.handler, + ProductCapabilityOptionHandler::Provider { .. } + ) { + continue; + } + let value = read_config_backed_option(&service, option) + .await + .unwrap_or_else(|error| panic!("{} is unreadable: {error}", option.id)); + assert!( + value.is_some(), + "{} unexpectedly requires a host", + option.id + ); + } + } + + #[tokio::test] + async fn every_catalog_config_option_can_be_applied_and_read_back() { + let (service, _dir) = test_service("product-control-all-options-round-trip").await; + for capability in &catalog().unwrap().capabilities { + for option in &capability.options { + if matches!( + option.handler, + ProductCapabilityOptionHandler::Provider { .. } + ) { + continue; + } + let current = read_config_backed_option(&service, option) + .await + .unwrap_or_else(|error| { + panic!( + "{}.{} initial read failed: {error}", + capability.id, option.id + ) + }) + .unwrap_or_else(|| { + panic!( + "{}.{} unexpectedly requires a host", + capability.id, option.id + ) + }); + let candidates = writable_samples(&option.value_schema, Some(¤t)); + let mut failures = Vec::new(); + let mut applied = false; + for candidate in candidates { + match configure_config_backed_option(&service, option, &candidate).await { + Ok(Some(result)) if result.effective_value == candidate => { + applied = true; + break; + } + Ok(Some(result)) => failures.push(format!( + "{candidate} read back as {}", + result.effective_value + )), + Ok(None) => { + failures.push(format!("{candidate} unexpectedly required host")) + } + Err(error) => failures.push(format!("{candidate}: {error}")), + } + } + assert!( + applied, + "{}.{} has no shared config value that round-trips: {}", + capability.id, + option.id, + failures.join("; ") + ); + } + } + } + + #[test] + fn every_catalog_config_option_binds_to_typed_global_config() { + let root = serde_json::to_value(GlobalConfig::default()).unwrap(); + for option in catalog() + .unwrap() + .capabilities + .iter() + .flat_map(|capability| &capability.options) + { + match &option.handler { + ProductCapabilityOptionHandler::Config { path } => { + assert_config_binding(&root, path, &option.value_schema); + } + ProductCapabilityOptionHandler::MergeConfig { path, fields } => { + for field in fields { + assert_config_binding( + &root, + &format!("{path}.{field}"), + &option.value_schema, + ); + } + let current = read_nested_value(&root, path) + .unwrap_or_else(|| panic!("product-control merge path is absent: {path}")); + validate_config_semantics(path, current).unwrap(); + } + ProductCapabilityOptionHandler::AppearanceSelection => { + assert_config_binding(&root, "appearance.selection", &option.value_schema); + } + ProductCapabilityOptionHandler::Language => { + assert_config_binding(&root, "app.language", &option.value_schema); + } + ProductCapabilityOptionHandler::FlowChatPermissionModeControl => { + let current = Value::Bool( + GlobalConfig::default() + .app + .flow_chat + .show_permission_mode_control, + ); + assert!(validate_option_value(&option.value_schema, ¤t).is_ok()); + } + ProductCapabilityOptionHandler::Provider { .. } => {} + } + } + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/bitfun_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/bitfun_control_tool.rs index c3472e67e2..8489701eb8 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/bitfun_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/bitfun_control_tool.rs @@ -1,6 +1,9 @@ //! BitFunControl — discover and control user-facing BitFun features and settings. use crate::agentic::agents::get_agent_registry; +use crate::agentic::tools::bitfun_control_config::{ + configure_config_backed_option, read_config_backed_option, +}; use crate::agentic::tools::bitfun_control_host::{ bitfun_control_host_available, invoke_bitfun_control, BitFunControlHostRequest, ProductControlAction, @@ -8,6 +11,7 @@ use crate::agentic::tools::bitfun_control_host::{ use crate::agentic::tools::framework::{ PermissionIntent, Tool, ToolResult, ToolUseContext, ValidationResult, }; +use crate::service::config::{get_global_config_service, ConfigService}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use bitfun_product_domains::product_control::{ @@ -16,21 +20,246 @@ use bitfun_product_domains::product_control::{ validate_operation_argument_scopes, validate_operation_arguments, validate_option_value, ProductControlRisk, }; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; +use std::sync::Arc; const ACTIONS: &[&str] = &["list", "search", "get", "open", "execute", "configure"]; +const INPUT_ID_ALIASES: &[(&str, &str)] = &[ + ("capability_id", "capabilityId"), + ("item_id", "itemId"), + ("operation_id", "operationId"), + ("option_id", "optionId"), +]; -pub struct BitFunControlTool; +pub struct BitFunControlTool { + config_service: Option>, +} impl BitFunControlTool { pub fn new() -> Self { - Self + Self { + config_service: None, + } + } + + #[cfg(test)] + fn with_config_service(config_service: Arc) -> Self { + Self { + config_service: Some(config_service), + } + } + + async fn config_service(&self) -> Result, String> { + if let Some(config_service) = &self.config_service { + return Ok(config_service.clone()); + } + get_global_config_service() + .await + .map_err(|error| error.to_string()) + } + + fn assistant_payload(result: &Value) -> String { + // Keep on-demand control responses structured without paying the + // context cost of pretty-print whitespace on every discovery step. + serde_json::to_string(result).unwrap_or_else(|_| result.to_string()) + } + + async fn inspect_shared_capability(&self, capability_id: &str) -> BitFunResult { + let capability = product_capability(capability_id).map_err(BitFunError::tool)?; + let mut result = + inspect_product_control_contract(capability_id).map_err(BitFunError::tool)?; + let object = result.as_object_mut().ok_or_else(|| { + BitFunError::tool("Product-control inspection was not an object".to_string()) + })?; + + let config_service = match self.config_service().await { + Ok(config_service) => config_service, + Err(error) => { + object.insert( + "controlAvailability".to_string(), + json!({ + "status": "unavailable", + "contractAvailable": true, + "readBack": false, + "reason": format!("Shared BitFun configuration is unavailable: {error}"), + }), + ); + return Ok(result); + } + }; + + let mut current_values = Map::new(); + let mut shared_option_ids = Vec::new(); + let mut host_option_ids = Vec::new(); + for option in &capability.options { + match read_config_backed_option(&config_service, option).await { + Ok(Some(value)) => { + shared_option_ids.push(option.id.clone()); + current_values.insert(option.id.clone(), value); + } + Ok(None) => { + host_option_ids.push(option.id.clone()); + current_values.insert( + option.id.clone(), + json!({ + "availability": "unavailable", + "reason": "This option requires a product-host provider on the active surface", + }), + ); + } + Err(error) => { + shared_option_ids.push(option.id.clone()); + current_values.insert( + option.id.clone(), + json!({ "availability": "degraded", "reason": error }), + ); + } + } + } + let operation_ids: Vec<&str> = capability + .operations + .iter() + .map(|operation| operation.id.as_str()) + .collect(); + let operation_availability: Map = operation_ids + .iter() + .map(|operation_id| { + ( + (*operation_id).to_string(), + json!({ + "status": "unavailable", + "reason": "This operation requires a product-host adapter on the active surface", + }), + ) + }) + .collect(); + + object.insert( + "currentOptionValues".to_string(), + Value::Object(current_values), + ); + object.insert( + "operationAvailability".to_string(), + Value::Object(operation_availability), + ); + object.insert( + "controlAvailability".to_string(), + json!({ + "status": if shared_option_ids.is_empty() { "unavailable" } else { "available" }, + "adapter": "shared-config", + "contractAvailable": true, + "readBack": !shared_option_ids.is_empty(), + "actions": { + "get": { "status": "available" }, + "configure": { + "status": if shared_option_ids.is_empty() { "unavailable" } else { "available" }, + "optionIds": shared_option_ids, + "requiresHostOptionIds": host_option_ids, + }, + "open": { + "status": "unavailable", + "reason": "This product surface has no presentation adapter", + }, + "execute": { + "status": "unavailable", + "operationIds": operation_ids, + "reason": "This product surface has no product-operation adapter", + }, + }, + }), + ); + Ok(result) + } + + async fn configure_shared_option( + &self, + request: &BitFunControlHostRequest, + ) -> BitFunResult { + let capability_id = request + .capability_id + .as_deref() + .ok_or_else(|| BitFunError::tool("capability_id is required".to_string()))?; + let option_id = request + .option_id + .as_deref() + .ok_or_else(|| BitFunError::tool("option_id is required".to_string()))?; + let value = request + .value + .as_ref() + .ok_or_else(|| BitFunError::tool("value is required".to_string()))?; + let capability = product_capability(capability_id).map_err(BitFunError::tool)?; + let option = capability + .options + .iter() + .find(|option| option.id == option_id) + .ok_or_else(|| { + BitFunError::tool(format!("Unknown option for {capability_id}: {option_id}")) + })?; + let config_service = self.config_service().await.map_err(BitFunError::tool)?; + let applied = configure_config_backed_option(&config_service, option, value) + .await + .map_err(BitFunError::tool)? + .ok_or_else(|| { + BitFunError::tool(format!( + "Option {capability_id}:{option_id} requires a product-host provider on the active surface" + )) + })?; + Ok(json!({ + "capabilityId": capability_id, + "optionId": option_id, + "configured": true, + "effectiveValue": applied.effective_value, + "changedPath": applied.changed_path, + "adapter": "shared-config", + "readBack": true, + })) } fn action(input: &Value) -> Option<&str> { input.get("action").and_then(Value::as_str).map(str::trim) } + fn aliased_field<'a>(input: &'a Value, canonical: &str, alias: &str) -> Option<&'a Value> { + input.get(canonical).or_else(|| input.get(alias)) + } + + fn aliased_string<'a>(input: &'a Value, canonical: &str, alias: &str) -> Option<&'a str> { + Self::aliased_field(input, canonical, alias) + .and_then(Value::as_str) + .map(str::trim) + } + + fn validate_input_aliases(input: &Value) -> Result<(), String> { + for (canonical, alias) in INPUT_ID_ALIASES { + if let (Some(canonical_value), Some(alias_value)) = + (input.get(*canonical), input.get(*alias)) + { + if canonical_value != alias_value { + return Err(format!( + "{canonical} and its compatibility alias {alias} must not disagree" + )); + } + } + } + Ok(()) + } + + fn capability_id(input: &Value) -> Option<&str> { + Self::aliased_string(input, "capability_id", "capabilityId") + } + + fn item_id(input: &Value) -> Option<&str> { + Self::aliased_string(input, "item_id", "itemId") + } + + fn operation_id(input: &Value) -> Option<&str> { + Self::aliased_string(input, "operation_id", "operationId") + } + + fn option_id(input: &Value) -> Option<&str> { + Self::aliased_string(input, "option_id", "optionId") + } + fn requires_capability_id(action: &str) -> bool { matches!(action, "get" | "open" | "execute" | "configure") } @@ -47,6 +276,59 @@ impl BitFunControlTool { } } + fn configure_value(input: &Value) -> Result, String> { + let mut values = Vec::new(); + let typed_fields = [ + ("value_boolean", "boolean"), + ("value_string", "string"), + ("value_integer", "integer"), + ("value_number", "number"), + ("value_object", "object"), + ("value_array", "array"), + ]; + for (field, expected_type) in typed_fields { + let Some(value) = input.get(field) else { + continue; + }; + let valid = match expected_type { + "boolean" => value.is_boolean(), + "string" => value.is_string(), + "integer" => value.as_i64().is_some() || value.as_u64().is_some(), + "number" => value.is_number(), + "object" => value.is_object(), + "array" => value.is_array(), + _ => false, + }; + if !valid { + return Err(format!("{field} must be a JSON {expected_type}")); + } + values.push((field, value.clone())); + } + if let Some(value) = input.get("value_null") { + if value != &Value::Bool(true) { + return Err("value_null must be true when used".to_string()); + } + values.push(("value_null", Value::Null)); + } + // Preserve compatibility with calls produced before typed value fields + // were added. It is intentionally absent from the prompt schema so new + // model calls cannot have an untyped value coerced by a provider. + if let Some(value) = input.get("value") { + values.push(("value", value.clone())); + } + if values.len() > 1 { + return Err(format!( + "configure accepts exactly one typed value field; received {}", + values + .iter() + .map(|(field, _)| *field) + .collect::>() + .join(", ") + )); + } + Ok(values.into_iter().next().map(|(_, value)| value)) + } + fn agent_is_readonly(context: &ToolUseContext) -> bool { let Some(agent_type) = context.agent_type.as_deref() else { return false; @@ -57,10 +339,9 @@ impl BitFunControlTool { } fn operation_is_readonly(input: &Value) -> bool { - let (Some(capability_id), Some(operation_id)) = ( - input.get("capability_id").and_then(Value::as_str), - input.get("operation_id").and_then(Value::as_str), - ) else { + let (Some(capability_id), Some(operation_id)) = + (Self::capability_id(input), Self::operation_id(input)) + else { return false; }; product_capability(capability_id) @@ -78,14 +359,8 @@ impl BitFunControlTool { if Self::action(input) != Some("execute") { return Ok(()); } - let capability_id = input - .get("capability_id") - .and_then(Value::as_str) - .unwrap_or_default(); - let operation_id = input - .get("operation_id") - .and_then(Value::as_str) - .unwrap_or_default(); + let capability_id = Self::capability_id(input).unwrap_or_default(); + let operation_id = Self::operation_id(input).unwrap_or_default(); let capability = product_capability(capability_id)?; let operation = capability .operations @@ -110,7 +385,7 @@ impl Tool for BitFunControlTool { async fn description(&self) -> BitFunResult { Ok( - "Control BitFun features and settings through its internal API. Use a two-step flow: (1) call `list` or `search`, then `get` the relevant capability; (2) follow the returned item `control.kind`: `direct` uses `execute`/`configure`, `delegate` calls the named owning tool, and `open` opens the exact BitFun UI. The catalog is loaded only on demand and is not embedded here." + "Control BitFun features and settings through its internal API. Use a two-step flow: (1) call `list` or `search`, then copy `nextToolCall` to `get` the capability; (2) follow `control.kind`: `direct` uses `execute`/`configure`, `delegate` calls the named tool, and `open` opens the UI. For configure, map valueSchema.type to exactly one value_boolean/value_string/value_integer/value_number/value_object/value_array field, or value_null=true. The catalog loads only on demand." .to_string(), ) } @@ -136,11 +411,11 @@ impl Tool for BitFunControlTool { }, "capability_id": { "type": "string", - "description": "Stable capability ID returned by list/search/get." + "description": "Canonical tool field: copy the returned capabilityId or nextToolCall.capability_id value here." }, "item_id": { "type": "string", - "description": "Optional documented item ID returned by search/get; open uses it to navigate to an exact subview." + "description": "Optional canonical tool field: copy a returned itemId here; open uses it to navigate to an exact subview." }, "operation_id": { "type": "string", @@ -152,10 +427,36 @@ impl Tool for BitFunControlTool { }, "option_id": { "type": "string", - "description": "User-level setting option ID returned by get; required for configure." + "description": "Canonical tool field: copy a user-level options[].id returned by get; required for configure." }, - "value": { - "description": "New option value for configure, following the value schema returned by get." + "value_boolean": { + "type": "boolean", + "description": "Configure value when get returns valueSchema.type=boolean." + }, + "value_string": { + "type": "string", + "description": "Configure value when get returns valueSchema.type=string." + }, + "value_integer": { + "type": "integer", + "description": "Configure value when get returns valueSchema.type=integer." + }, + "value_number": { + "type": "number", + "description": "Configure value when get returns valueSchema.type=number." + }, + "value_object": { + "type": "object", + "description": "Configure value when get returns valueSchema.type=object." + }, + "value_array": { + "type": "array", + "description": "Configure value when get returns valueSchema.type=array." + }, + "value_null": { + "type": "boolean", + "enum": [true], + "description": "Set a nullable option to null; pass true." }, "cursor": { "type": "integer", @@ -198,31 +499,19 @@ impl Tool for BitFunControlTool { if action == "execute" && Self::operation_is_readonly(input) { return Ok(Vec::new()); } - let capability_id = input - .get("capability_id") - .and_then(Value::as_str) - .map(str::trim) + let capability_id = Self::capability_id(input) .filter(|value| !value.is_empty()) .unwrap_or(""); let target = match action { - "execute" => input - .get("operation_id") - .and_then(Value::as_str) - .map(str::trim) + "execute" => Self::operation_id(input) .filter(|value| !value.is_empty()) .map(|value| format!("{capability_id}:{value}")) .unwrap_or_else(|| capability_id.to_string()), - "configure" => input - .get("option_id") - .and_then(Value::as_str) - .map(str::trim) + "configure" => Self::option_id(input) .filter(|value| !value.is_empty()) .map(|value| format!("{capability_id}:{value}")) .unwrap_or_else(|| capability_id.to_string()), - "open" => input - .get("item_id") - .and_then(Value::as_str) - .map(str::trim) + "open" => Self::item_id(input) .filter(|value| !value.is_empty()) .map(|value| format!("{capability_id}:{value}")) .unwrap_or_else(|| capability_id.to_string()), @@ -248,6 +537,9 @@ impl Tool for BitFunControlTool { if !input.is_object() { return invalid("Input must be an object."); } + if let Err(error) = Self::validate_input_aliases(input) { + return invalid(&error); + } let Some(action) = Self::action(input) else { return invalid("action is required."); }; @@ -265,26 +557,20 @@ impl Tool for BitFunControlTool { return invalid("query is required for search."); } if Self::requires_capability_id(action) - && !input - .get("capability_id") - .and_then(Value::as_str) - .is_some_and(|id| !id.trim().is_empty()) + && !Self::capability_id(input).is_some_and(|id| !id.is_empty()) { - return invalid("capability_id is required for get, open, execute, and configure."); + return invalid( + "capability_id is required for get, open, execute, and configure (capabilityId is accepted as a compatibility alias).", + ); } - if input.get("item_id").is_some_and(|value| { + if Self::aliased_field(input, "item_id", "itemId").is_some_and(|value| { !value .as_str() .is_some_and(|item_id| !item_id.trim().is_empty()) }) { return invalid("item_id must be a non-empty string when provided."); } - if action == "execute" - && !input - .get("operation_id") - .and_then(Value::as_str) - .is_some_and(|id| !id.trim().is_empty()) - { + if action == "execute" && !Self::operation_id(input).is_some_and(|id| !id.is_empty()) { return invalid("operation_id is required for execute."); } if input @@ -293,27 +579,31 @@ impl Tool for BitFunControlTool { { return invalid("arguments must be an object when provided."); } - if action == "configure" - && !input - .get("option_id") - .and_then(Value::as_str) - .is_some_and(|id| !id.trim().is_empty()) - { + if action == "configure" && !Self::option_id(input).is_some_and(|id| !id.is_empty()) { return invalid("option_id is required for configure."); } - if action == "configure" && input.get("value").is_none() { - return invalid("value is required for configure."); + let configure_value = if action == "configure" { + match Self::configure_value(input) { + Ok(Some(value)) => Some(value), + Ok(None) => { + return invalid("Exactly one typed value field is required for configure.") + } + Err(error) => return invalid(&error), + } + } else { + None + }; + if action != "configure" { + match Self::configure_value(input) { + Ok(None) => {} + Ok(Some(_)) => return invalid("Typed value fields are only valid for configure."), + Err(error) => return invalid(&error), + } } if matches!(action, "get" | "open") { - let capability_id = input - .get("capability_id") - .and_then(Value::as_str) - .unwrap_or_default(); + let capability_id = Self::capability_id(input).unwrap_or_default(); if action == "open" { - if let Err(error) = validate_open_target( - capability_id, - input.get("item_id").and_then(Value::as_str), - ) { + if let Err(error) = validate_open_target(capability_id, Self::item_id(input)) { return invalid(&error); } } else if product_capability(capability_id).is_err() { @@ -321,14 +611,8 @@ impl Tool for BitFunControlTool { } } if action == "execute" { - let capability_id = input - .get("capability_id") - .and_then(Value::as_str) - .unwrap_or_default(); - let operation_id = input - .get("operation_id") - .and_then(Value::as_str) - .unwrap_or_default(); + let capability_id = Self::capability_id(input).unwrap_or_default(); + let operation_id = Self::operation_id(input).unwrap_or_default(); let Ok(capability) = product_capability(capability_id) else { return invalid("capability_id does not identify a known BitFun capability."); }; @@ -346,14 +630,8 @@ impl Tool for BitFunControlTool { } } if action == "configure" { - let capability_id = input - .get("capability_id") - .and_then(Value::as_str) - .unwrap_or_default(); - let option_id = input - .get("option_id") - .and_then(Value::as_str) - .unwrap_or_default(); + let capability_id = Self::capability_id(input).unwrap_or_default(); + let option_id = Self::option_id(input).unwrap_or_default(); let Ok(capability) = product_capability(capability_id) else { return invalid("capability_id does not identify a known BitFun capability."); }; @@ -364,7 +642,7 @@ impl Tool for BitFunControlTool { else { return invalid("option_id is not exposed by this BitFun setting."); }; - if let Some(value) = input.get("value") { + if let Some(value) = configure_value.as_ref() { if let Err(error) = validate_option_value(&option.value_schema, value) { return invalid(&error); } @@ -396,6 +674,7 @@ impl Tool for BitFunControlTool { input: &Value, context: &ToolUseContext, ) -> BitFunResult> { + Self::validate_input_aliases(input).map_err(BitFunError::tool)?; let action = Self::action(input) .ok_or_else(|| BitFunError::tool("action is required".to_string()))?; let typed_action = Self::typed_action(action).ok_or_else(|| { @@ -419,32 +698,24 @@ impl Tool for BitFunControlTool { .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), - capability_id: input - .get("capability_id") - .and_then(Value::as_str) - .map(str::trim) + capability_id: Self::capability_id(input) .filter(|value| !value.is_empty()) .map(str::to_string), - item_id: input - .get("item_id") - .and_then(Value::as_str) - .map(str::trim) + item_id: Self::item_id(input) .filter(|value| !value.is_empty()) .map(str::to_string), - operation_id: input - .get("operation_id") - .and_then(Value::as_str) - .map(str::trim) + operation_id: Self::operation_id(input) .filter(|value| !value.is_empty()) .map(str::to_string), - option_id: input - .get("option_id") - .and_then(Value::as_str) - .map(str::trim) + option_id: Self::option_id(input) .filter(|value| !value.is_empty()) .map(str::to_string), arguments: input.get("arguments").cloned(), - value: input.get("value").cloned(), + value: if typed_action == ProductControlAction::Configure { + Self::configure_value(input).map_err(BitFunError::tool)? + } else { + None + }, cursor: input .get("cursor") .and_then(Value::as_u64) @@ -464,19 +735,16 @@ impl Tool for BitFunControlTool { .capability_id .as_deref() .ok_or_else(|| BitFunError::tool("capability_id is required".to_string()))?; - if bitfun_control_host_available() { + let mut result = if bitfun_control_host_available() { match invoke_bitfun_control(request.clone()).await { Ok(result) => result, Err(error) => { - let mut result = inspect_product_control_contract(capability_id) - .map_err(BitFunError::tool)?; + let mut result = self.inspect_shared_capability(capability_id).await?; if let Some(object) = result.as_object_mut() { object.insert( - "controlAvailability".to_string(), + "hostAdapterAvailability".to_string(), json!({ "status": "degraded", - "contractAvailable": true, - "readBack": false, "reason": error, }), ); @@ -485,25 +753,26 @@ impl Tool for BitFunControlTool { } } } else { - let mut result = inspect_product_control_contract(capability_id) - .map_err(BitFunError::tool)?; - if let Some(object) = result.as_object_mut() { - object.insert( - "controlAvailability".to_string(), - json!({ - "status": "unavailable", - "contractAvailable": true, - "readBack": false, - "reason": "This product surface has no BitFun control adapter", - }), - ); - } - result + self.inspect_shared_capability(capability_id).await? + }; + if let Some(object) = result.as_object_mut() { + object.insert( + "toolInput".to_string(), + json!({ "capability_id": capability_id }), + ); + } + result + } + ProductControlAction::Configure => { + if bitfun_control_host_available() { + invoke_bitfun_control(request) + .await + .map_err(BitFunError::tool)? + } else { + self.configure_shared_option(&request).await? } } - ProductControlAction::Open - | ProductControlAction::Execute - | ProductControlAction::Configure => { + ProductControlAction::Open | ProductControlAction::Execute => { if !bitfun_control_host_available() { return Err(BitFunError::tool( "This BitFun product surface can discover the capability but does not provide its control adapter" @@ -516,21 +785,10 @@ impl Tool for BitFunControlTool { } }; - let assistant = match action { - "list" | "search" => { - let count = result - .get("items") - .and_then(Value::as_array) - .map(Vec::len) - .unwrap_or_default(); - format!("BitFun feature and setting discovery returned {count} item(s). If nextCursor is present, continue the same discovery action with that cursor. Call get for the relevant capability, then follow the returned item control route.") - } - "get" => "Loaded the BitFun feature or setting manual. Follow each item's control.kind: direct uses BitFunControl, delegate names the owning tool, and open routes to the exact UI.".to_string(), - "open" => "Opened the BitFun feature or setting in the active product surface.".to_string(), - "execute" => "Executed the selected user-level BitFun operation.".to_string(), - "configure" => "Updated the selected BitFun setting option.".to_string(), - _ => "BitFunControl completed.".to_string(), - }; + // The model needs the IDs, schemas, availability, and effective values + // to perform the second step. A prose success summary would hide the + // structured payload because result_for_assistant is authoritative. + let assistant = Self::assistant_payload(&result); Ok(vec![ToolResult::ok(result, Some(assistant))]) } } @@ -538,6 +796,8 @@ impl Tool for BitFunControlTool { #[cfg(test)] mod tests { use super::*; + use crate::infrastructure::PathManager; + use crate::service::config::ConfigManagerSettings; use std::collections::HashMap; fn context() -> ToolUseContext { @@ -572,14 +832,35 @@ mod tests { context } + async fn tool_with_temp_config(name: &str) -> (BitFunControlTool, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(dir.path().join(name))); + let config_service = Arc::new( + ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(path_manager), + auto_save: true, + backup_count: 0, + }) + .await + .expect("config service"), + ); + (BitFunControlTool::with_config_service(config_service), dir) + } + #[tokio::test] async fn description_keeps_the_catalog_out_of_the_prompt() { let description = BitFunControlTool::new().description().await.unwrap(); assert!(description.contains("two-step")); assert!(description.contains("list")); assert!(description.contains("search")); + assert!(description.contains("value_boolean")); assert!(!description.contains("get_configs")); assert!(description.len() < 600); + + let schema = BitFunControlTool::new().input_schema(); + assert_eq!(schema["properties"]["value_boolean"]["type"], "boolean"); + assert_eq!(schema["properties"]["value_integer"]["type"], "integer"); + assert!(schema["properties"].get("value").is_none()); } #[tokio::test] @@ -614,6 +895,32 @@ mod tests { .await .result ); + assert!( + tool.validate_input( + &json!({ + "action": "configure", + "capabilityId": "setting.tools.execution", + "optionId": "deferred-tool-loading", + "value_boolean": false + }), + None, + ) + .await + .result + ); + assert!( + !tool + .validate_input( + &json!({ + "action": "get", + "capability_id": "setting.tools.execution", + "capabilityId": "feature.ai-assistant" + }), + None, + ) + .await + .result + ); assert!( tool.validate_input( &json!({ @@ -670,6 +977,123 @@ mod tests { ); } + #[tokio::test] + async fn discovery_payload_exposes_ids_to_the_model() { + let tool = BitFunControlTool::new(); + let results = tool + .call_impl( + &json!({ + "action": "search", + "query": "延迟加载工具 deferred tool loading", + "limit": 20 + }), + &context(), + ) + .await + .unwrap(); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &results[0] + else { + panic!("expected a structured product-control result"); + }; + assert!(data["items"].as_array().is_some_and(|items| items + .iter() + .any(|item| item["id"] == "setting.tools.execution"))); + let execution = data["items"] + .as_array() + .and_then(|items| { + items + .iter() + .find(|item| item["capabilityId"] == "setting.tools.execution") + }) + .expect("execution capability result"); + assert_eq!(execution["nextAction"]["action"], "get"); + assert_eq!( + execution["nextAction"]["capabilityId"], + "setting.tools.execution" + ); + assert_eq!(execution["nextToolCall"]["action"], "get"); + assert_eq!( + execution["nextToolCall"]["capability_id"], + "setting.tools.execution" + ); + let matched_item = execution["matchedItems"] + .as_array() + .and_then(|items| items.iter().find(|item| item["itemId"] == "deferred-tools")) + .expect("deferred-tools match"); + assert_eq!(matched_item["capabilityId"], "setting.tools.execution"); + let assistant = result_for_assistant.as_deref().unwrap(); + assert!(assistant.contains("capabilityId")); + assert!(assistant.contains("capability_id")); + assert!(assistant.contains("itemId")); + assert!(assistant.contains("setting.tools.execution")); + assert!(assistant.contains("deferred-tools")); + assert!(!assistant.contains("returned 1 item(s)")); + } + + #[tokio::test] + async fn headless_tool_configures_and_reads_back_shared_product_config() { + assert!(!bitfun_control_host_available()); + let (tool, _dir) = tool_with_temp_config("bitfun-control-tool-round-trip").await; + + let configured = tool + .call_impl( + &json!({ + "action": "configure", + "capabilityId": "setting.tools.execution", + "optionId": "deferred-tool-loading", + "value_boolean": false + }), + &context(), + ) + .await + .unwrap(); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &configured[0] + else { + panic!("expected a structured product-control result"); + }; + assert_eq!(data["effectiveValue"], false); + assert_eq!(data["adapter"], "shared-config"); + assert!(result_for_assistant + .as_deref() + .is_some_and(|assistant| assistant.contains("effectiveValue"))); + + let inspected = tool + .call_impl( + &json!({ + "action": "get", + "capability_id": "setting.tools.execution" + }), + &context(), + ) + .await + .unwrap(); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &inspected[0] + else { + panic!("expected a structured product-control result"); + }; + assert_eq!(data["currentOptionValues"]["deferred-tool-loading"], false); + assert_eq!(data["controlAvailability"]["adapter"], "shared-config"); + assert_eq!( + data["toolInput"]["capability_id"], + "setting.tools.execution" + ); + let assistant = result_for_assistant.as_deref().unwrap(); + assert!(assistant.contains("deferred-tool-loading")); + assert!(assistant.contains("currentOptionValues")); + } + #[test] fn discovery_is_permission_free_but_execution_is_scoped() { let tool = BitFunControlTool::new(); diff --git a/src/crates/assembly/core/src/agentic/tools/mod.rs b/src/crates/assembly/core/src/agentic/tools/mod.rs index 23575cf91f..ab56b0e9b1 100644 --- a/src/crates/assembly/core/src/agentic/tools/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/mod.rs @@ -1,6 +1,7 @@ //! Tool system - includes Tool interface, tool registry and tool executor pub mod account_login_capability; +pub mod bitfun_control_config; pub mod bitfun_control_host; #[cfg(feature = "browser-control")] pub mod browser_control; diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 14165afb31..6349d7a34a 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "BitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c743edc2a5b5103230aba095264d371c81999653fad41f89f0e46878d5f9c91a", + "digest": "3c78642ff2ca49ce070288d4e9dc22c9615f731f46a56d8135fea25cf5c474bf", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -83,6 +83,30 @@ "capabilityId": "feature.pages", "itemId": "publish" } + }, + { + "id": "deferred-tools-chinese", + "query": "延迟加载工具", + "expectedFirstCapabilityId": "setting.tools.execution", + "expectedCapabilityIds": [ + "setting.tools.execution" + ], + "expectedItem": { + "capabilityId": "setting.tools.execution", + "itemId": "deferred-tools" + } + }, + { + "id": "deferred-tools-english", + "query": "deferred tool loading", + "expectedFirstCapabilityId": "setting.tools.execution", + "expectedCapabilityIds": [ + "setting.tools.execution" + ], + "expectedItem": { + "capabilityId": "setting.tools.execution", + "itemId": "deferred-tools" + } } ], "counts": { @@ -8446,6 +8470,7 @@ "keywordsZh": [ "执行控制", "工具权限", + "延迟加载工具", "审批", "并行", "超时", @@ -8456,6 +8481,7 @@ "keywordsEn": [ "execution control", "tool permissions", + "deferred tool loading", "approval", "parallel", "timeout", @@ -8520,8 +8546,8 @@ }, { "id": "deferred-tools", - "titleZh": "按需延迟加载大型工具定义以减少 Agent 初始上下文", - "titleEn": "Load large tool definitions on demand to reduce initial agent context", + "titleZh": "按需延迟加载工具定义以减少 Agent 初始上下文", + "titleEn": "Deferred tool loading on demand to reduce initial agent context", "control": { "kind": "direct", "operations": [], @@ -8675,8 +8701,7 @@ "type": "boolean" }, "handler": { - "kind": "config", - "path": "app.flow_chat.show_permission_mode_control" + "kind": "flowChatPermissionModeControl" } }, { @@ -8812,6 +8837,7 @@ "AI & collaboration", "执行控制", "工具权限", + "延迟加载工具", "审批", "并行", "超时", @@ -8820,6 +8846,7 @@ "JSON 修复", "execution control", "tool permissions", + "deferred tool loading", "approval", "parallel", "timeout", @@ -8840,8 +8867,8 @@ "Create, reorder, and save global or project-level Allow, Ask, and Deny rules", "查看、撤销或清空项目中记住的权限授权与审计记录", "Review, revoke, or clear remembered project permission grants and audit records", - "按需延迟加载大型工具定义以减少 Agent 初始上下文", - "Load large tool definitions on demand to reduce initial agent context", + "按需延迟加载工具定义以减少 Agent 初始上下文", + "Deferred tool loading on demand to reduce initial agent context", "设置子 Agent 与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具执行超时", diff --git a/src/crates/contracts/product-domains/src/product_control.rs b/src/crates/contracts/product-domains/src/product_control.rs index a738592fbb..a6930891e7 100644 --- a/src/crates/contracts/product-domains/src/product_control.rs +++ b/src/crates/contracts/product-domains/src/product_control.rs @@ -367,6 +367,7 @@ pub enum ProductCapabilityOptionHandler { }, AppearanceSelection, Language, + FlowChatPermissionModeControl, Provider { provider_id: String, option_id: String, @@ -539,10 +540,23 @@ fn compact_capability(capability: &ProductCapability, query: &str) -> Value { .collect() }; matched_items.sort_by(|left, right| right.1.cmp(&left.1)); - let matched_items: Vec<&ProductCapabilityItem> = matched_items + let matched_items: Vec = matched_items .into_iter() .take(5) - .map(|(item, _)| item) + .map(|(item, _)| { + json!({ + // Keep `id` for compatibility with existing discovery consumers, + // while exposing the unambiguous control-route field names used + // by ProductControlRequest. + "id": item.id, + "capabilityId": capability.id, + "itemId": item.id, + "titleZh": item.title_zh, + "titleEn": item.title_en, + "destination": item.destination, + "control": item.control, + }) + }) .collect(); let item_control_count = |kind: &str| { capability @@ -552,7 +566,10 @@ fn compact_capability(capability: &ProductCapability, query: &str) -> Value { .count() }; json!({ + // `id` remains a compatibility alias. New Agent and API consumers use + // `capabilityId`, which cannot be confused with a matched item's id. "id": capability.id, + "capabilityId": capability.id, "kind": capability.kind, "titleZh": capability.title_zh, "titleEn": capability.title_en, @@ -568,6 +585,14 @@ fn compact_capability(capability: &ProductCapability, query: &str) -> Value { "interactive": item_control_count("open"), }, "matchedItems": matched_items, + "nextAction": { + "action": "get", + "capabilityId": capability.id, + }, + "nextToolCall": { + "action": "get", + "capability_id": capability.id, + }, }) } @@ -602,18 +627,24 @@ pub fn discover(request: &ProductControlRequest) -> Result { .capabilities .iter() .filter_map(|capability| { - let score = if request.action == ProductControlAction::Search { + let text_score = if request.action == ProductControlAction::Search { let mut fields = vec![ capability.id.as_str(), capability.title_zh.as_str(), capability.title_en.as_str(), ]; fields.extend(capability.search_terms.iter().map(String::as_str)); - score_text_match(query, &fields).saturating_add(control_priority(capability)) + score_text_match(query, &fields) } else { 1 }; - (score > 0).then_some((capability, score)) + // Control coverage is a tie-breaker only after a textual match. + // Adding it first makes unrelated but highly controllable entries + // appear for every search query. + (text_score > 0).then_some(( + capability, + text_score.saturating_add(control_priority(capability)), + )) }) .collect(); matches.sort_by(|left, right| { @@ -979,15 +1010,62 @@ mod tests { capability["matchedItems"] .as_array() .and_then(|items| items.first()) - .and_then(|item| item["id"].as_str()), + .and_then(|item| item["itemId"].as_str()), Some(expected_item.item_id.as_str()), "acceptance={} item route", acceptance.id ); + let matched_item = capability["matchedItems"] + .as_array() + .and_then(|items| items.first()) + .unwrap(); + assert_eq!( + matched_item["capabilityId"].as_str(), + Some(expected_item.capability_id.as_str()), + "acceptance={} matched item capability route", + acceptance.id + ); } } } + #[test] + fn discovery_routes_use_unambiguous_product_control_field_names() { + let result = discover(&request( + ProductControlAction::Search, + Some("deferred tool loading"), + )) + .unwrap(); + let capability = &result["items"][0]; + assert_eq!(capability["capabilityId"], "setting.tools.execution"); + assert_eq!(capability["id"], capability["capabilityId"]); + assert_eq!(capability["nextAction"]["action"], "get"); + assert_eq!( + capability["nextAction"]["capabilityId"], + capability["capabilityId"] + ); + assert_eq!(capability["nextToolCall"]["action"], "get"); + assert_eq!( + capability["nextToolCall"]["capability_id"], + capability["capabilityId"] + ); + let matched_item = &capability["matchedItems"][0]; + assert_eq!(matched_item["capabilityId"], capability["capabilityId"]); + assert_eq!(matched_item["itemId"], "deferred-tools"); + assert_eq!(matched_item["id"], matched_item["itemId"]); + } + + #[test] + fn discovery_excludes_unrelated_capabilities_before_control_ranking() { + let result = discover(&request( + ProductControlAction::Search, + Some("火星量子烤面包机"), + )) + .unwrap(); + assert_eq!(result["totalCount"], 0); + assert_eq!(result["items"], json!([])); + } + #[test] fn delegated_routes_are_typed_and_self_contained() { for capability in &catalog().unwrap().capabilities { diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 0fd5dfe823..39e0c37bbb 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -10,7 +10,9 @@ {"id":"appearance-theme","query":"深色模式 dark theme appearance","expectedFirstCapabilityId":"setting.application.appearance","expectedCapabilityIds":["setting.application.appearance"],"expectedItem":{"capabilityId":"setting.application.appearance","itemId":"built-in-appearance"}}, {"id":"git-cherry-pick","query":"Git cherry pick 挑选提交","expectedFirstCapabilityId":"feature.git","expectedCapabilityIds":["feature.git"],"expectedItem":{"capabilityId":"feature.git","itemId":"cherry-pick"}}, {"id":"scheduled-cron","query":"Cron 时间表 schedule types 定时类型","expectedFirstCapabilityId":"feature.tasks-automation","expectedCapabilityIds":["feature.tasks-automation"],"expectedItem":{"capabilityId":"feature.tasks-automation","itemId":"schedule-types"}}, - {"id":"pages-deploy","query":"发布网页 deploy page","expectedFirstCapabilityId":"feature.pages","expectedCapabilityIds":["feature.pages"],"expectedItem":{"capabilityId":"feature.pages","itemId":"publish"}} + {"id":"pages-deploy","query":"发布网页 deploy page","expectedFirstCapabilityId":"feature.pages","expectedCapabilityIds":["feature.pages"],"expectedItem":{"capabilityId":"feature.pages","itemId":"publish"}}, + {"id":"deferred-tools-chinese","query":"延迟加载工具","expectedFirstCapabilityId":"setting.tools.execution","expectedCapabilityIds":["setting.tools.execution"],"expectedItem":{"capabilityId":"setting.tools.execution","itemId":"deferred-tools"}}, + {"id":"deferred-tools-english","query":"deferred tool loading","expectedFirstCapabilityId":"setting.tools.execution","expectedCapabilityIds":["setting.tools.execution"],"expectedItem":{"capabilityId":"setting.tools.execution","itemId":"deferred-tools"}} ], "categories": { "assistant": { @@ -1654,8 +1656,8 @@ "titleEn": "Execution & permissions", "summaryZh": "管理 Agent 工具权限、并行度、超时、延迟加载、Computer Use 与浏览器控制。", "summaryEn": "Manage agent tool permissions, concurrency, timeouts, deferred loading, Computer Use, and browser control.", - "keywordsZh": ["执行控制", "工具权限", "审批", "并行", "超时", "Computer Use", "浏览器控制", "JSON 修复"], - "keywordsEn": ["execution control", "tool permissions", "approval", "parallel", "timeout", "computer use", "browser control", "JSON repair"], + "keywordsZh": ["执行控制", "工具权限", "延迟加载工具", "审批", "并行", "超时", "Computer Use", "浏览器控制", "JSON 修复"], + "keywordsEn": ["execution control", "tool permissions", "deferred tool loading", "approval", "parallel", "timeout", "computer use", "browser control", "JSON repair"], "highlightsZh": ["询问、自动批准与完全访问策略", "子 Agent 并行度和工具超时", "Computer Use、浏览器连接与工具参数修复"], "highlightsEn": ["Ask, auto-approve, and full-access policies", "Subagent concurrency and tool timeouts", "Computer Use, browser connection, and tool-argument repair"], "items": [ @@ -1663,7 +1665,7 @@ { "id": "permission-selector", "titleZh": "控制权限模式选择器是否显示在聊天输入框下方", "titleEn": "Choose whether the permission-mode selector appears below the chat composer", "control": {"kind":"direct","operations":[],"options":[{"id":"show-permission-mode-control"}]}, "evidence": ["source:src/web-ui/src/locales/zh-CN/settings/runtime.json#permissionPolicy.showInChatInput"] }, { "id": "global-project-rules", "titleZh": "创建、排序和保存全局或项目级 Allow、Ask、Deny 规则", "titleEn": "Create, reorder, and save global or project-level Allow, Ask, and Deny rules", "control": {"kind":"open","reasonZh":"该流程依赖当前界面状态、用户选择或额外确认;Agent 可准确打开入口,但契约不把跳转冒充为直接执行。","reasonEn":"This workflow depends on live UI state, user choice, or additional confirmation; the Agent can open the exact entry, but the contract does not pretend navigation is direct execution."}, "evidence": ["command:get_project_permission_rules", "command:save_project_permission_rules", "source:src/web-ui/src/locales/zh-CN/settings/runtime.json#permissionPolicy.globalRules"] }, { "id": "remembered-grants", "titleZh": "查看、撤销或清空项目中记住的权限授权与审计记录", "titleEn": "Review, revoke, or clear remembered project permission grants and audit records", "control": {"kind":"open","reasonZh":"该流程依赖当前界面状态、用户选择或额外确认;Agent 可准确打开入口,但契约不把跳转冒充为直接执行。","reasonEn":"This workflow depends on live UI state, user choice, or additional confirmation; the Agent can open the exact entry, but the contract does not pretend navigation is direct execution."}, "evidence": ["command:list_project_permission_grants", "command:remove_project_permission_grant", "command:clear_project_permission_grants", "command:list_project_permission_audit"] }, - { "id": "deferred-tools", "titleZh": "按需延迟加载大型工具定义以减少 Agent 初始上下文", "titleEn": "Load large tool definitions on demand to reduce initial agent context", "control": {"kind":"direct","operations":[],"options":[{"id":"deferred-tool-loading"}]}, "evidence": ["source:src/web-ui/src/locales/zh-CN/settings/runtime.json#deferredToolLoading.sectionTitle"] }, + { "id": "deferred-tools", "titleZh": "按需延迟加载工具定义以减少 Agent 初始上下文", "titleEn": "Deferred tool loading on demand to reduce initial agent context", "control": {"kind":"direct","operations":[],"options":[{"id":"deferred-tool-loading"}]}, "evidence": ["source:src/web-ui/src/locales/zh-CN/settings/runtime.json#deferredToolLoading.sectionTitle"] }, { "id": "parallelism", "titleZh": "设置子 Agent 与 Swarm 最大并发及安全、强制并行或串行策略", "titleEn": "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "control": {"kind":"direct","operations":[],"options":[{"id":"subagent-batch-policy"},{"id":"subagent-max-concurrency"},{"id":"swarm-max-concurrency"}]}, "evidence": ["source:src/web-ui/src/locales/zh-CN/settings/agentic-tools.json#config.subagentMaxConcurrency", "source:src/web-ui/src/locales/zh-CN/settings/agentic-tools.json#config.subagentBatchPolicy.label"] }, { "id": "timeouts", "titleZh": "设置或取消单次工具执行超时", "titleEn": "Set or disable the per-tool execution timeout", "control": {"kind":"direct","operations":[],"options":[{"id":"tool-timeout-seconds"}]}, "evidence": ["source:src/web-ui/src/locales/zh-CN/settings/agentic-tools.json#config.executionTimeout"] }, { "id": "review-capacity", "titleZh": "设置 Deep Review 最大并行评审者和队列等待时间", "titleEn": "Set the maximum parallel Deep Review workers and queue wait time", "control": {"kind":"open","reasonZh":"该流程依赖当前界面状态、用户选择或额外确认;Agent 可准确打开入口,但契约不把跳转冒充为直接执行。","reasonEn":"This workflow depends on live UI state, user choice, or additional confirmation; the Agent can open the exact entry, but the contract does not pretend navigation is direct execution."}, "evidence": ["source:src/web-ui/src/locales/zh-CN/settings/review-capacity.json#capacity.maxParallelReviewers.label", "source:src/web-ui/src/locales/zh-CN/settings/review-capacity.json#capacity.maxQueueWaitSeconds.label"] }, @@ -1687,7 +1689,7 @@ "descriptionZh": "在聊天输入框下方显示或隐藏权限模式快捷选择器。", "descriptionEn": "Show or hide the permission-mode shortcut below the chat composer.", "valueSchema": { "type": "boolean" }, - "handler": { "kind": "config", "path": "app.flow_chat.show_permission_mode_control" } + "handler": { "kind": "flowChatPermissionModeControl" } }, { "id": "deferred-tool-loading", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 14165afb31..6349d7a34a 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "BitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c743edc2a5b5103230aba095264d371c81999653fad41f89f0e46878d5f9c91a", + "digest": "3c78642ff2ca49ce070288d4e9dc22c9615f731f46a56d8135fea25cf5c474bf", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -83,6 +83,30 @@ "capabilityId": "feature.pages", "itemId": "publish" } + }, + { + "id": "deferred-tools-chinese", + "query": "延迟加载工具", + "expectedFirstCapabilityId": "setting.tools.execution", + "expectedCapabilityIds": [ + "setting.tools.execution" + ], + "expectedItem": { + "capabilityId": "setting.tools.execution", + "itemId": "deferred-tools" + } + }, + { + "id": "deferred-tools-english", + "query": "deferred tool loading", + "expectedFirstCapabilityId": "setting.tools.execution", + "expectedCapabilityIds": [ + "setting.tools.execution" + ], + "expectedItem": { + "capabilityId": "setting.tools.execution", + "itemId": "deferred-tools" + } } ], "counts": { @@ -8446,6 +8470,7 @@ "keywordsZh": [ "执行控制", "工具权限", + "延迟加载工具", "审批", "并行", "超时", @@ -8456,6 +8481,7 @@ "keywordsEn": [ "execution control", "tool permissions", + "deferred tool loading", "approval", "parallel", "timeout", @@ -8520,8 +8546,8 @@ }, { "id": "deferred-tools", - "titleZh": "按需延迟加载大型工具定义以减少 Agent 初始上下文", - "titleEn": "Load large tool definitions on demand to reduce initial agent context", + "titleZh": "按需延迟加载工具定义以减少 Agent 初始上下文", + "titleEn": "Deferred tool loading on demand to reduce initial agent context", "control": { "kind": "direct", "operations": [], @@ -8675,8 +8701,7 @@ "type": "boolean" }, "handler": { - "kind": "config", - "path": "app.flow_chat.show_permission_mode_control" + "kind": "flowChatPermissionModeControl" } }, { @@ -8812,6 +8837,7 @@ "AI & collaboration", "执行控制", "工具权限", + "延迟加载工具", "审批", "并行", "超时", @@ -8820,6 +8846,7 @@ "JSON 修复", "execution control", "tool permissions", + "deferred tool loading", "approval", "parallel", "timeout", @@ -8840,8 +8867,8 @@ "Create, reorder, and save global or project-level Allow, Ask, and Deny rules", "查看、撤销或清空项目中记住的权限授权与审计记录", "Review, revoke, or clear remembered project permission grants and audit records", - "按需延迟加载大型工具定义以减少 Agent 初始上下文", - "Load large tool definitions on demand to reduce initial agent context", + "按需延迟加载工具定义以减少 Agent 初始上下文", + "Deferred tool loading on demand to reduce initial agent context", "设置子 Agent 与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具执行超时",