diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 99a12e30..04bb656a 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -42,7 +42,7 @@ use crate::seam_manager::{SeamConfig, SeamManager}; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; use crate::tools::spec::RuntimeToolServices; -use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult, required_str}; +use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult}; use crate::tools::subagent::{ Mailbox, SharedSubAgentManager, SubAgentRuntime, SubAgentType, new_shared_subagent_manager, }; @@ -398,14 +398,6 @@ pub(crate) const TOOL_CALL_START_MARKERS: [&str; 5] = [ "", ]; -const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel"; -const REQUEST_USER_INPUT_NAME: &str = "request_user_input"; -const CODE_EXECUTION_TOOL_NAME: &str = "code_execution"; -const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825"; -const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex"; -const TOOL_SEARCH_REGEX_TYPE: &str = "tool_search_tool_regex_20251119"; -const TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25"; -const TOOL_SEARCH_BM25_TYPE: &str = "tool_search_tool_bm25_20251119"; pub(crate) const TOOL_CALL_END_MARKERS: [&str; 5] = [ "[/TOOL_CALL]", "", @@ -463,413 +455,6 @@ pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> St output } -/// Compute the tool input that should be reported when a tool's stream block -/// closes (`ContentBlockStop`). Prefers the parsed `input_buffer` over the -/// initial `input` placeholder so a `ToolCallStarted` event never carries a -/// stale `{}` when args were actually streamed in via `InputJsonDelta`. -/// -/// Order of preference: -/// 1. `input_buffer` parses cleanly → use that. -/// 2. `input_buffer` is empty → fall back to `input` (model embedded args -/// directly in the `ContentBlockStart` frame and sent no deltas). -/// 3. `input_buffer` non-empty but unparseable → fall back to `input` -/// (the per-delta parser has already mirrored the most recent valid -/// partial parse into `tool_state.input`). -fn is_tool_search_tool(name: &str) -> bool { - matches!(name, TOOL_SEARCH_REGEX_NAME | TOOL_SEARCH_BM25_NAME) -} - -fn should_default_defer_tool(name: &str, mode: AppMode) -> bool { - if mode == AppMode::Yolo { - return false; - } - - // Shell tools are kept active in Agent so the model can run verification - // commands (build/test/git/cargo) without first having to discover the - // tool through ToolSearch. Plan mode never registers shell tools. - let always_loaded_in_action_modes = matches!(mode, AppMode::Agent) - && matches!( - name, - "exec_shell" - | "exec_shell_wait" - | "exec_shell_interact" - | "exec_wait" - | "exec_interact" - ); - if always_loaded_in_action_modes { - return false; - } - - !matches!( - name, - "read_file" - | "list_dir" - | "grep_files" - | "file_search" - | "diagnostics" - | "rlm" - | "recall_archive" - | MULTI_TOOL_PARALLEL_NAME - | "update_plan" - | "checklist_write" - | "todo_write" - | "task_create" - | "task_list" - | "task_read" - | "task_gate_run" - | "task_shell_start" - | "task_shell_wait" - | "github_issue_context" - | "github_pr_context" - | REQUEST_USER_INPUT_NAME - ) -} - -fn ensure_advanced_tooling(catalog: &mut Vec) { - if !catalog.iter().any(|t| t.name == CODE_EXECUTION_TOOL_NAME) { - catalog.push(Tool { - tool_type: Some(CODE_EXECUTION_TOOL_TYPE.to_string()), - name: CODE_EXECUTION_TOOL_NAME.to_string(), - description: "Execute Python code in a local sandboxed runtime and return stdout/stderr/return_code as JSON.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "code": { "type": "string", "description": "Python source code to execute." } - }, - "required": ["code"] - }), - allowed_callers: Some(vec!["direct".to_string()]), - defer_loading: Some(false), - input_examples: None, - strict: None, - cache_control: None, - }); - } - - if !catalog.iter().any(|t| t.name == TOOL_SEARCH_REGEX_NAME) { - catalog.push(Tool { - tool_type: Some(TOOL_SEARCH_REGEX_TYPE.to_string()), - name: TOOL_SEARCH_REGEX_NAME.to_string(), - description: "Search deferred tool definitions using a regex query and return matching tool references.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." } - }, - "required": ["query"] - }), - allowed_callers: Some(vec!["direct".to_string()]), - defer_loading: Some(false), - input_examples: None, - strict: None, - cache_control: None, - }); - } - - if !catalog.iter().any(|t| t.name == TOOL_SEARCH_BM25_NAME) { - catalog.push(Tool { - tool_type: Some(TOOL_SEARCH_BM25_TYPE.to_string()), - name: TOOL_SEARCH_BM25_NAME.to_string(), - description: "Search deferred tool definitions using natural-language matching and return matching tool references.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "query": { "type": "string", "description": "Natural language query for tool discovery." } - }, - "required": ["query"] - }), - allowed_callers: Some(vec!["direct".to_string()]), - defer_loading: Some(false), - input_examples: None, - strict: None, - cache_control: None, - }); - } -} - -fn initial_active_tools(catalog: &[Tool]) -> std::collections::HashSet { - let mut active = std::collections::HashSet::new(); - for tool in catalog { - if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) { - active.insert(tool.name.clone()); - } - } - if active.is_empty() - && !catalog.is_empty() - && let Some(first) = catalog.first() - { - active.insert(first.name.clone()); - } - active -} - -fn active_tool_list_from_catalog( - catalog: &[Tool], - active: &std::collections::HashSet, -) -> Vec { - catalog - .iter() - .filter(|tool| active.contains(&tool.name)) - .cloned() - .collect() -} - -fn active_tools_for_step( - catalog: &[Tool], - active: &std::collections::HashSet, - force_update_plan: bool, -) -> Vec { - // DeepSeek reasoning models reject explicit named tool_choice forcing here, so for - // obvious quick-plan asks we narrow the first-step tool surface to update_plan instead. - if force_update_plan { - let forced: Vec<_> = catalog - .iter() - .filter(|tool| tool.name == "update_plan") - .cloned() - .collect(); - if !forced.is_empty() { - return forced; - } - } - - active_tool_list_from_catalog(catalog, active) -} - -fn tool_search_haystack(tool: &Tool) -> String { - format!( - "{}\n{}\n{}", - tool.name.to_lowercase(), - tool.description.to_lowercase(), - tool.input_schema.to_string().to_lowercase() - ) -} - -fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result, ToolError> { - let regex = regex::Regex::new(query) - .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?; - - let mut matches = Vec::new(); - for tool in catalog { - if is_tool_search_tool(&tool.name) { - continue; - } - let hay = tool_search_haystack(tool); - if regex.is_match(&hay) { - matches.push(tool.name.clone()); - } - if matches.len() >= 5 { - break; - } - } - Ok(matches) -} - -fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec { - let terms: Vec = query - .split_whitespace() - .map(|term| term.trim().to_lowercase()) - .filter(|term| !term.is_empty()) - .collect(); - if terms.is_empty() { - return Vec::new(); - } - - let mut scored: Vec<(i64, String)> = Vec::new(); - for tool in catalog { - if is_tool_search_tool(&tool.name) { - continue; - } - let hay = tool_search_haystack(tool); - let mut score = 0i64; - for term in &terms { - if hay.contains(term) { - score += 1; - } - if tool.name.to_lowercase().contains(term) { - score += 2; - } - } - if score > 0 { - scored.push((score, tool.name.clone())); - } - } - scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); - scored.into_iter().take(5).map(|(_, name)| name).collect() -} - -fn edit_distance(a: &str, b: &str) -> usize { - if a == b { - return 0; - } - if a.is_empty() { - return b.chars().count(); - } - if b.is_empty() { - return a.chars().count(); - } - - let b_chars: Vec = b.chars().collect(); - let mut prev: Vec = (0..=b_chars.len()).collect(); - let mut curr = vec![0usize; b_chars.len() + 1]; - - for (i, a_ch) in a.chars().enumerate() { - curr[0] = i + 1; - for (j, b_ch) in b_chars.iter().enumerate() { - let cost = if a_ch == *b_ch { 0 } else { 1 }; - let delete = prev[j + 1] + 1; - let insert = curr[j] + 1; - let substitute = prev[j] + cost; - curr[j + 1] = delete.min(insert).min(substitute); - } - std::mem::swap(&mut prev, &mut curr); - } - - prev[b_chars.len()] -} - -fn suggest_tool_names(catalog: &[Tool], requested: &str, limit: usize) -> Vec { - let requested = requested.trim().to_ascii_lowercase(); - if requested.is_empty() || limit == 0 { - return Vec::new(); - } - - let mut candidates: Vec<(u8, usize, String)> = Vec::new(); - for tool in catalog { - let candidate = tool.name.to_ascii_lowercase(); - let prefix_match = candidate.starts_with(&requested) || requested.starts_with(&candidate); - let contains_match = candidate.contains(&requested) || requested.contains(&candidate); - let distance = edit_distance(&candidate, &requested); - let close_typo = distance <= 3; - - if !(prefix_match || contains_match || close_typo) { - continue; - } - - let rank = if prefix_match { - 0 - } else if contains_match { - 1 - } else { - 2 - }; - candidates.push((rank, distance, tool.name.clone())); - } - - candidates.sort_by(|a, b| { - a.0.cmp(&b.0) - .then_with(|| a.1.cmp(&b.1)) - .then_with(|| a.2.cmp(&b.2)) - }); - candidates.dedup_by(|a, b| a.2 == b.2); - candidates - .into_iter() - .take(limit) - .map(|(_, _, name)| name) - .collect() -} - -fn missing_tool_error_message(tool_name: &str, catalog: &[Tool]) -> String { - let suggestions = suggest_tool_names(catalog, tool_name, 3); - if suggestions.is_empty() { - return format!( - "Tool '{tool_name}' is not available in the current tool catalog. \ - Verify mode/feature flags, or use {TOOL_SEARCH_BM25_NAME} with a short query." - ); - } - - format!( - "Tool '{tool_name}' is not available in the current tool catalog. \ - Did you mean: {}? You can also use {TOOL_SEARCH_BM25_NAME} to discover tools.", - suggestions.join(", ") - ) -} - -fn maybe_activate_requested_deferred_tool( - tool_name: &str, - catalog: &[Tool], - active_tools: &mut std::collections::HashSet, -) -> bool { - let Some(def) = catalog.iter().find(|def| def.name == tool_name) else { - return false; - }; - - if !def.defer_loading.unwrap_or(false) || active_tools.contains(tool_name) { - return false; - } - - active_tools.insert(tool_name.to_string()) -} - -fn execute_tool_search( - tool_name: &str, - input: &serde_json::Value, - catalog: &[Tool], - active_tools: &mut std::collections::HashSet, -) -> Result { - let query = required_str(input, "query")?; - let discovered = if tool_name == TOOL_SEARCH_REGEX_NAME { - discover_tools_with_regex(catalog, query)? - } else { - discover_tools_with_bm25_like(catalog, query) - }; - - for name in &discovered { - active_tools.insert(name.clone()); - } - - let references = discovered - .iter() - .map(|name| json!({"type": "tool_reference", "tool_name": name})) - .collect::>(); - - let payload = json!({ - "type": "tool_search_tool_search_result", - "tool_references": references, - }); - - Ok(ToolResult { - content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), - success: true, - metadata: Some(json!({ - "tool_references": discovered, - })), - }) -} - -async fn execute_code_execution_tool( - input: &serde_json::Value, - workspace: &std::path::Path, -) -> Result { - let code = required_str(input, "code")?; - let mut cmd = tokio::process::Command::new("python3"); - cmd.arg("-c"); - cmd.arg(code); - cmd.current_dir(workspace); - - let output = tokio::time::timeout(Duration::from_secs(120), cmd.output()) - .await - .map_err(|_| ToolError::Timeout { seconds: 120 }) - .and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?; - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let return_code = output.status.code().unwrap_or(-1); - let success = output.status.success(); - let payload = json!({ - "type": "code_execution_result", - "stdout": stdout, - "stderr": stderr, - "return_code": return_code, - "content": [], - }); - - Ok(ToolResult { - content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), - success, - metadata: Some(payload), - }) -} - fn caller_type_for_tool_use(caller: Option<&ToolCaller>) -> &str { caller.map_or("direct", |c| c.caller_type.as_str()) } @@ -2878,6 +2463,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle { mod approval; mod capacity_flow; mod dispatch; +mod tool_catalog; mod turn_loop; use self::approval::{ApprovalDecision, ApprovalResult, UserInputDecision}; @@ -2887,6 +2473,14 @@ use self::dispatch::{ mcp_tool_is_read_only, parse_parallel_tool_calls, parse_tool_input, should_force_update_plan_first, should_parallelize_tool_batch, should_stop_after_plan_tool, }; +#[cfg(test)] +use self::tool_catalog::TOOL_SEARCH_BM25_NAME; +use self::tool_catalog::{ + CODE_EXECUTION_TOOL_NAME, MULTI_TOOL_PARALLEL_NAME, REQUEST_USER_INPUT_NAME, + active_tools_for_step, ensure_advanced_tooling, execute_code_execution_tool, + execute_tool_search, initial_active_tools, is_tool_search_tool, + maybe_activate_requested_deferred_tool, missing_tool_error_message, should_default_defer_tool, +}; #[cfg(test)] mod tests; diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs new file mode 100644 index 00000000..011d632e --- /dev/null +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -0,0 +1,418 @@ +//! Deferred tool catalog and built-in advanced tool helpers. +//! +//! The streaming turn loop owns when tools are offered or executed. This module +//! owns the catalog-level policy around deferred loading, tool search, missing +//! tool suggestions, and the small set of built-in advanced tools that are not +//! registered by the normal runtime tool registry. + +use std::collections::HashSet; +use std::path::Path; +use std::time::Duration; + +use serde_json::json; + +use crate::models::Tool; +use crate::tools::spec::{ToolError, ToolResult, required_str}; +use crate::tui::app::AppMode; + +pub(super) const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel"; +pub(super) const REQUEST_USER_INPUT_NAME: &str = "request_user_input"; +pub(super) const CODE_EXECUTION_TOOL_NAME: &str = "code_execution"; +const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825"; +const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex"; +const TOOL_SEARCH_REGEX_TYPE: &str = "tool_search_tool_regex_20251119"; +pub(super) const TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25"; +const TOOL_SEARCH_BM25_TYPE: &str = "tool_search_tool_bm25_20251119"; + +pub(super) fn is_tool_search_tool(name: &str) -> bool { + matches!(name, TOOL_SEARCH_REGEX_NAME | TOOL_SEARCH_BM25_NAME) +} + +pub(super) fn should_default_defer_tool(name: &str, mode: AppMode) -> bool { + if mode == AppMode::Yolo { + return false; + } + + // Shell tools are kept active in Agent so the model can run verification + // commands (build/test/git/cargo) without first having to discover the + // tool through ToolSearch. Plan mode never registers shell tools. + let always_loaded_in_action_modes = matches!(mode, AppMode::Agent) + && matches!( + name, + "exec_shell" + | "exec_shell_wait" + | "exec_shell_interact" + | "exec_wait" + | "exec_interact" + ); + if always_loaded_in_action_modes { + return false; + } + + !matches!( + name, + "read_file" + | "list_dir" + | "grep_files" + | "file_search" + | "diagnostics" + | "rlm" + | "recall_archive" + | MULTI_TOOL_PARALLEL_NAME + | "update_plan" + | "checklist_write" + | "todo_write" + | "task_create" + | "task_list" + | "task_read" + | "task_gate_run" + | "task_shell_start" + | "task_shell_wait" + | "github_issue_context" + | "github_pr_context" + | REQUEST_USER_INPUT_NAME + ) +} + +pub(super) fn ensure_advanced_tooling(catalog: &mut Vec) { + if !catalog.iter().any(|t| t.name == CODE_EXECUTION_TOOL_NAME) { + catalog.push(Tool { + tool_type: Some(CODE_EXECUTION_TOOL_TYPE.to_string()), + name: CODE_EXECUTION_TOOL_NAME.to_string(), + description: "Execute Python code in a local sandboxed runtime and return stdout/stderr/return_code as JSON.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "code": { "type": "string", "description": "Python source code to execute." } + }, + "required": ["code"] + }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); + } + + if !catalog.iter().any(|t| t.name == TOOL_SEARCH_REGEX_NAME) { + catalog.push(Tool { + tool_type: Some(TOOL_SEARCH_REGEX_TYPE.to_string()), + name: TOOL_SEARCH_REGEX_NAME.to_string(), + description: "Search deferred tool definitions using a regex query and return matching tool references.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." } + }, + "required": ["query"] + }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); + } + + if !catalog.iter().any(|t| t.name == TOOL_SEARCH_BM25_NAME) { + catalog.push(Tool { + tool_type: Some(TOOL_SEARCH_BM25_TYPE.to_string()), + name: TOOL_SEARCH_BM25_NAME.to_string(), + description: "Search deferred tool definitions using natural-language matching and return matching tool references.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Natural language query for tool discovery." } + }, + "required": ["query"] + }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); + } +} + +pub(super) fn initial_active_tools(catalog: &[Tool]) -> HashSet { + let mut active = HashSet::new(); + for tool in catalog { + if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) { + active.insert(tool.name.clone()); + } + } + if active.is_empty() + && !catalog.is_empty() + && let Some(first) = catalog.first() + { + active.insert(first.name.clone()); + } + active +} + +fn active_tool_list_from_catalog(catalog: &[Tool], active: &HashSet) -> Vec { + catalog + .iter() + .filter(|tool| active.contains(&tool.name)) + .cloned() + .collect() +} + +pub(super) fn active_tools_for_step( + catalog: &[Tool], + active: &HashSet, + force_update_plan: bool, +) -> Vec { + // DeepSeek reasoning models reject explicit named tool_choice forcing here, + // so for obvious quick-plan asks we narrow the first-step tool surface to + // update_plan instead. + if force_update_plan { + let forced: Vec<_> = catalog + .iter() + .filter(|tool| tool.name == "update_plan") + .cloned() + .collect(); + if !forced.is_empty() { + return forced; + } + } + + active_tool_list_from_catalog(catalog, active) +} + +fn tool_search_haystack(tool: &Tool) -> String { + format!( + "{}\n{}\n{}", + tool.name.to_lowercase(), + tool.description.to_lowercase(), + tool.input_schema.to_string().to_lowercase() + ) +} + +fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result, ToolError> { + let regex = regex::Regex::new(query) + .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?; + + let mut matches = Vec::new(); + for tool in catalog { + if is_tool_search_tool(&tool.name) { + continue; + } + let hay = tool_search_haystack(tool); + if regex.is_match(&hay) { + matches.push(tool.name.clone()); + } + if matches.len() >= 5 { + break; + } + } + Ok(matches) +} + +fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec { + let terms: Vec = query + .split_whitespace() + .map(|term| term.trim().to_lowercase()) + .filter(|term| !term.is_empty()) + .collect(); + if terms.is_empty() { + return Vec::new(); + } + + let mut scored: Vec<(i64, String)> = Vec::new(); + for tool in catalog { + if is_tool_search_tool(&tool.name) { + continue; + } + let hay = tool_search_haystack(tool); + let mut score = 0i64; + for term in &terms { + if hay.contains(term) { + score += 1; + } + if tool.name.to_lowercase().contains(term) { + score += 2; + } + } + if score > 0 { + scored.push((score, tool.name.clone())); + } + } + scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + scored.into_iter().take(5).map(|(_, name)| name).collect() +} + +fn edit_distance(a: &str, b: &str) -> usize { + if a == b { + return 0; + } + if a.is_empty() { + return b.chars().count(); + } + if b.is_empty() { + return a.chars().count(); + } + + let b_chars: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b_chars.len()).collect(); + let mut curr = vec![0usize; b_chars.len() + 1]; + + for (i, a_ch) in a.chars().enumerate() { + curr[0] = i + 1; + for (j, b_ch) in b_chars.iter().enumerate() { + let cost = if a_ch == *b_ch { 0 } else { 1 }; + let delete = prev[j + 1] + 1; + let insert = curr[j] + 1; + let substitute = prev[j] + cost; + curr[j + 1] = delete.min(insert).min(substitute); + } + std::mem::swap(&mut prev, &mut curr); + } + + prev[b_chars.len()] +} + +fn suggest_tool_names(catalog: &[Tool], requested: &str, limit: usize) -> Vec { + let requested = requested.trim().to_ascii_lowercase(); + if requested.is_empty() || limit == 0 { + return Vec::new(); + } + + let mut candidates: Vec<(u8, usize, String)> = Vec::new(); + for tool in catalog { + let candidate = tool.name.to_ascii_lowercase(); + let prefix_match = candidate.starts_with(&requested) || requested.starts_with(&candidate); + let contains_match = candidate.contains(&requested) || requested.contains(&candidate); + let distance = edit_distance(&candidate, &requested); + let close_typo = distance <= 3; + + if !(prefix_match || contains_match || close_typo) { + continue; + } + + let rank = if prefix_match { + 0 + } else if contains_match { + 1 + } else { + 2 + }; + candidates.push((rank, distance, tool.name.clone())); + } + + candidates.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + candidates.dedup_by(|a, b| a.2 == b.2); + candidates + .into_iter() + .take(limit) + .map(|(_, _, name)| name) + .collect() +} + +pub(super) fn missing_tool_error_message(tool_name: &str, catalog: &[Tool]) -> String { + let suggestions = suggest_tool_names(catalog, tool_name, 3); + if suggestions.is_empty() { + return format!( + "Tool '{tool_name}' is not available in the current tool catalog. \ + Verify mode/feature flags, or use {TOOL_SEARCH_BM25_NAME} with a short query." + ); + } + + format!( + "Tool '{tool_name}' is not available in the current tool catalog. \ + Did you mean: {}? You can also use {TOOL_SEARCH_BM25_NAME} to discover tools.", + suggestions.join(", ") + ) +} + +pub(super) fn maybe_activate_requested_deferred_tool( + tool_name: &str, + catalog: &[Tool], + active_tools: &mut HashSet, +) -> bool { + let Some(def) = catalog.iter().find(|def| def.name == tool_name) else { + return false; + }; + + if !def.defer_loading.unwrap_or(false) || active_tools.contains(tool_name) { + return false; + } + + active_tools.insert(tool_name.to_string()) +} + +pub(super) fn execute_tool_search( + tool_name: &str, + input: &serde_json::Value, + catalog: &[Tool], + active_tools: &mut HashSet, +) -> Result { + let query = required_str(input, "query")?; + let discovered = if tool_name == TOOL_SEARCH_REGEX_NAME { + discover_tools_with_regex(catalog, query)? + } else { + discover_tools_with_bm25_like(catalog, query) + }; + + for name in &discovered { + active_tools.insert(name.clone()); + } + + let references = discovered + .iter() + .map(|name| json!({"type": "tool_reference", "tool_name": name})) + .collect::>(); + + let payload = json!({ + "type": "tool_search_tool_search_result", + "tool_references": references, + }); + + Ok(ToolResult { + content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), + success: true, + metadata: Some(json!({ + "tool_references": discovered, + })), + }) +} + +pub(super) async fn execute_code_execution_tool( + input: &serde_json::Value, + workspace: &Path, +) -> Result { + let code = required_str(input, "code")?; + let mut cmd = tokio::process::Command::new("python3"); + cmd.arg("-c"); + cmd.arg(code); + cmd.current_dir(workspace); + + let output = tokio::time::timeout(Duration::from_secs(120), cmd.output()) + .await + .map_err(|_| ToolError::Timeout { seconds: 120 }) + .and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let return_code = output.status.code().unwrap_or(-1); + let success = output.status.success(); + let payload = json!({ + "type": "code_execution_result", + "stdout": stdout, + "stderr": stderr, + "return_code": return_code, + "content": [], + }); + + Ok(ToolResult { + content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), + success, + metadata: Some(payload), + }) +}