refactor(engine): extract tool catalog helpers
This commit is contained in:
+10
-416
@@ -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] = [
|
||||
"<function_calls>",
|
||||
];
|
||||
|
||||
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]",
|
||||
"</deepseek: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<Tool>) {
|
||||
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<String> {
|
||||
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<String>,
|
||||
) -> Vec<Tool> {
|
||||
catalog
|
||||
.iter()
|
||||
.filter(|tool| active.contains(&tool.name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn active_tools_for_step(
|
||||
catalog: &[Tool],
|
||||
active: &std::collections::HashSet<String>,
|
||||
force_update_plan: bool,
|
||||
) -> Vec<Tool> {
|
||||
// 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<Vec<String>, 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<String> {
|
||||
let terms: Vec<String> = 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<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (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<String> {
|
||||
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<String>,
|
||||
) -> 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<String>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<ToolResult, ToolError> {
|
||||
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;
|
||||
|
||||
@@ -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<Tool>) {
|
||||
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<String> {
|
||||
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<String>) -> Vec<Tool> {
|
||||
catalog
|
||||
.iter()
|
||||
.filter(|tool| active.contains(&tool.name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn active_tools_for_step(
|
||||
catalog: &[Tool],
|
||||
active: &HashSet<String>,
|
||||
force_update_plan: bool,
|
||||
) -> Vec<Tool> {
|
||||
// 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<Vec<String>, 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<String> {
|
||||
let terms: Vec<String> = 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<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (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<String> {
|
||||
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<String>,
|
||||
) -> 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<String>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<ToolResult, ToolError> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user