From 78291a050d2dba9e44b0e8124ba16618b9098244 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Wed, 13 May 2026 09:24:30 +0800 Subject: [PATCH] feat: add on-demand tool spec discovery - add GetToolSpec and manifest resolution for collapsed tool definitions - expose additional tool hints in agent prompts and tool visibility policies - wire tool catalog support into execution, registry, and desktop/MCP adapters --- src/apps/desktop/src/api/tool_api.rs | 1 + src/crates/acp/src/client/tool.rs | 7 + .../agents/definitions/modes/agentic.rs | 2 + .../agents/definitions/modes/deep_research.rs | 12 +- .../agents/definitions/review/review_fixer.rs | 12 +- .../definitions/review/review_specialists.rs | 41 +- .../agents/definitions/shared/readonly.rs | 113 ++++- .../definitions/subagents/computer_use.rs | 12 +- src/crates/core/src/agentic/agents/mod.rs | 18 +- .../prompt_builder/prompt_builder_impl.rs | 53 +++ .../core/src/agentic/agents/registry/query.rs | 54 ++- .../core/src/agentic/agents/registry/types.rs | 8 +- .../src/agentic/execution/execution_engine.rs | 254 ++++++----- .../src/agentic/execution/round_executor.rs | 9 +- .../core/src/agentic/execution/types.rs | 2 + .../src/agentic/tools/agent-tool-exposure.md | 59 +++ .../core/src/agentic/tools/framework.rs | 24 ++ .../implementations/ask_user_question_tool.rs | 4 + .../tools/implementations/bash_tool.rs | 4 + .../tools/implementations/code_review_tool.rs | 5 + .../computer_use_mouse_click_tool.rs | 4 + .../computer_use_mouse_precise_tool.rs | 4 + .../computer_use_mouse_step_tool.rs | 4 + .../implementations/computer_use_tool.rs | 10 +- .../tools/implementations/control_hub_tool.rs | 12 +- .../tools/implementations/create_plan_tool.rs | 4 + .../tools/implementations/cron_tool.rs | 10 +- .../tools/implementations/delete_file_tool.rs | 4 + .../tools/implementations/file_edit_tool.rs | 4 + .../tools/implementations/file_read_tool.rs | 4 + .../tools/implementations/file_write_tool.rs | 5 + .../implementations/generative_ui_tool.rs | 12 +- .../implementations/get_file_diff_tool.rs | 10 +- .../implementations/get_tool_spec_tool.rs | 405 ++++++++++++++++++ .../agentic/tools/implementations/git_tool.rs | 10 +- .../tools/implementations/glob_tool.rs | 4 + .../tools/implementations/grep_tool.rs | 4 + .../agentic/tools/implementations/log_tool.rs | 10 +- .../agentic/tools/implementations/ls_tool.rs | 4 + .../tools/implementations/mcp_tools.rs | 35 +- .../implementations/miniapp_init_tool.rs | 10 +- .../src/agentic/tools/implementations/mod.rs | 2 + .../tools/implementations/playbook_tool.rs | 10 +- .../implementations/session_control_tool.rs | 11 +- .../implementations/session_history_tool.rs | 11 +- .../implementations/session_message_tool.rs | 11 +- .../tools/implementations/skill_tool.rs | 6 + .../tools/implementations/task_tool.rs | 6 + .../implementations/terminal_control_tool.rs | 10 +- .../tools/implementations/todo_write_tool.rs | 4 + .../tools/implementations/web_tools.rs | 21 +- .../src/agentic/tools/manifest_resolver.rs | 278 ++++++++++++ src/crates/core/src/agentic/tools/mod.rs | 2 + .../agentic/tools/pipeline/state_manager.rs | 2 + .../agentic/tools/pipeline/tool_pipeline.rs | 82 ++++ .../core/src/agentic/tools/pipeline/types.rs | 2 + src/crates/core/src/agentic/tools/registry.rs | 110 +++-- .../core/src/service/mcp/adapter/tool.rs | 10 + .../core/src/service/snapshot/manager.rs | 12 +- 59 files changed, 1660 insertions(+), 193 deletions(-) create mode 100644 src/crates/core/src/agentic/tools/agent-tool-exposure.md create mode 100644 src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs create mode 100644 src/crates/core/src/agentic/tools/manifest_resolver.rs diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index b549adad4..d5bc2673c 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -178,6 +178,7 @@ async fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { session_id: None, dialog_turn_id: None, workspace, + unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, diff --git a/src/crates/acp/src/client/tool.rs b/src/crates/acp/src/client/tool.rs index 42037a274..d25257eec 100644 --- a/src/crates/acp/src/client/tool.rs +++ b/src/crates/acp/src/client/tool.rs @@ -53,6 +53,13 @@ impl Tool for AcpAgentTool { )) } + fn short_description(&self) -> String { + format!( + "Delegate a task to the external ACP agent '{}'.", + self.display_name() + ) + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs index c8005dc5b..a43b7959d 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs @@ -25,6 +25,7 @@ impl AgenticMode { "Grep".to_string(), "Glob".to_string(), "WebSearch".to_string(), + "WebFetch".to_string(), "TodoWrite".to_string(), "GenerativeUI".to_string(), "Skill".to_string(), @@ -32,6 +33,7 @@ impl AgenticMode { "Git".to_string(), "TerminalControl".to_string(), "ControlHub".to_string(), + "InitMiniApp".to_string(), ], } } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs index fcf73ff04..f9f10d566 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -1,8 +1,10 @@ -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides}; +use crate::agentic::tools::framework::ToolExposure; use async_trait::async_trait; pub struct DeepResearchMode { default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, } impl Default for DeepResearchMode { @@ -13,6 +15,9 @@ impl Default for DeepResearchMode { impl DeepResearchMode { pub fn new() -> Self { + let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); + tool_exposure_overrides.insert("WebSearch".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("WebFetch".to_string(), ToolExposure::Expanded); Self { default_tools: vec![ "Task".to_string(), @@ -30,6 +35,7 @@ impl DeepResearchMode { "TodoWrite".to_string(), "AskUserQuestion".to_string(), ], + tool_exposure_overrides, } } } @@ -60,6 +66,10 @@ impl Agent for DeepResearchMode { self.default_tools.clone() } + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs b/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs index 565f31106..23a924f38 100644 --- a/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs +++ b/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs @@ -1,8 +1,10 @@ -use crate::agentic::agents::{Agent, RequestContextPolicy}; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides, RequestContextPolicy}; +use crate::agentic::tools::framework::ToolExposure; use async_trait::async_trait; pub struct ReviewFixerAgent { default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, } impl Default for ReviewFixerAgent { @@ -13,6 +15,9 @@ impl Default for ReviewFixerAgent { impl ReviewFixerAgent { pub fn new() -> Self { + let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); + tool_exposure_overrides.insert("GetFileDiff".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("Git".to_string(), ToolExposure::Expanded); Self { default_tools: vec![ "Read".to_string(), @@ -26,6 +31,7 @@ impl ReviewFixerAgent { "TodoWrite".to_string(), "Git".to_string(), ], + tool_exposure_overrides, } } } @@ -60,6 +66,10 @@ impl Agent for ReviewFixerAgent { RequestContextPolicy::instructions_only() } + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs b/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs index 09d412af1..d806fa475 100644 --- a/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs +++ b/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs @@ -3,60 +3,75 @@ use crate::agentic::deep_review_policy::{ REVIEWER_FRONTEND_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, }; -use crate::define_readonly_subagent; +use crate::agentic::agents::AgentToolPolicyOverrides; +use crate::agentic::tools::framework::ToolExposure; +use crate::define_readonly_subagent_with_overrides; -define_readonly_subagent!( +fn reviewer_tool_exposure_overrides() -> AgentToolPolicyOverrides { + let mut overrides = AgentToolPolicyOverrides::default(); + overrides.insert("GetFileDiff".to_string(), ToolExposure::Expanded); + overrides.insert("Git".to_string(), ToolExposure::Expanded); + overrides +} + +define_readonly_subagent_with_overrides!( BusinessLogicReviewerAgent, REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, "Business Logic Reviewer", r#"Independent read-only reviewer focused on workflow correctness, business rules, state transitions, data integrity, and edge-case handling in the review target. Use this when you need a fresh perspective on whether the change still does the right thing for real users."#, "review_business_logic_agent", - &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"] + &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"], + reviewer_tool_exposure_overrides() ); -define_readonly_subagent!( +define_readonly_subagent_with_overrides!( PerformanceReviewerAgent, REVIEWER_PERFORMANCE_AGENT_TYPE, "Performance Reviewer", r#"Independent read-only reviewer focused on latency, hot-path efficiency, unnecessary allocations, N+1 patterns, blocking calls, over-fetching, and scale-sensitive regressions introduced by the review target."#, "review_performance_agent", - &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"] + &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"], + reviewer_tool_exposure_overrides() ); -define_readonly_subagent!( +define_readonly_subagent_with_overrides!( SecurityReviewerAgent, REVIEWER_SECURITY_AGENT_TYPE, "Security Reviewer", r#"Independent read-only reviewer focused on security risks such as injection, auth gaps, data exposure, unsafe command/file handling, privilege escalation, and trust-boundary mistakes in the review target."#, "review_security_agent", - &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"] + &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"], + reviewer_tool_exposure_overrides() ); -define_readonly_subagent!( +define_readonly_subagent_with_overrides!( ArchitectureReviewerAgent, REVIEWER_ARCHITECTURE_AGENT_TYPE, "Architecture Reviewer", r#"Independent read-only reviewer focused on structural and architectural issues such as module boundary violations, API contract design, abstraction integrity, dependency direction, and cross-cutting concern impact in the review target."#, "review_architecture_agent", - &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"] + &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"], + reviewer_tool_exposure_overrides() ); -define_readonly_subagent!( +define_readonly_subagent_with_overrides!( FrontendReviewerAgent, REVIEWER_FRONTEND_AGENT_TYPE, "Frontend Reviewer", r#"Independent read-only reviewer focused on frontend-specific issues such as i18n key synchronization, frontend performance patterns (e.g., memoization, virtualization, effect/reactivity dependencies), accessibility, state management, frontend-backend API contract alignment, and platform boundary compliance in the review target."#, "review_frontend_agent", - &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"] + &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"], + reviewer_tool_exposure_overrides() ); -define_readonly_subagent!( +define_readonly_subagent_with_overrides!( ReviewJudgeAgent, REVIEW_JUDGE_AGENT_TYPE, "Review Quality Inspector", r#"Independent third-party arbiter that validates reviewer reports for logical consistency and evidence quality. It spot-checks specific code locations only when a claim needs verification, rather than re-reviewing the codebase from scratch."#, "review_quality_gate_agent", - &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"] + &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"], + reviewer_tool_exposure_overrides() ); #[cfg(test)] diff --git a/src/crates/core/src/agentic/agents/definitions/shared/readonly.rs b/src/crates/core/src/agentic/agents/definitions/shared/readonly.rs index a9c12d942..9de4a6998 100644 --- a/src/crates/core/src/agentic/agents/definitions/shared/readonly.rs +++ b/src/crates/core/src/agentic/agents/definitions/shared/readonly.rs @@ -1,4 +1,4 @@ -use crate::agentic::agents::{Agent, RequestContextPolicy}; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides, RequestContextPolicy}; use async_trait::async_trait; /// Internal helper that holds the common metadata and behaviour for @@ -9,15 +9,34 @@ pub struct ReadonlySubagent { description: &'static str, prompt_template: &'static str, default_tools: &'static [&'static str], + tool_exposure_overrides: AgentToolPolicyOverrides, } impl ReadonlySubagent { - pub const fn new( + pub fn new( id: &'static str, name: &'static str, description: &'static str, prompt_template: &'static str, default_tools: &'static [&'static str], + ) -> Self { + Self::with_overrides( + id, + name, + description, + prompt_template, + default_tools, + AgentToolPolicyOverrides::default(), + ) + } + + pub fn with_overrides( + id: &'static str, + name: &'static str, + description: &'static str, + prompt_template: &'static str, + default_tools: &'static [&'static str], + tool_exposure_overrides: AgentToolPolicyOverrides, ) -> Self { Self { id, @@ -25,6 +44,7 @@ impl ReadonlySubagent { description, prompt_template, default_tools, + tool_exposure_overrides, } } } @@ -59,6 +79,10 @@ impl Agent for ReadonlySubagent { RequestContextPolicy::instructions_only() } + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + fn is_readonly(&self) -> bool { true } @@ -130,6 +154,91 @@ macro_rules! define_readonly_subagent { self.inner.request_context_policy() } + fn tool_exposure_overrides( + &self, + ) -> &$crate::agentic::agents::AgentToolPolicyOverrides { + self.inner.tool_exposure_overrides() + } + + fn is_readonly(&self) -> bool { + self.inner.is_readonly() + } + } + }; +} + +#[macro_export] +macro_rules! define_readonly_subagent_with_overrides { + ( + $struct_name:ident, + $id:expr, + $name:literal, + $description:literal, + $prompt:literal, + $tools:expr, + $overrides:expr + ) => { + pub struct $struct_name { + inner: $crate::agentic::agents::ReadonlySubagent, + } + + impl Default for $struct_name { + fn default() -> Self { + Self::new() + } + } + + impl $struct_name { + pub fn new() -> Self { + Self { + inner: $crate::agentic::agents::ReadonlySubagent::with_overrides( + $id, + $name, + $description, + $prompt, + $tools, + $overrides, + ), + } + } + } + + #[async_trait::async_trait] + impl $crate::agentic::agents::Agent for $struct_name { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + self.inner.id() + } + + fn name(&self) -> &str { + self.inner.name() + } + + fn description(&self) -> &str { + self.inner.description() + } + + fn prompt_template_name(&self, model_name: Option<&str>) -> &str { + self.inner.prompt_template_name(model_name) + } + + fn default_tools(&self) -> Vec { + self.inner.default_tools() + } + + fn request_context_policy(&self) -> $crate::agentic::agents::RequestContextPolicy { + self.inner.request_context_policy() + } + + fn tool_exposure_overrides( + &self, + ) -> &$crate::agentic::agents::AgentToolPolicyOverrides { + self.inner.tool_exposure_overrides() + } + fn is_readonly(&self) -> bool { self.inner.is_readonly() } diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs b/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs index 85a183263..80306d874 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs +++ b/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs @@ -2,11 +2,13 @@ //! //! Dedicated agent for perceiving and operating the user's local computer. -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides}; +use crate::agentic::tools::framework::ToolExposure; use async_trait::async_trait; pub struct ComputerUseMode { default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, } impl Default for ComputerUseMode { @@ -17,6 +19,9 @@ impl Default for ComputerUseMode { impl ComputerUseMode { pub fn new() -> Self { + let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); + tool_exposure_overrides.insert("ControlHub".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("ComputerUse".to_string(), ToolExposure::Expanded); Self { default_tools: vec![ "AskUserQuestion".to_string(), @@ -27,6 +32,7 @@ impl ComputerUseMode { "ControlHub".to_string(), "ComputerUse".to_string(), ], + tool_exposure_overrides, } } } @@ -57,6 +63,10 @@ impl Agent for ComputerUseMode { self.default_tools.clone() } + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index 133fb8ab6..5bd89010d 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -6,8 +6,10 @@ mod definitions; mod prompt_builder; mod registry; +use crate::agentic::tools::framework::ToolExposure; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; +use indexmap::IndexMap; pub use definitions::custom::{CustomSubagent, CustomSubagentKind}; pub use definitions::hidden::{ CodeReviewAgent, DeepReviewAgent, GenerateDocAgent, InitAgent, @@ -32,8 +34,8 @@ pub use registry::catalog::{ builtin_agent_specs, BuiltinAgentSpec, }; pub use registry::types::{ - AgentCategory, AgentInfo, CustomSubagentConfig, SubAgentSource, SubagentListScope, - SubagentQueryContext, + AgentCategory, AgentInfo, AgentToolPolicy, CustomSubagentConfig, SubAgentSource, + SubagentListScope, SubagentQueryContext, }; pub use registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, SubagentVisibilitySummary, @@ -43,6 +45,11 @@ use std::any::Any; // Include embedded prompts generated at compile time include!(concat!(env!("OUT_DIR"), "/embedded_agents_prompt.rs")); +pub type AgentToolPolicyOverrides = IndexMap; + +static EMPTY_AGENT_TOOL_POLICY_OVERRIDES: std::sync::LazyLock = + std::sync::LazyLock::new(AgentToolPolicyOverrides::default); + /// Agent trait defining the interface for all agents #[async_trait] pub trait Agent: Send + Sync + 'static { @@ -120,6 +127,13 @@ pub trait Agent: Send + Sync + 'static { /// Get the list of default tools for this agent fn default_tools(&self) -> Vec; + /// Per-agent exposure overrides for allowed tools. + /// + /// Tools omitted here inherit their tool-defined default exposure. + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &EMPTY_AGENT_TOOL_POLICY_OVERRIDES + } + /// Whether this agent is read-only (prevents file modifications) fn is_readonly(&self) -> bool { false diff --git a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs index 5c1092ae2..c9b92e6be 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -21,6 +21,14 @@ const PLACEHOLDER_AGENT_MEMORY: &str = "{AGENT_MEMORY}"; const PLACEHOLDER_CLAW_WORKSPACE: &str = "{CLAW_WORKSPACE}"; const PLACEHOLDER_VISUAL_MODE: &str = "{VISUAL_MODE}"; const PLACEHOLDER_SESSION_ID: &str = "{SESSION_ID}"; +const ADDITIONAL_TOOLS_PROMPT: &str = r#"# Additional Tools + +Some tools in the tool list are intentionally collapsed. +Their listed descriptions are short summaries rather than full usage instructions. +Before calling a collapsed tool, call `GetToolSpec` with its exact tool name to read its full definition and input schema. +After reading the returned spec, call the real tool directly by its own name. +If a tool spec is already available in the current conversation, do not call `GetToolSpec` for it again. +"#; /// SSH remote host facts for system prompt (workspace tools run here, not on the local client). #[derive(Debug, Clone)] @@ -41,6 +49,8 @@ pub struct PromptBuilderContext { pub remote_project_layout: Option, /// When `Some(false)`, system prompt append Computer use text-only guidance (no screenshot tool output). pub supports_image_understanding: Option, + /// When true, append a static reminder that additional collapsed tools exist behind GetToolSpec. + pub has_additional_tools: bool, } impl PromptBuilderContext { @@ -56,6 +66,7 @@ impl PromptBuilderContext { remote_execution: None, remote_project_layout: None, supports_image_understanding: None, + has_additional_tools: false, } } @@ -64,6 +75,11 @@ impl PromptBuilderContext { self } + pub fn with_additional_tools_hint(mut self, has_additional_tools: bool) -> Self { + self.has_additional_tools = has_additional_tools; + self + } + pub fn with_remote_prompt_overlay( mut self, execution: RemoteExecutionHints, @@ -400,6 +416,43 @@ The configured **primary model does not accept image inputs**. When using **`Com ); } + if self.context.has_additional_tools { + result.push_str("\n\n"); + result.push_str(ADDITIONAL_TOOLS_PROMPT); + result.push('\n'); + } + Ok(result.trim().to_string()) } } + +#[cfg(test)] +mod tests { + use super::PromptBuilder; + use super::PromptBuilderContext; + + #[tokio::test] + async fn appends_additional_tools_section_when_hint_is_enabled() { + let context = + PromptBuilderContext::new("E:/workspace", None, None).with_additional_tools_hint(true); + let prompt = PromptBuilder::new(context) + .build_prompt_from_template("Base prompt") + .await + .expect("prompt should build"); + + assert!(prompt.contains("# Additional Tools")); + assert!(prompt.contains("short summaries rather than full usage instructions")); + assert!(prompt.contains("call `GetToolSpec` with its exact tool name")); + } + + #[tokio::test] + async fn omits_additional_tools_section_when_hint_is_disabled() { + let context = PromptBuilderContext::new("E:/workspace", None, None); + let prompt = PromptBuilder::new(context) + .build_prompt_from_template("Base prompt") + .await + .expect("prompt should build"); + + assert!(!prompt.contains("# Additional Tools")); + } +} diff --git a/src/crates/core/src/agentic/agents/registry/query.rs b/src/crates/core/src/agentic/agents/registry/query.rs index 3d649f5c2..46b9d7240 100644 --- a/src/crates/core/src/agentic/agents/registry/query.rs +++ b/src/crates/core/src/agentic/agents/registry/query.rs @@ -2,7 +2,7 @@ use super::support::{get_mode_configs, get_subagent_configs, merge_dynamic_mcp_t use super::AgentRegistry; use crate::agentic::agents::registry::types::{is_review_agent_entry, AgentEntry}; use crate::agentic::agents::{ - AgentCategory, AgentInfo, SubagentListScope, SubagentQueryContext, + AgentCategory, AgentInfo, AgentToolPolicy, SubagentListScope, SubagentQueryContext, }; use crate::agentic::tools::get_all_registered_tool_names; use crate::service::config::mode_config_canonicalizer::resolve_effective_tools; @@ -32,17 +32,21 @@ impl AgentRegistry { result } - /// get agent tools from config - /// if not set, return default tools - /// mode config canonicalization is handled separately; this only reads resolved configuration - pub async fn get_agent_tools( + /// Resolve the current tool policy for an agent. + /// + /// This returns both the allowed tool set and any per-agent exposure + /// overrides that should be applied on top of tool defaults. + pub async fn get_agent_tool_policy( &self, agent_type: &str, workspace_root: Option<&Path>, - ) -> Vec { + ) -> AgentToolPolicy { let entry = self.find_agent_entry(agent_type, workspace_root); let Some(entry) = entry else { - return Vec::new(); + return AgentToolPolicy { + allowed_tools: Vec::new(), + exposure_overrides: Default::default(), + }; }; match entry.category { AgentCategory::Mode => { @@ -54,13 +58,45 @@ impl AgentRegistry { mode_configs.get(agent_type), &valid_tools, ); + let allowed_tools = merge_dynamic_mcp_tools(resolved_tools, ®istered_tool_names); + let allowed_tool_set: HashSet<&str> = + allowed_tools.iter().map(String::as_str).collect(); + let mut exposure_overrides = entry.agent.tool_exposure_overrides().clone(); + exposure_overrides.retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); - merge_dynamic_mcp_tools(resolved_tools, ®istered_tool_names) + AgentToolPolicy { + allowed_tools, + exposure_overrides, + } + } + AgentCategory::SubAgent | AgentCategory::Hidden => { + let allowed_tools = entry.agent.default_tools(); + let allowed_tool_set: HashSet<&str> = + allowed_tools.iter().map(String::as_str).collect(); + let mut exposure_overrides = entry.agent.tool_exposure_overrides().clone(); + exposure_overrides.retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); + + AgentToolPolicy { + allowed_tools, + exposure_overrides, + } } - AgentCategory::SubAgent | AgentCategory::Hidden => entry.agent.default_tools(), } } + /// get agent tools from config + /// if not set, return default tools + /// mode config canonicalization is handled separately; this only reads resolved configuration + pub async fn get_agent_tools( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Vec { + self.get_agent_tool_policy(agent_type, workspace_root) + .await + .allowed_tools + } + /// get all mode agent information (including enabled status, used for frontend mode selector etc.) pub async fn get_modes_info(&self) -> Vec { let mode_configs = get_mode_configs().await; diff --git a/src/crates/core/src/agentic/agents/registry/types.rs b/src/crates/core/src/agentic/agents/registry/types.rs index 8947ed42a..fae492c76 100644 --- a/src/crates/core/src/agentic/agents/registry/types.rs +++ b/src/crates/core/src/agentic/agents/registry/types.rs @@ -4,7 +4,7 @@ use crate::agentic::deep_review_policy::{ REVIEWER_FRONTEND_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, }; -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides}; use crate::agentic::agents::registry::visibility::{ SubagentVisibilityPolicy, SubagentVisibilitySummary, }; @@ -53,6 +53,12 @@ pub struct CustomSubagentConfig { pub model: String, } +#[derive(Debug, Clone)] +pub struct AgentToolPolicy { + pub allowed_tools: Vec, + pub exposure_overrides: AgentToolPolicyOverrides, +} + /// Agent category #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentCategory { diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 7b9b4ec6f..980a7ce28 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -19,9 +19,7 @@ use crate::agentic::image_analysis::{ ImageLimits, }; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; -use crate::agentic::tools::{ - get_all_registered_tools, SubagentParentInfo, ToolRuntimeRestrictions, -}; +use crate::agentic::tools::{resolve_tool_manifest, ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions}; use crate::agentic::util::build_remote_workspace_layout_preview; use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; use crate::infrastructure::ai::get_global_ai_client_factory; @@ -35,7 +33,7 @@ use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::Path; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -496,10 +494,46 @@ impl ExecutionEngine { turn_index == 0 && original_user_input.chars().count() <= 10 } + fn collect_unlocked_collapsed_tools( + messages: &[Message], + collapsed_tools: &[String], + ) -> Vec { + let collapsed_set: HashSet<&str> = collapsed_tools.iter().map(String::as_str).collect(); + let mut unlocked = BTreeSet::new(); + + for message in messages { + let MessageContent::ToolResult { + tool_name, + result, + is_error, + .. + } = &message.content + else { + continue; + }; + + if *is_error || tool_name != "GetToolSpec" { + continue; + } + + let Some(tool_name) = result.get("tool_name").and_then(|v| v.as_str()) + else { + continue; + }; + + if collapsed_set.contains(tool_name) { + unlocked.insert(tool_name.to_string()); + } + } + + unlocked.into_iter().collect() + } + async fn build_prompt_context( context: &ExecutionContext, model_name: &str, supports_image_understanding: bool, + has_additional_tools: bool, ) -> Option { let workspace_path = context .workspace @@ -511,7 +545,8 @@ impl ExecutionEngine { Some(context.session_id.clone()), Some(model_name.to_string()), ) - .with_supports_image_understanding(supports_image_understanding); + .with_supports_image_understanding(supports_image_understanding) + .with_additional_tools_hint(has_additional_tools); let Some(workspace) = context.workspace.as_ref() else { return Some(base); @@ -719,6 +754,8 @@ impl ExecutionEngine { workspace: context.workspace.clone(), messages: messages.to_vec(), available_tools: Vec::new(), + collapsed_tools: Vec::new(), + unlocked_collapsed_tools: Vec::new(), model_name: ai_client.config.model.clone(), agent_type, context_vars: execution_context_vars.clone(), @@ -1480,7 +1517,54 @@ impl ExecutionEngine { context_profile_policy.consecutive_failed_command_threshold ); - // 3. Get System Prompt from current Agent + // 3. Get available tools list (read tool configuration for current mode from global config) + let tool_policy = agent_registry + .get_agent_tool_policy( + &agent_type, + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), + ) + .await; + let allowed_tools = tool_policy.allowed_tools.clone(); + let enable_tools = context + .context + .get("enable_tools") + .and_then(|v| v.parse::().ok()) + .unwrap_or(true); + let tool_manifest = if enable_tools { + debug!( + "Agent tools: agent={}, tool_count={}", + agent_type, + allowed_tools.len() + ); + Some( + self.get_available_tools_and_definitions( + &allowed_tools, + &tool_policy.exposure_overrides, + context.workspace.as_ref(), + context.workspace_services.as_ref(), + &agent_type, + primary_supports_image_understanding, + ) + .await, + ) + } else { + None + }; + let collapsed_tools = tool_manifest + .as_ref() + .map(|manifest| manifest.collapsed_tool_names.clone()) + .unwrap_or_default(); + let has_additional_tools = !collapsed_tools.is_empty(); + let (available_tools, tool_definitions) = if let Some(manifest) = tool_manifest { + (manifest.allowed_tool_names, Some(manifest.tool_definitions)) + } else { + (vec![], None) + }; + + // 4. Get System Prompt from current Agent debug!( "Building system prompt from agent: {}, model={}", current_agent.name(), @@ -1490,6 +1574,7 @@ impl ExecutionEngine { &context, &ai_client.config.model, primary_supports_image_understanding, + has_additional_tools, ) .await; let request_context_reminder = if let Some(prompt_context) = prompt_context.as_ref() { @@ -1558,39 +1643,6 @@ impl ExecutionEngine { .collect::>() ); - // 4. Get available tools list (read tool configuration for current mode from global config) - let allowed_tools = agent_registry - .get_agent_tools( - &agent_type, - context - .workspace - .as_ref() - .map(|workspace| workspace.root_path()), - ) - .await; - let enable_tools = context - .context - .get("enable_tools") - .and_then(|v| v.parse::().ok()) - .unwrap_or(true); - let (available_tools, tool_definitions) = if enable_tools { - debug!( - "Agent tools: agent={}, tool_count={}", - agent_type, - allowed_tools.len() - ); - self.get_available_tools_and_definitions( - &allowed_tools, - context.workspace.as_ref(), - context.workspace_services.as_ref(), - &agent_type, - primary_supports_image_understanding, - ) - .await - } else { - (vec![], None) - }; - let enable_context_compression = session.config.enable_context_compression; let compression_threshold = session.config.compression_threshold; @@ -1786,6 +1838,8 @@ impl ExecutionEngine { if context.skip_tool_confirmation { round_context_vars.insert("skip_tool_confirmation".to_string(), "true".to_string()); } + let unlocked_collapsed_tools = + Self::collect_unlocked_collapsed_tools(&messages, &collapsed_tools); let round_context = RoundContext { session_id: context.session_id.clone(), subagent_parent_info: context.subagent_parent_info.clone(), @@ -1795,6 +1849,8 @@ impl ExecutionEngine { workspace: context.workspace.clone(), messages: messages.clone(), available_tools: available_tools.clone(), + collapsed_tools: collapsed_tools.clone(), + unlocked_collapsed_tools, model_name: ai_client.config.model.clone(), agent_type: agent_type.clone(), context_vars: round_context_vars, @@ -2123,9 +2179,7 @@ impl ExecutionEngine { } warn!( "Thinking-only round detected; injecting rescue reminder #{}: turn={}, round={}", - thinking_only_rescue_attempts, - context.dialog_turn_id, - round_index + thinking_only_rescue_attempts, context.dialog_turn_id, round_index ); // Continue into the next round so the model gets a chance to act. } else { @@ -2419,17 +2473,13 @@ impl ExecutionEngine { /// Get available tool names and definitions: 1. Tool itself is enabled 2. Explicitly allowed in mode config async fn get_available_tools_and_definitions( &self, - mode_allowed_tools: &[String], + allowed_tools: &[String], + exposure_overrides: &crate::agentic::agents::AgentToolPolicyOverrides, workspace: Option<&crate::agentic::WorkspaceBinding>, workspace_services: Option<&crate::agentic::workspace::WorkspaceServices>, agent_type: &str, primary_supports_image_understanding: bool, - ) -> (Vec, Option>) { - // Use get_all_registered_tools to get all tools including MCP tools - let all_tools = get_all_registered_tools().await; - - // Filter tools: 1) Check if enabled 2) Check if mode allows - let mut tool_definitions = Vec::new(); + ) -> ResolvedToolManifest { let mut tool_opts_custom = HashMap::new(); tool_opts_custom.insert( "primary_model_supports_image_understanding".to_string(), @@ -2441,68 +2491,14 @@ impl ExecutionEngine { session_id: None, dialog_turn_id: None, workspace: workspace.cloned(), + unlocked_collapsed_tools: Vec::new(), custom_data: tool_opts_custom, computer_use_host: None, cancellation_token: None, runtime_tool_restrictions: ToolRuntimeRestrictions::default(), workspace_services: workspace_services.cloned(), }; - for tool in &all_tools { - if !tool - .is_available_in_context(Some(&description_context)) - .await - { - continue; - } - - let tool_name = tool.name().to_string(); - if mode_allowed_tools.contains(&tool_name) { - let description = tool - .description_with_context(Some(&description_context)) - .await - .unwrap_or_else(|_| format!("Tool: {}", tool.name())); - - let parameters = tool - .input_schema_for_model_with_context(Some(&description_context)) - .await; - - tool_definitions.push(ToolDefinition { - name: tool.name().to_string(), - description, - parameters, - }); - } - } - - // Order tools for the model API: terminal → file-ish tools → **`ControlHub`** - // (unified desktop / browser / app / terminal / system control) last so the - // list matches “think with files first, act on UI last”. - let tool_ordering: HashMap = [ - ("Task", 1), - ("Bash", 2), - ("TerminalControl", 3), - ("Glob", 4), - ("Grep", 5), - ("Read", 6), - ("Edit", 7), - ("Write", 8), - ("Delete", 9), - ("WebFetch", 10), - ("WebSearch", 11), - ("TodoWrite", 12), - ("Skill", 13), - ("Log", 14), - ("ControlHub", 15), - ] - .into_iter() - .map(|(k, v)| (k.to_string(), v)) - .collect(); - tool_definitions.sort_by_key(|tool| tool_ordering.get(&tool.name).unwrap_or(&100)); - - let enabled_tool_names: Vec = - tool_definitions.iter().map(|d| d.name.clone()).collect(); - - (enabled_tool_names, Some(tool_definitions)) + resolve_tool_manifest(allowed_tools, exposure_overrides, &description_context).await } /// Emit event @@ -2750,6 +2746,54 @@ mod tests { )); } + #[test] + fn collects_unlocked_collapsed_tools_from_visible_get_tool_spec_results() { + let visible_get_tool_spec_result = Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "GetToolSpec".to_string(), + result: json!({ + "tool_name": "WebFetch", + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }); + let hidden_get_tool_spec_result = Message::tool_result(ToolResult { + tool_id: "tool-2".to_string(), + tool_name: "GetToolSpec".to_string(), + result: json!({ + "tool_name": "Read", + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }); + let failed_get_tool_spec_result = Message::tool_result(ToolResult { + tool_id: "tool-3".to_string(), + tool_name: "GetToolSpec".to_string(), + result: json!({ + "tool_name": "GetFileDiff", + }), + result_for_assistant: None, + is_error: true, + duration_ms: Some(1), + image_attachments: None, + }); + + let unlocked = ExecutionEngine::collect_unlocked_collapsed_tools( + &[ + visible_get_tool_spec_result, + hidden_get_tool_spec_result, + failed_get_tool_spec_result, + ], + &["WebFetch".to_string(), "GetFileDiff".to_string()], + ); + + assert_eq!(unlocked, vec!["WebFetch".to_string()]); + } + #[test] fn detects_tool_result_after_last_assistant() { let assistant = Message::assistant_with_tools( diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 5841a1e5f..8c0a9d80b 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -4,15 +4,15 @@ use super::stream_processor::{StreamProcessOptions, StreamProcessor, StreamResult}; use super::types::{FinishReason, RoundContext, RoundResult}; -use crate::agentic::MessageContent; use crate::agentic::core::{Message, ToolCall}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; -use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::ToolUseContext; use crate::agentic::tools::implementations::file_write_tool::FileWriteTool; use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions, ToolPipeline}; use crate::agentic::tools::registry::get_global_tool_registry; +use crate::agentic::tools::ToolPathOperation; +use crate::agentic::MessageContent; use crate::infrastructure::ai::AIClient; use crate::service::config::GlobalConfigManager; use crate::util::elapsed_ms_u64; @@ -585,6 +585,8 @@ impl RoundExecutor { workspace: context.workspace.clone(), context_vars: context.context_vars.clone(), subagent_parent_info, + collapsed_tools: context.collapsed_tools.clone(), + unlocked_collapsed_tools: context.unlocked_collapsed_tools.clone(), allowed_tools: context.available_tools.clone(), runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), steering_interrupt: context.steering_interrupt.clone(), @@ -1070,6 +1072,7 @@ impl RoundExecutor { session_id: Some(context.session_id.clone()), dialog_turn_id: Some(context.dialog_turn_id.clone()), workspace: context.workspace.clone(), + unlocked_collapsed_tools: context.unlocked_collapsed_tools.clone(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, @@ -1465,7 +1468,7 @@ fn detect_placeholder_patterns(content: &str) -> Option<&'static str> { #[cfg(test)] mod tests { - use super::{RoundExecutor, StreamProcessor, extract_bitfun_contents}; + use super::{extract_bitfun_contents, RoundExecutor, StreamProcessor}; use crate::agentic::events::{EventQueue, EventQueueConfig}; use dashmap::DashMap; use std::sync::Arc; diff --git a/src/crates/core/src/agentic/execution/types.rs b/src/crates/core/src/agentic/execution/types.rs index 51ce884d6..89176a93d 100644 --- a/src/crates/core/src/agentic/execution/types.rs +++ b/src/crates/core/src/agentic/execution/types.rs @@ -48,6 +48,8 @@ pub struct RoundContext { pub workspace: Option, pub messages: Vec, pub available_tools: Vec, + pub collapsed_tools: Vec, + pub unlocked_collapsed_tools: Vec, pub model_name: String, pub agent_type: String, pub context_vars: HashMap, diff --git a/src/crates/core/src/agentic/tools/agent-tool-exposure.md b/src/crates/core/src/agentic/tools/agent-tool-exposure.md new file mode 100644 index 000000000..08097ffac --- /dev/null +++ b/src/crates/core/src/agentic/tools/agent-tool-exposure.md @@ -0,0 +1,59 @@ +## Current Tool Default Exposure / Collapse States and Agent Overrides + +Notes: +- "Default state" comes from `Tool::default_exposure()`. Tools that do not implement this method default to `Expanded`. +- "Overriding agents" only lists built-in agents that explicitly define `tool_exposure_overrides()` in the current code. +- Custom subagents do not currently support independent exposure overrides and inherit the default behavior. + +**Tool Exposure Table** + +| Tool | Default State | Overridden By | Override State | +|---|---|---|---| +| `LS` | Expanded | None | - | +| `Read` | Expanded | None | - | +| `Glob` | Expanded | None | - | +| `Grep` | Expanded | None | - | +| `Write` | Expanded | None | - | +| `Edit` | Expanded | None | - | +| `Delete` | Expanded | None | - | +| `Bash` | Expanded | None | - | +| `Task` | Expanded | None | - | +| `Skill` | Expanded | None | - | +| `AskUserQuestion` | Expanded | None | - | +| `TodoWrite` | Expanded | None | - | +| `CreatePlan` | Expanded | None | - | +| `CodeReview` | Expanded | None | - | +| `GetToolSpec` | Expanded | None | - | +| `GetFileDiff` | Collapsed | `ReviewFixer`, `ReviewBusinessLogic`, `ReviewPerformance`, `ReviewSecurity`, `ReviewArchitecture`, `ReviewFrontend`, `ReviewJudge` | Expanded | +| `Log` | Collapsed | None | - | +| `TerminalControl` | Collapsed | None | - | +| `SessionControl` | Collapsed | None | - | +| `SessionMessage` | Collapsed | None | - | +| `SessionHistory` | Collapsed | None | - | +| `Cron` | Collapsed | None | - | +| `WebSearch` | Collapsed | `DeepResearch` | Expanded | +| `WebFetch` | Collapsed | `DeepResearch` | Expanded | +| `ListMCPResources` | Collapsed | None | - | +| `ReadMCPResource` | Collapsed | None | - | +| `ListMCPPrompts` | Collapsed | None | - | +| `GetMCPPrompt` | Collapsed | None | - | +| `GenerativeUI` | Collapsed | None | - | +| `Git` | Collapsed | `ReviewFixer`, `ReviewBusinessLogic`, `ReviewPerformance`, `ReviewSecurity`, `ReviewArchitecture`, `ReviewFrontend`, `ReviewJudge` | Expanded | +| `InitMiniApp` | Collapsed | None | - | +| `ControlHub` | Collapsed | `ComputerUse` | Expanded | +| `ComputerUse` | Collapsed | `ComputerUse` | Expanded | +| `Playbook` | Collapsed | None | - | + +**Agents With Override Policies** + +| agent id | Overridden Tools | +|---|---| +| `DeepResearch` | `WebSearch`, `WebFetch` | +| `ComputerUse` | `ControlHub`, `ComputerUse` | +| `ReviewFixer` | `GetFileDiff`, `Git` | +| `ReviewBusinessLogic` | `GetFileDiff`, `Git` | +| `ReviewPerformance` | `GetFileDiff`, `Git` | +| `ReviewSecurity` | `GetFileDiff`, `Git` | +| `ReviewArchitecture` | `GetFileDiff`, `Git` | +| `ReviewFrontend` | `GetFileDiff`, `Git` | +| `ReviewJudge` | `GetFileDiff`, `Git` | diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index 1381d65e1..fe36b36d1 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -29,6 +29,11 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio_util::sync::CancellationToken; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolExposure { + Expanded, + Collapsed, +} /// Tool use context #[derive(Debug, Clone)] pub struct ToolUseContext { @@ -37,6 +42,7 @@ pub struct ToolUseContext { pub session_id: Option, pub dialog_turn_id: Option, pub workspace: Option, + pub unlocked_collapsed_tools: Vec, /// Extended context data passed from execution layer to tools. pub custom_data: HashMap, /// Desktop automation (Computer use); only set in BitFun desktop. @@ -448,6 +454,7 @@ mod path_resolution_tests { session_id: None, dialog_turn_id: None, workspace: Some(WorkspaceBinding::new(None, PathBuf::from(root))), + unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, @@ -463,6 +470,7 @@ mod path_resolution_tests { session_id: None, dialog_turn_id: None, workspace: None, + unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, @@ -540,6 +548,17 @@ pub trait Tool: Send + Sync { self.description().await } + /// Short description used in condensed tool listings such as GetToolSpec. + fn short_description(&self) -> String; + + /// Default exposure level when building the model tool manifest. + /// + /// This is tool-owned metadata: registries and agent manifests may use it + /// as the baseline before applying any higher-level overrides. + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Expanded + } + /// Input mode definition - using JSON Schema fn input_schema(&self) -> Value; @@ -711,6 +730,10 @@ mod shared_context_tests { Ok("Read file".to_string()) } + fn short_description(&self) -> String { + "Read file".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -751,6 +774,7 @@ mod shared_context_tests { session_id: Some("subagent-session".to_string()), dialog_turn_id: Some("subagent-turn".to_string()), workspace: None, + unlocked_collapsed_tools: Vec::new(), custom_data, computer_use_host: None, cancellation_token: None, diff --git a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs index 35cc9f007..b0c688b39 100644 --- a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs @@ -201,6 +201,10 @@ Usage notes: - Use multiSelect: true to allow multiple answers to be selected for a question"#.to_string()) } + fn short_description(&self) -> String { + "Ask the user focused follow-up questions during execution.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index cad5f1dd5..0538bc51e 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -421,6 +421,10 @@ Usage notes: )) } + fn short_description(&self) -> String { + "Run commands in the persistent shell session.".to_string() + } + async fn description_with_context( &self, context: Option<&ToolUseContext>, diff --git a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs index 4595b51d0..1a94f8fb2 100644 --- a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs @@ -590,6 +590,10 @@ impl Tool for CodeReviewTool { Ok(Self::description_for_language(lang.as_str())) } + fn short_description(&self) -> String { + "Submit a structured code review result.".to_string() + } + fn input_schema(&self) -> Value { Self::input_schema_value() } @@ -736,6 +740,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, + unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs index a5f407710..171b806a7 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs @@ -35,6 +35,10 @@ impl Tool for ComputerUseMouseClickTool { ) } + fn short_description(&self) -> String { + "Click or scroll at the current mouse pointer position.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs index d6e4eb1d9..b9af910f1 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs @@ -34,6 +34,10 @@ impl Tool for ComputerUseMousePreciseTool { ) } + fn short_description(&self) -> String { + "Move the mouse pointer to precise absolute screen coordinates.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs index 8e2e73b68..616be4b52 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs @@ -34,6 +34,10 @@ impl Tool for ComputerUseMouseStepTool { ) } + fn short_description(&self) -> String { + "Move the mouse pointer by a small directional step.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs index ae9260877..128cc7366 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -13,7 +13,7 @@ use crate::agentic::tools::computer_use_host::{ COMPUTER_USE_QUADRANT_CLICK_READY_MAX_LONG_EDGE, COMPUTER_USE_QUADRANT_EDGE_EXPAND_PX, }; use crate::agentic::tools::computer_use_optimizer::hash_screenshot_bytes; -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; use crate::service::config::global::GlobalConfigManager; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::ToolImageAttachment; @@ -1274,6 +1274,14 @@ impl Tool for ComputerUseTool { )) } + fn short_description(&self) -> String { + "Inspect the screen and control desktop input for computer-use tasks.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + async fn description_with_context( &self, context: Option<&ToolUseContext>, diff --git a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs index 60a873a13..d558c6b89 100644 --- a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -17,7 +17,7 @@ use crate::agentic::tools::browser_control::session_registry::{ BrowserSession, BrowserSessionRegistry, }; use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::service::config::{get_global_config_service, GlobalConfig}; use crate::util::errors::{BitFunError, BitFunResult}; @@ -1221,6 +1221,15 @@ impl Tool for ControlHubTool { Ok(Self::description_text()) } + fn short_description(&self) -> String { + "Control browser, terminal, and desktop helper domains through one tool." + .to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + async fn description_with_context( &self, _context: Option<&ToolUseContext>, @@ -1469,6 +1478,7 @@ mod control_hub_tests { session_id: None, dialog_turn_id: None, workspace: None, + unlocked_collapsed_tools: Vec::new(), custom_data: std::collections::HashMap::new(), computer_use_host: None, cancellation_token: None, diff --git a/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs b/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs index 13e602278..a521b073a 100644 --- a/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs @@ -82,6 +82,10 @@ Additional guidelines: .to_string()) } + fn short_description(&self) -> String { + "Create and store a concise implementation plan.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs index 572ca7583..4a629c83f 100644 --- a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs @@ -1,7 +1,7 @@ use super::util::normalize_path; use crate::agentic::coordination::get_global_coordinator; use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; use crate::service::{ @@ -585,6 +585,14 @@ Patch schema for "update": .to_string()) } + fn short_description(&self) -> String { + "Manage scheduled jobs for agent sessions.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs index 3a5495537..d6f22b5f2 100644 --- a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -84,6 +84,10 @@ Important notes: - The tool will fail gracefully if permissions are insufficient"#.to_string()) } + fn short_description(&self) -> String { + "Delete a file or directory from the filesystem.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index a23e42d52..a284aed24 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -43,6 +43,10 @@ Usage: .to_string()) } + fn short_description(&self) -> String { + "Apply exact string replacements to an existing file.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs index 8fc8aba82..fa2d7d4c4 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs @@ -221,6 +221,10 @@ Usage: )) } + fn short_description(&self) -> String { + "Read file contents from the current workspace.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index e9b19e43e..0598059fc 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -104,6 +104,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: Some(WorkspaceBinding::new(None, root)), + unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, @@ -214,6 +215,10 @@ Usage: - Do NOT include the file content in the tool call arguments. Only provide file_path. The system will prompt you separately to output the file content as plain text."#.to_string()) } + fn short_description(&self) -> String { + "Write a new file or fully replace an existing file.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs b/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs index 66a0d352f..384139858 100644 --- a/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs @@ -1,6 +1,8 @@ //! GenerativeUI tool — renders LLM-generated HTML/SVG widgets. -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolResult, ToolUseContext, ValidationResult, +}; use crate::service::config::get_global_config_service; use crate::util::errors::BitFunResult; use async_trait::async_trait; @@ -332,6 +334,14 @@ Input rules: Ok(description) } + fn short_description(&self) -> String { + "Render visual HTML or SVG widgets in chat. Use when charts, visual structure, or lightweight interaction would communicate information more clearly and efficiently than plain text.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs index 1eca54485..3eeb96e02 100644 --- a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -1,5 +1,5 @@ use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::workspace_paths::is_bitfun_runtime_uri; use crate::service::git::git_service::GitService; @@ -305,6 +305,14 @@ Usage: ) } + fn short_description(&self) -> String { + "Show the diff for a file against its baseline snapshot or Git HEAD.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs new file mode 100644 index 000000000..f3a6ce713 --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs @@ -0,0 +1,405 @@ +//! GetToolSpec tool implementation + +use crate::agentic::agents::get_agent_registry; +use crate::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::agentic::tools::resolve_visible_tools; +use crate::agentic::tools::registry::get_global_tool_registry; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use log::debug; +use serde_json::{json, Value}; +use std::sync::Arc; + +pub struct GetToolSpecTool; + +impl GetToolSpecTool { + pub fn new() -> Self { + Self + } + + fn escape_xml_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + } + + fn render_collapsed_tools_description(&self, collapsed_tools_list: String) -> String { + format!( + r#"Read usage instructions for additional tools. + +You have access to an additional tools listed below. + + +{} + + +Before using one of these tools, first call GetToolSpec with its exact tool name to read its full description and input schema. + +After reading the returned definition, call the real tool directly using its own name. + +Do not call GetToolSpec again for a tool whose definition is already loaded in the current conversation. + +Example: +- Suppose the catalog includes a tool named `GetWeather` and you need to use it. +- First call `GetToolSpec` with `{{"tool_name":"GetWeather"}}` +- Then read the returned schema and call `GetWeather` itself with the appropriate arguments +"#, + collapsed_tools_list + ) + } + + async fn get_contextual_collapsed_tools( + &self, + context: &ToolUseContext, + ) -> BitFunResult>> { + let agent_type = context.agent_type.as_deref().ok_or_else(|| { + BitFunError::Validation("GetToolSpec requires agent type context".to_string()) + })?; + let workspace_root = context.workspace_root(); + let agent_registry = get_agent_registry(); + let policy = agent_registry + .get_agent_tool_policy(agent_type, workspace_root) + .await; + let visible_tools = + resolve_visible_tools(&policy.allowed_tools, &policy.exposure_overrides, context).await; + Ok(visible_tools.collapsed_tools) + } + + async fn build_collapsed_tools_description(&self, context: Option<&ToolUseContext>) -> String { + let mut entries = Vec::new(); + + if let Some(context) = context { + if let Ok(collapsed_tools) = self.get_contextual_collapsed_tools(context).await { + for tool in collapsed_tools { + entries.push(format!("- {}", tool.name())); + } + } + } else { + let registry = get_global_tool_registry(); + let collapsed_tools = { + let registry = registry.read().await; + registry + .get_all_tools() + .into_iter() + .filter(|tool| { + tool.default_exposure() + == crate::agentic::tools::framework::ToolExposure::Collapsed + }) + .map(|tool| (tool.name().to_string(), tool.short_description())) + .collect::>() + }; + + for (tool_name, short_description) in collapsed_tools { + entries.push(format!("- {}: {}", tool_name, short_description)); + } + } + + let collapsed_tools_list = if entries.is_empty() { + "No additional tools are available.".to_string() + } else { + entries.join("\n") + }; + + self.render_collapsed_tools_description(collapsed_tools_list) + } + + async fn build_tool_detail( + &self, + tool_name: &str, + context: Option<&ToolUseContext>, + ) -> BitFunResult { + let context = context.ok_or_else(|| { + BitFunError::Validation("GetToolSpec requires execution context".to_string()) + })?; + let collapsed_tools = self.get_contextual_collapsed_tools(context).await?; + let tool = collapsed_tools + .into_iter() + .find(|tool| tool.name() == tool_name) + .ok_or_else(|| { + BitFunError::Validation(format!( + "Tool '{}' is not an available collapsed tool in the current context", + tool_name + )) + })?; + + if tool.name() == self.name() { + return Err(BitFunError::Validation(format!( + "Tool '{}' cannot inspect itself", + tool_name + ))); + } + + let description = tool + .description_with_context(Some(context)) + .await + .unwrap_or_else(|_| format!("Tool: {}", tool.name())); + let input_schema = tool.input_schema_for_model_with_context(Some(context)).await; + + Ok(json!({ + "tool_name": tool_name, + "description": description, + "input_schema": input_schema + })) + } +} + +impl Default for GetToolSpecTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for GetToolSpecTool { + fn name(&self) -> &str { + "GetToolSpec" + } + + async fn description(&self) -> BitFunResult { + Ok(self.build_collapsed_tools_description(None).await) + } + + fn short_description(&self) -> String { + "Discover collapsed tools and read their detailed definitions.".to_string() + } + + async fn description_with_context( + &self, + context: Option<&ToolUseContext>, + ) -> BitFunResult { + Ok(self.build_collapsed_tools_description(context).await) + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["tool_name"], + "properties": { + "tool_name": { + "type": "string", + "description": "The exact tool name to read details for." + } + } + }) + } + + fn is_readonly(&self) -> bool { + true + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let tool_name = input + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + format!("Reading tool spec for '{}'.", tool_name) + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let Some(tool_name) = input.get("tool_name").and_then(|v| v.as_str()) else { + return ValidationResult { + result: false, + message: Some("tool_name is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + }; + + if tool_name.is_empty() { + return ValidationResult { + result: false, + message: Some("tool_name is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + + ValidationResult::default() + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let tool_name = input + .get("tool_name") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("tool_name is required".to_string()))?; + + if context + .unlocked_collapsed_tools + .iter() + .any(|loaded| loaded == tool_name) + { + return Ok(vec![ToolResult::Result { + data: json!({ + "tool_name": tool_name, + "already_loaded": true + }), + result_for_assistant: Some(format!( + "Tool '{}' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use '{}' directly.", + tool_name, tool_name + )), + image_attachments: None, + }]); + } + + debug!("GetToolSpec reading tool: {}", tool_name); + let detail = self.build_tool_detail(tool_name, Some(context)).await?; + let description = detail + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let input_schema = detail + .get("input_schema") + .map(|value| value.to_string()) + .unwrap_or_else(|| "{}".to_string()); + let assistant_detail = format!( + "\n{}\n\n\n{}\n", + Self::escape_xml_text(description), + Self::escape_xml_text(&input_schema) + ); + + Ok(vec![ToolResult::Result { + data: detail, + result_for_assistant: Some(assistant_detail), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::GetToolSpecTool; + use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolResult, ToolUseContext, ValidationResult, + }; + use crate::agentic::tools::registry::get_global_tool_registry; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::util::errors::BitFunResult; + use async_trait::async_trait; + use serde_json::{json, Value}; + use std::collections::HashMap; + use std::sync::Arc; + + struct CatalogDescriptionTestTool { + name: String, + } + + #[async_trait] + impl Tool for CatalogDescriptionTestTool { + fn name(&self) -> &str { + &self.name + } + + async fn description(&self) -> BitFunResult { + Ok("Verbose description first line.\nSecond line.".to_string()) + } + + fn short_description(&self) -> String { + "Concise catalog entry.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + + fn input_schema(&self) -> Value { + json!({ "type": "object" }) + } + + async fn validate_input( + &self, + _input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + ValidationResult::default() + } + + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(Vec::new()) + } + } + + #[tokio::test] + async fn get_tool_spec_uses_explicit_short_description() { + let tool_name = format!("CatalogDescriptionTestTool_{}", uuid::Uuid::new_v4()); + let registry = get_global_tool_registry(); + { + let mut registry = registry.write().await; + registry.register_tool(Arc::new(CatalogDescriptionTestTool { + name: tool_name.clone(), + })); + } + + let description = GetToolSpecTool::new() + .build_collapsed_tools_description(None) + .await; + + assert!(description.contains(&format!("- {}: Concise catalog entry.", tool_name))); + assert!(!description.contains(&format!( + "- {}: Verbose description first line.", + tool_name + ))); + } + + #[tokio::test] + async fn reloading_already_unlocked_tool_returns_assistant_hint() { + let tool = GetToolSpecTool::new(); + let context = ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: vec!["WebFetch".to_string()], + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + }; + + let results = tool + .call_impl(&json!({ "tool_name": "WebFetch" }), &context) + .await; + + let results = results.expect("duplicate load should return a normal result"); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &results[0] + else { + panic!("expected regular tool result"); + }; + + assert_eq!(data["tool_name"], "WebFetch"); + assert_eq!(data["already_loaded"], true); + assert!(result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("already loaded in the current conversation")); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/core/src/agentic/tools/implementations/git_tool.rs index 6d9e27d95..3fed240d6 100644 --- a/src/crates/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/git_tool.rs @@ -3,7 +3,7 @@ //! Provides safe and convenient Git command execution functionality, reuses underlying GitService use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::service::git::{ execute_git_command, execute_git_command_raw, GitAddParams, GitCommitParams, GitDiffParams, @@ -835,6 +835,14 @@ When creating commits, use this format for the commit message: Ok(base) } + fn short_description(&self) -> String { + "Inspect and operate on the Git repository.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs index 67a0adb9f..052439f1b 100644 --- a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs @@ -427,6 +427,10 @@ impl Tool for GlobTool { "#.to_string()) } + fn short_description(&self) -> String { + "Find files by glob pattern.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs index cc6c16046..0e9a20b47 100644 --- a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs @@ -733,6 +733,10 @@ Usage: - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \{[\s\S]*?field`, use `multiline: true`"#.to_string()) } + fn short_description(&self) -> String { + "Search file contents with ripgrep-powered pattern matching.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/log_tool.rs b/src/crates/core/src/agentic/tools/implementations/log_tool.rs index 93d7bfa9e..2d4f32da8 100644 --- a/src/crates/core/src/agentic/tools/implementations/log_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/log_tool.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use tokio::fs; use tokio::io::AsyncReadExt; -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; use crate::util::errors::{BitFunError, BitFunResult}; /// LogTool - log viewing and analysis tool @@ -187,6 +187,14 @@ Usage examples: The tool will return the log content or analysis results that you can use to diagnose issues."#.to_string()) } + fn short_description(&self) -> String { + "Read and analyze log files for debugging and monitoring.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs b/src/crates/core/src/agentic/tools/implementations/ls_tool.rs index 33c0a20d8..407150613 100644 --- a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ls_tool.rs @@ -59,6 +59,10 @@ Usage: .to_string()) } + fn short_description(&self) -> String { + "List files and directories in a workspace path.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs b/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs index e419ce6c9..b7d239df5 100644 --- a/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs +++ b/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs @@ -1,7 +1,7 @@ //! Built-in MCP resource/prompt tools. use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::service::mcp::adapter::PromptAdapter; use crate::service::mcp::get_global_mcp_service; @@ -235,6 +235,14 @@ impl Tool for ListMCPResourcesTool { Ok("Lists MCP resources exposed by a connected MCP server. Use this before ReadMCPResource when you need to inspect available MCP-hosted files, docs, or structured context.".to_string()) } + fn short_description(&self) -> String { + "List MCP resources exposed by a connected MCP server.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -345,6 +353,14 @@ impl Tool for ReadMCPResourceTool { Ok("Reads a specific MCP resource by URI from a connected MCP server. Use ListMCPResources first if you do not already know the resource URI.".to_string()) } + fn short_description(&self) -> String { + "Read a specific MCP resource by URI from a connected MCP server.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -459,6 +475,14 @@ impl Tool for ListMCPPromptsTool { Ok("Lists MCP prompts exposed by a connected MCP server. Use this before GetMCPPrompt when you need reusable server-provided prompt templates.".to_string()) } + fn short_description(&self) -> String { + "List MCP prompts exposed by a connected MCP server.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -569,6 +593,15 @@ impl Tool for GetMCPPromptTool { Ok("Fetches a named MCP prompt template from a connected MCP server and renders it into plain text for the model. Pass prompt arguments when the server requires them.".to_string()) } + fn short_description(&self) -> String { + "Fetch and render a named MCP prompt template from a connected MCP server." + .to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs index c1e16ce3e..f6805711f 100644 --- a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs @@ -1,6 +1,6 @@ //! InitMiniApp tool — create a new MiniApp skeleton; AI then uses generic file tools to edit. -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; use crate::infrastructure::events::{emit_global_event, BackendEvent}; use crate::miniapp::try_get_global_miniapp_manager; use crate::miniapp::types::{ @@ -73,6 +73,14 @@ Returns app_id and the app root directory. Use the root directory and file names .to_string()) } + fn short_description(&self) -> String { + "Create a new MiniApp skeleton in the Toolbox.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 07d8e2201..6436bcb83 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -37,6 +37,7 @@ pub mod skills; pub mod task_tool; pub mod terminal_control_tool; pub mod todo_write_tool; +pub mod get_tool_spec_tool; pub mod util; pub mod web_tools; @@ -73,4 +74,5 @@ pub use skill_tool::SkillTool; pub use task_tool::TaskTool; pub use terminal_control_tool::TerminalControlTool; pub use todo_write_tool::TodoWriteTool; +pub use get_tool_spec_tool::GetToolSpecTool; pub use web_tools::{WebFetchTool, WebSearchTool}; diff --git a/src/crates/core/src/agentic/tools/implementations/playbook_tool.rs b/src/crates/core/src/agentic/tools/implementations/playbook_tool.rs index 736aa11a0..8d846660c 100644 --- a/src/crates/core/src/agentic/tools/implementations/playbook_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/playbook_tool.rs @@ -6,7 +6,7 @@ //! ControlHub. use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -258,6 +258,14 @@ Use this tool when you recognize a common task pattern — it saves planning tim )) } + fn short_description(&self) -> String { + "Get predefined step-by-step operation guides for common tasks.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs index 704d09114..0e4cd8627 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs @@ -8,7 +8,7 @@ use super::util::normalize_path; use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; use crate::agentic::core::SessionConfig; use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -323,6 +323,14 @@ Optional inputs: ) } + fn short_description(&self) -> String { + "Create, list, cancel, and delete persisted agent sessions.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -699,6 +707,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, + unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, diff --git a/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs index 4af540726..826c0201b 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs @@ -1,7 +1,7 @@ use super::util::normalize_path; use crate::agentic::persistence::PersistenceManager; use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::infrastructure::PathManager; use crate::service::session::SessionTranscriptExportOptions; @@ -152,6 +152,15 @@ Examples: ) } + fn short_description(&self) -> String { + "Export an agent session transcript with an index for targeted history reads. Use this tool when you need the history of an agent session." + .to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs index 226cc8723..b25200ef7 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs @@ -5,7 +5,7 @@ use crate::agentic::coordination::{ }; use crate::agentic::core::PromptEnvelope; use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; use crate::util::errors::{BitFunError, BitFunResult}; @@ -180,6 +180,15 @@ When overriding an existing session's agent_type, only switching between "agenti ) } + fn short_description(&self) -> String { + "Send a message to another agent session and receive the result asynchronously." + .to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index fcf5217e8..ac50fb27e 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -107,6 +107,10 @@ impl Tool for SkillTool { Ok(self.build_description_for_context(None).await) } + fn short_description(&self) -> String { + "Discover and load reusable skills for specialized workflows.".to_string() + } + async fn description_with_context( &self, context: Option<&ToolUseContext>, @@ -376,6 +380,7 @@ Use the remote project skill. session_id: None, dialog_turn_id: None, workspace: Some(workspace), + unlocked_collapsed_tools: Vec::new(), custom_data: Default::default(), computer_use_host: None, cancellation_token: None, @@ -415,6 +420,7 @@ Use the remote project skill. session_id: None, dialog_turn_id: None, workspace: Some(workspace), + unlocked_collapsed_tools: Vec::new(), custom_data: Default::default(), computer_use_host: None, cancellation_token: None, diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index f23bec7db..5d88606d7 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -510,6 +510,10 @@ impl Tool for TaskTool { Ok(self.build_description(None).await) } + fn short_description(&self) -> String { + "Delegate work to a subagent task and collect the result.".to_string() + } + async fn description_with_context( &self, context: Option<&ToolUseContext>, @@ -1686,6 +1690,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, + unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, @@ -1722,6 +1727,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, + unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, diff --git a/src/crates/core/src/agentic/tools/implementations/terminal_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/terminal_control_tool.rs index 51dc97a17..c0e9d1731 100644 --- a/src/crates/core/src/agentic/tools/implementations/terminal_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/terminal_control_tool.rs @@ -1,5 +1,5 @@ use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -41,6 +41,14 @@ The terminal_session_id is returned inside ... String { + "Interrupt or close a managed terminal session.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/todo_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/todo_write_tool.rs index e04a3dccb..accf1bf9e 100644 --- a/src/crates/core/src/agentic/tools/implementations/todo_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/todo_write_tool.rs @@ -206,6 +206,10 @@ When in doubt, use this tool. Being proactive with task management demonstrates "###.to_string()) } + fn short_description(&self) -> String { + "Create and update the session todo list.".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object", diff --git a/src/crates/core/src/agentic/tools/implementations/web_tools.rs b/src/crates/core/src/agentic/tools/implementations/web_tools.rs index 21c5c8b7c..8772f90e8 100644 --- a/src/crates/core/src/agentic/tools/implementations/web_tools.rs +++ b/src/crates/core/src/agentic/tools/implementations/web_tools.rs @@ -1,6 +1,8 @@ //! Web tool implementation - WebSearchTool and URLFetcherTool -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolResult, ToolUseContext, ValidationResult, +}; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::truncate_at_char_boundary; use async_trait::async_trait; @@ -235,6 +237,14 @@ Advanced features: ) } + fn short_description(&self) -> String { + "Search the web for up-to-date information and sources.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -467,6 +477,14 @@ Example usage: .to_string()) } + fn short_description(&self) -> String { + "Fetch content from a URL in raw, text, markdown, or JSON format.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -647,6 +665,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, + unlocked_collapsed_tools: Vec::new(), custom_data: std::collections::HashMap::new(), computer_use_host: None, cancellation_token: None, diff --git a/src/crates/core/src/agentic/tools/manifest_resolver.rs b/src/crates/core/src/agentic/tools/manifest_resolver.rs new file mode 100644 index 000000000..5a151efbb --- /dev/null +++ b/src/crates/core/src/agentic/tools/manifest_resolver.rs @@ -0,0 +1,278 @@ +use crate::agentic::agents::AgentToolPolicyOverrides; +use crate::agentic::tools::framework::{Tool, ToolExposure, ToolUseContext}; +use crate::agentic::tools::registry::{get_global_tool_registry, GET_TOOL_SPEC_TOOL_NAME}; +use crate::util::types::ToolDefinition; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +type ToolRef = Arc; + +#[derive(Debug, Clone)] +pub struct ResolvedToolManifest { + pub allowed_tool_names: Vec, + pub tool_definitions: Vec, + pub collapsed_tool_names: Vec, +} + +#[derive(Clone)] +pub struct ResolvedVisibleTools { + allowed_tool_names: Vec, + pub expanded_tools: Vec>, + collapsed_tool_names: Vec, + pub collapsed_tools: Vec>, +} + +fn build_visible_tools( + tool_snapshot: &[ToolRef], + allowed_tools: &[String], + exposure_overrides: &AgentToolPolicyOverrides, + available_tool_names: &HashSet, +) -> ResolvedVisibleTools { + let allowed_set: HashSet<&str> = allowed_tools.iter().map(String::as_str).collect(); + let mut allowed_tool_names = allowed_tools.to_vec(); + let mut expanded_tools = Vec::new(); + let mut collapsed_tool_names = Vec::new(); + let mut collapsed_tools = Vec::new(); + + for tool in tool_snapshot { + let tool_name = tool.name().to_string(); + if !available_tool_names.contains(&tool_name) || !allowed_set.contains(tool_name.as_str()) { + continue; + } + + let exposure = exposure_overrides + .get(&tool_name) + .copied() + .unwrap_or_else(|| tool.default_exposure()); + match exposure { + ToolExposure::Collapsed => { + collapsed_tool_names.push(tool_name); + collapsed_tools.push(tool.clone()); + } + ToolExposure::Expanded => expanded_tools.push(tool.clone()), + } + } + + if !collapsed_tool_names.is_empty() { + if !allowed_tool_names + .iter() + .any(|name| name == GET_TOOL_SPEC_TOOL_NAME) + { + allowed_tool_names.push(GET_TOOL_SPEC_TOOL_NAME.to_string()); + } + if let Some(tool) = tool_snapshot + .iter() + .find(|tool| tool.name() == GET_TOOL_SPEC_TOOL_NAME) + .cloned() + { + expanded_tools.push(tool); + } + } + + ResolvedVisibleTools { + allowed_tool_names, + expanded_tools, + collapsed_tool_names, + collapsed_tools, + } +} + +pub async fn resolve_visible_tools( + allowed_tools: &[String], + exposure_overrides: &AgentToolPolicyOverrides, + context: &ToolUseContext, +) -> ResolvedVisibleTools { + let registry = get_global_tool_registry(); + let tool_snapshot = { + let registry = registry.read().await; + registry.get_all_tools() + }; + + let mut available_tool_names = HashSet::new(); + for tool in &tool_snapshot { + if tool.is_available_in_context(Some(context)).await { + available_tool_names.insert(tool.name().to_string()); + } + } + + build_visible_tools( + &tool_snapshot, + allowed_tools, + exposure_overrides, + &available_tool_names, + ) +} + +pub async fn resolve_tool_manifest( + allowed_tools: &[String], + exposure_overrides: &AgentToolPolicyOverrides, + context: &ToolUseContext, +) -> ResolvedToolManifest { + let visible_tools = resolve_visible_tools(allowed_tools, exposure_overrides, context).await; + + let mut tool_definitions = + Vec::with_capacity(visible_tools.expanded_tools.len() + visible_tools.collapsed_tools.len()); + for tool in &visible_tools.expanded_tools { + let description = tool + .description_with_context(Some(context)) + .await + .unwrap_or_else(|_| format!("Tool: {}", tool.name())); + let parameters = tool.input_schema_for_model_with_context(Some(context)).await; + + tool_definitions.push(ToolDefinition { + name: tool.name().to_string(), + description, + parameters, + }); + } + + for tool in &visible_tools.collapsed_tools { + let description = format!( + "{} [This tool is collapsed. Call `GetToolSpec` first with {{\"tool_name\":\"{}\"}} before using it.]", + tool.short_description(), + tool.name() + ); + + tool_definitions.push(ToolDefinition { + name: tool.name().to_string(), + description, + parameters: json!({ + "type": "object", + "properties": {}, + "additionalProperties": true + }), + }); + } + + let tool_ordering: HashMap = [ + ("Task", 1), + ("Bash", 2), + ("TerminalControl", 3), + ("Glob", 4), + ("Grep", 5), + ("Read", 6), + ("Edit", 7), + ("Write", 8), + ("Delete", 9), + ("WebFetch", 10), + ("WebSearch", 11), + ("TodoWrite", 12), + ("Skill", 13), + ("Log", 14), + ("GetToolSpec", 15), + ("ControlHub", 16), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(); + tool_definitions.sort_by_key(|tool| tool_ordering.get(&tool.name).unwrap_or(&100)); + + ResolvedToolManifest { + allowed_tool_names: visible_tools.allowed_tool_names, + tool_definitions, + collapsed_tool_names: visible_tools.collapsed_tool_names, + } +} + +#[cfg(test)] +mod tests { + use super::resolve_tool_manifest; + use crate::agentic::agents::AgentToolPolicyOverrides; + use crate::agentic::tools::framework::{ToolExposure, ToolUseContext}; + use crate::agentic::tools::registry::GET_TOOL_SPEC_TOOL_NAME; + use crate::agentic::tools::ToolRuntimeRestrictions; + use serde_json::json; + use std::collections::HashMap; + + fn tool_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: Some("test-agent".to_string()), + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + #[tokio::test] + async fn manifest_omits_get_tool_spec_without_collapsed_tools() { + let allowed_tools = vec!["Read".to_string(), "Grep".to_string()]; + + let manifest = resolve_tool_manifest( + &allowed_tools, + &AgentToolPolicyOverrides::default(), + &tool_context(), + ) + .await; + + assert!(manifest.collapsed_tool_names.is_empty()); + assert_eq!(manifest.allowed_tool_names, allowed_tools); + assert!(!manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); + } + + #[tokio::test] + async fn manifest_adds_get_tool_spec_when_collapsed_tools_are_allowed() { + let allowed_tools = vec!["Read".to_string(), "WebFetch".to_string()]; + + let manifest = resolve_tool_manifest( + &allowed_tools, + &AgentToolPolicyOverrides::default(), + &tool_context(), + ) + .await; + + assert_eq!(manifest.collapsed_tool_names, vec!["WebFetch".to_string()]); + assert!(manifest + .allowed_tool_names + .contains(&GET_TOOL_SPEC_TOOL_NAME.to_string())); + assert!(manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "Read")); + assert!(manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "WebFetch")); + assert!(manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); + let stub = manifest + .tool_definitions + .iter() + .find(|tool| tool.name == "WebFetch") + .expect("WebFetch stub should exist"); + assert!(stub.description.contains("Call `GetToolSpec` first")); + assert_eq!(stub.parameters["type"], json!("object")); + assert_eq!(stub.parameters["additionalProperties"], json!(true)); + } + + #[tokio::test] + async fn manifest_expands_tool_when_agent_override_requests_it() { + let allowed_tools = vec!["Read".to_string(), "WebFetch".to_string()]; + let mut overrides = AgentToolPolicyOverrides::default(); + overrides.insert("WebFetch".to_string(), ToolExposure::Expanded); + + let manifest = resolve_tool_manifest(&allowed_tools, &overrides, &tool_context()).await; + + assert!(manifest.collapsed_tool_names.is_empty()); + assert!(manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "WebFetch")); + assert!(!manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); + } +} diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index 08e1d58a7..b00ef750c 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -8,6 +8,7 @@ pub mod computer_use_verification; pub mod framework; pub mod image_context; pub mod implementations; +pub mod manifest_resolver; pub mod pipeline; pub(crate) mod post_call_hooks; pub mod registry; @@ -19,6 +20,7 @@ pub use bitfun_agent_tools::input_validator; pub use framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; pub use image_context::{ImageContextData, ImageContextProvider, ImageContextProviderRef}; pub use input_validator::InputValidator; +pub use manifest_resolver::{resolve_tool_manifest, resolve_visible_tools, ResolvedToolManifest, ResolvedVisibleTools}; pub use pipeline::*; pub use registry::{ create_tool_registry, get_all_registered_tool_names, get_all_registered_tools, get_all_tools, diff --git a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs index a7884a26c..0f4b2b33f 100644 --- a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs @@ -315,6 +315,8 @@ mod tests { workspace: None, context_vars: HashMap::new(), subagent_parent_info: None, + collapsed_tools: Vec::new(), + unlocked_collapsed_tools: Vec::new(), allowed_tools: Vec::new(), runtime_tool_restrictions: Default::default(), steering_interrupt: None, diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index b973657b6..02a677e9c 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -313,6 +313,36 @@ pub struct ToolPipeline { } impl ToolPipeline { + fn validate_collapsed_tool_usage(task: &ToolTask) -> BitFunResult<()> { + let tool_name = task.tool_call.tool_name.as_str(); + if tool_name == "GetToolSpec" { + return Ok(()); + } + + if !task + .context + .collapsed_tools + .iter() + .any(|collapsed| collapsed == tool_name) + { + return Ok(()); + } + + if task + .context + .unlocked_collapsed_tools + .iter() + .any(|loaded| loaded == tool_name) + { + return Ok(()); + } + + Err(BitFunError::Validation(format!( + "Tool '{}' is collapsed. Call GetToolSpec first with {{\"tool_name\":\"{}\"}} to read its full usage instructions and input schema, then try again.", + tool_name, tool_name + ))) + } + pub fn new( tool_registry: Arc>, state_manager: Arc, @@ -781,6 +811,28 @@ impl ToolPipeline { return Err(err.into()); } + if let Err(err) = Self::validate_collapsed_tool_usage(&task) { + let error_msg = err.to_string(); + warn!("Collapsed tool usage validation failed: {}", error_msg); + + self.state_manager + .update_state( + &tool_id, + ToolExecutionState::Failed { + error: error_msg, + is_retryable: false, + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + ) + .await; + + return Err(err); + } + let tool = { let registry = self.tool_registry.read().await; registry @@ -1237,6 +1289,7 @@ impl ToolPipeline { session_id: Some(task.context.session_id.clone()), dialog_turn_id: Some(task.context.dialog_turn_id.clone()), workspace: task.context.workspace.clone(), + unlocked_collapsed_tools: task.context.unlocked_collapsed_tools.clone(), custom_data: { let mut map = HashMap::new(); @@ -1545,6 +1598,8 @@ mod tests { workspace: None, context_vars: HashMap::new(), subagent_parent_info: None, + collapsed_tools: Vec::new(), + unlocked_collapsed_tools: Vec::new(), allowed_tools: Vec::new(), runtime_tool_restrictions: ToolRuntimeRestrictions::default(), steering_interrupt: None, @@ -1619,4 +1674,31 @@ mod tests { assert!(assistant_text.contains("\"working_directory\": \"/private/tmp\"")); assert!(!assistant_text.contains("completed with error")); } + + #[test] + fn collapsed_tool_requires_tool_catalog_unlock() { + let mut task = test_tool_task("tool_1", "WebFetch"); + task.context.collapsed_tools = vec!["WebFetch".to_string()]; + + let err = ToolPipeline::validate_collapsed_tool_usage(&task) + .expect_err("collapsed tool should require GetToolSpec unlock"); + + assert!(err + .to_string() + .contains("Call GetToolSpec first with {\"tool_name\":\"WebFetch\"}")); + } + + #[test] + fn tool_catalog_rejects_reloading_already_unlocked_tool() { + let mut task = test_tool_task("tool_1", "GetToolSpec"); + task.tool_call.arguments = json!({ "tool_name": "WebFetch" }); + task.context.unlocked_collapsed_tools = vec!["WebFetch".to_string()]; + + let result = ToolPipeline::validate_collapsed_tool_usage(&task); + + assert!( + result.is_ok(), + "GetToolSpec duplicate-load validation moved into GetToolSpec itself" + ); + } } diff --git a/src/crates/core/src/agentic/tools/pipeline/types.rs b/src/crates/core/src/agentic/tools/pipeline/types.rs index 98396784f..2944d2bd2 100644 --- a/src/crates/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/core/src/agentic/tools/pipeline/types.rs @@ -69,6 +69,8 @@ pub struct ToolExecutionContext { pub workspace: Option, pub context_vars: HashMap, pub subagent_parent_info: Option, + pub collapsed_tools: Vec, + pub unlocked_collapsed_tools: Vec, /// Allowed tools list (whitelist) /// If empty, allow all registered tools /// If not empty, only allow tools in the list to be executed diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 1d9c5c78f..c8bfc2e51 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -1,6 +1,6 @@ //! Tool registry -use crate::agentic::tools::framework::{DynamicToolInfo, Tool}; +use crate::agentic::tools::framework::{DynamicToolInfo, Tool, ToolExposure}; use crate::agentic::tools::implementations::*; use crate::util::errors::BitFunResult; use bitfun_agent_tools::{ @@ -14,6 +14,9 @@ use std::sync::Arc; type ToolRef = Arc; type ToolDecoratorRef = Arc>; +pub const GET_TOOL_SPEC_TOOL_NAME: &str = "GetToolSpec"; + +#[derive(Debug, Clone)] struct SnapshotToolDecorator; impl ToolDecorator for SnapshotToolDecorator { @@ -138,31 +141,42 @@ impl ToolRegistry { self.register_tool(Arc::new(FileEditTool::new())); self.register_tool(Arc::new(DeleteFileTool::new())); self.register_tool(Arc::new(BashTool::new())); + // TaskTool, execute subagent + self.register_tool(Arc::new(TaskTool::new())); + // Skill tool + self.register_tool(Arc::new(SkillTool::new())); + // AskUserQuestion tool + self.register_tool(Arc::new(AskUserQuestionTool::new())); + // TodoWrite tool + self.register_tool(Arc::new(TodoWriteTool::new())); + // CreatePlan tool + self.register_tool(Arc::new(CreatePlanTool::new())); + // Code review submit tool + self.register_tool(Arc::new(CodeReviewTool::new())); + + // GetToolSpec — the discovery entry point for collapsed tools. + self.register_tool(Arc::new(GetToolSpecTool::new())); + + // GetFileDiff tool + self.register_tool(Arc::new(GetFileDiffTool::new())); + // Log tool (debug mode only) + self.register_tool(Arc::new(LogTool::new())); + // TerminalControl is now accessible via ControlHub's "terminal" domain, // but we keep it registered separately for backward compatibility. self.register_tool(Arc::new(TerminalControlTool::new())); + self.register_tool(Arc::new(SessionControlTool::new())); self.register_tool(Arc::new(SessionMessageTool::new())); self.register_tool(Arc::new(SessionHistoryTool::new())); - // TodoWrite tool - self.register_tool(Arc::new(TodoWriteTool::new())); - // Cron scheduled jobs tool self.register_tool(Arc::new(CronTool::new())); - // TaskTool, execute subagent - self.register_tool(Arc::new(TaskTool::new())); - - // Skill tool - self.register_tool(Arc::new(SkillTool::new())); - - // AskUserQuestion tool - self.register_tool(Arc::new(AskUserQuestionTool::new())); - // Web tool self.register_tool(Arc::new(WebSearchTool::new())); self.register_tool(Arc::new(WebFetchTool::new())); + self.register_tool(Arc::new(ListMCPResourcesTool::new())); self.register_tool(Arc::new(ReadMCPResourceTool::new())); self.register_tool(Arc::new(ListMCPPromptsTool::new())); @@ -170,21 +184,9 @@ impl ToolRegistry { self.register_tool(Arc::new(GenerativeUITool::new())); - // GetFileDiff tool - self.register_tool(Arc::new(GetFileDiffTool::new())); - - // Log tool - self.register_tool(Arc::new(LogTool::new())); - // Git version control tool self.register_tool(Arc::new(GitTool::new())); - // CreatePlan tool - self.register_tool(Arc::new(CreatePlanTool::new())); - - // Code review submit tool - self.register_tool(Arc::new(CodeReviewTool::new())); - // MiniApp Agent tool (single InitMiniApp) self.register_tool(Arc::new(InitMiniAppTool::new())); @@ -211,6 +213,19 @@ impl ToolRegistry { self.inner.get_dynamic_tool_info(name) } + pub fn is_tool_collapsed(&self, name: &str) -> bool { + self.inner + .get_tool(name) + .is_some_and(|tool| tool.default_exposure() == ToolExposure::Collapsed) + } + + pub fn get_collapsed_tool_names(&self) -> Vec { + self.get_tool_names() + .into_iter() + .filter(|name| self.is_tool_collapsed(name)) + .collect() + } + /// Get all tool names pub fn get_tool_names(&self) -> Vec { self.inner.get_tool_names() @@ -224,6 +239,7 @@ impl ToolRegistry { ); self.inner.get_all_tools() } + } #[async_trait::async_trait] @@ -291,6 +307,10 @@ mod tests { Ok("dynamic test tool".to_string()) } + fn short_description(&self) -> String { + "dynamic test tool".to_string() + } + fn input_schema(&self) -> Value { json!({ "type": "object" }) } @@ -383,15 +403,20 @@ mod tests { "Edit", "Delete", "Bash", + "Task", + "Skill", + "AskUserQuestion", + "TodoWrite", + "CreatePlan", + "submit_code_review", + "GetToolSpec", + "GetFileDiff", + "Log", "TerminalControl", "SessionControl", "SessionMessage", "SessionHistory", - "TodoWrite", "Cron", - "Task", - "Skill", - "AskUserQuestion", "WebSearch", "WebFetch", "ListMCPResources", @@ -399,11 +424,7 @@ mod tests { "ListMCPPrompts", "GetMCPPrompt", "GenerativeUI", - "GetFileDiff", - "Log", "Git", - "CreatePlan", - "submit_code_review", "InitMiniApp", "ControlHub", "ComputerUse", @@ -427,6 +448,16 @@ mod tests { ); } + #[test] + fn registry_marks_collapsed_tools_for_get_tool_spec() { + let registry = create_tool_registry(); + + assert!(registry.is_tool_collapsed("WebFetch")); + assert!(registry.is_tool_collapsed("GetFileDiff")); + assert!(!registry.is_tool_collapsed("GetToolSpec")); + assert!(registry.is_tool_collapsed("Git")); + } + #[tokio::test] async fn registry_preserves_readonly_tool_manifest_for_owner_migration() { let readonly_names = super::get_readonly_tools() @@ -443,10 +474,15 @@ mod tests { "Read", "Glob", "Grep", - "SessionHistory", - "TodoWrite", "Skill", "AskUserQuestion", + "TodoWrite", + "CreatePlan", + "submit_code_review", + "GetToolSpec", + "GetFileDiff", + "Log", + "SessionHistory", "WebSearch", "WebFetch", "ListMCPResources", @@ -454,10 +490,6 @@ mod tests { "ListMCPPrompts", "GetMCPPrompt", "GenerativeUI", - "GetFileDiff", - "Log", - "CreatePlan", - "submit_code_review", "Playbook", ], "readonly tool manifest must stay stable before moving registry ownership" diff --git a/src/crates/core/src/service/mcp/adapter/tool.rs b/src/crates/core/src/service/mcp/adapter/tool.rs index f07f8a32e..d3ce8bdfe 100644 --- a/src/crates/core/src/service/mcp/adapter/tool.rs +++ b/src/crates/core/src/service/mcp/adapter/tool.rs @@ -68,6 +68,16 @@ impl Tool for MCPToolWrapper { Ok(self.descriptor.description.clone()) } + fn short_description(&self) -> String { + let summary = self + .mcp_tool + .description + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("MCP tool"); + format!("{} ({})", summary, self.server_name) + } + fn input_schema(&self) -> Value { self.mcp_tool.input_schema.clone() } diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index bb9590b44..987bb5fb8 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -1,4 +1,6 @@ -use crate::agentic::tools::framework::{DynamicToolInfo, Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{ + DynamicToolInfo, Tool, ToolExposure, ToolResult, ToolUseContext, +}; use crate::agentic::tools::registry::ToolRegistry; use crate::service::remote_ssh::workspace_state::is_remote_path; use crate::service::snapshot::service::SnapshotService; @@ -364,6 +366,14 @@ impl Tool for WrappedTool { self.original_tool.description_with_context(context).await } + fn short_description(&self) -> String { + self.original_tool.short_description() + } + + fn default_exposure(&self) -> ToolExposure { + self.original_tool.default_exposure() + } + fn input_schema(&self) -> Value { self.original_tool.input_schema() }