use super::{Capability, CapabilityStatus, ToolDefinitionHook};
use crate::tool_types::{DeferrablePolicy, ToolDefinition, ToolHints};
use crate::tools::{Tool, ToolExecutionResult};
use crate::traits::ToolContext;
use async_trait::async_trait;
use serde_json::{Value, json};
use std::sync::Arc;
pub use super::openai_tool_search::DEFAULT_TOOL_SEARCH_THRESHOLD;
pub const TOOL_SEARCH_CAPABILITY_ID: &str = "tool_search";
pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
const MAX_SEARCH_RESULTS: usize = 12;
const SYSTEM_PROMPT: &str = "Many of your tools are loaded lazily to save context: \
you can see their names and descriptions, but their parameter schemas are hidden \
until you ask for them. Before calling a tool whose parameters you have not yet \
loaded, call `tool_search` with a short query describing what you need (for example \
\"read file\" or \"send email\"). It returns the matching tools with their full JSON \
parameter schemas. Then call the tool with correct arguments. Frequently used tools \
keep their full schemas and do not need to be searched for.";
pub struct ToolSearchCapability {
threshold: usize,
}
impl ToolSearchCapability {
pub fn new() -> Self {
Self {
threshold: DEFAULT_TOOL_SEARCH_THRESHOLD,
}
}
pub fn with_threshold(threshold: usize) -> Self {
Self { threshold }
}
}
impl Default for ToolSearchCapability {
fn default() -> Self {
Self::new()
}
}
impl Capability for ToolSearchCapability {
fn id(&self) -> &str {
TOOL_SEARCH_CAPABILITY_ID
}
fn name(&self) -> &str {
"Tool Search"
}
fn description(&self) -> &str {
"Provider-agnostic deferred tool loading. Hides tool parameter schemas \
until the model loads them via the tool_search tool, reducing token \
usage for agents with many tools. Works with any model."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn category(&self) -> Option<&str> {
Some("Optimization")
}
fn system_prompt_addition(&self) -> Option<&str> {
Some(SYSTEM_PROMPT)
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![Box::new(ToolSearchTool)]
}
fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
vec![Arc::new(DeferSchemaHook {
threshold: self.threshold,
})]
}
fn tool_definition_hooks_with_config(
&self,
config: &Value,
) -> Vec<Arc<dyn ToolDefinitionHook>> {
let threshold = config
.get("threshold")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.unwrap_or(self.threshold);
vec![Arc::new(DeferSchemaHook { threshold })]
}
}
fn deferred_stub_schema(tool_name: &str) -> Value {
json!({
"type": "object",
"description": format!(
"Parameter schema hidden to save context. Call tool_search with query \"{tool_name}\" to load the full schema before using this tool."
),
"additionalProperties": true,
})
}
pub(crate) struct DeferSchemaHook {
threshold: usize,
}
impl ToolDefinitionHook for DeferSchemaHook {
fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
if tools.len() < self.threshold {
return tools;
}
tools
.into_iter()
.map(|tool| {
if tool.name() == TOOL_SEARCH_TOOL_NAME
|| matches!(tool.deferrable(), DeferrablePolicy::Never)
{
return tool;
}
strip_parameters(tool)
})
.collect()
}
fn applies_with_native_tool_search(&self) -> bool {
false
}
}
fn strip_parameters(tool: ToolDefinition) -> ToolDefinition {
match tool {
ToolDefinition::Builtin(mut b) => {
if b.full_parameters.is_none() {
b.full_parameters = Some(b.parameters.clone());
}
b.parameters = deferred_stub_schema(&b.name);
ToolDefinition::Builtin(b)
}
ToolDefinition::ClientSide(mut c) => {
if c.full_parameters.is_none() {
c.full_parameters = Some(c.parameters.clone());
}
c.parameters = deferred_stub_schema(&c.name);
ToolDefinition::ClientSide(c)
}
}
}
pub struct ToolSearchTool;
impl ToolSearchTool {
fn search(defs: &[ToolDefinition], query: &str) -> Vec<Value> {
let terms: Vec<String> = query
.split_whitespace()
.map(|t| {
t.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
})
.filter(|t| !t.is_empty())
.collect();
let mut scored: Vec<(usize, &ToolDefinition)> = defs
.iter()
.filter(|d| d.name() != TOOL_SEARCH_TOOL_NAME)
.filter_map(|d| {
if terms.is_empty() {
return Some((0, d));
}
let haystack = format!("{} {}", d.name(), d.description()).to_lowercase();
let score = terms.iter().filter(|t| haystack.contains(*t)).count();
(score > 0).then_some((score, d))
})
.collect();
scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
scored
.into_iter()
.take(MAX_SEARCH_RESULTS)
.map(|(_, d)| {
json!({
"name": d.name(),
"description": d.description(),
"parameters": d.full_parameters(),
})
})
.collect()
}
}
#[async_trait]
impl Tool for ToolSearchTool {
fn name(&self) -> &str {
TOOL_SEARCH_TOOL_NAME
}
fn display_name(&self) -> Option<&str> {
Some("Tool Search")
}
fn description(&self) -> &str {
"Search the available tools by keyword and load their full parameter \
schemas. Returns matching tools with their names, descriptions, and JSON \
parameter schemas. Call this before using any tool whose parameters you \
have not loaded yet."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keywords describing the tool or capability you need (e.g. 'read file', 'run sql', 'send message')."
}
},
"required": ["query"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_readonly(true)
.with_idempotent(true)
}
fn to_definition(&self) -> ToolDefinition {
ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
name: self.name().to_string(),
display_name: self.display_name().map(str::to_string),
description: self.description().to_string(),
parameters: self.parameters_schema(),
policy: self.policy(),
category: None,
deferrable: DeferrablePolicy::Never,
hints: self.hints(),
full_parameters: None,
})
}
fn requires_context(&self) -> bool {
true
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"tool_search requires tool execution context and cannot run standalone.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let Some(registry) = &context.tool_registry else {
return ToolExecutionResult::tool_error(
"Tool registry not available in this context. tool_search requires worker-side tool execution.",
);
};
let Some(visible_tool_names) = &context.visible_tool_names else {
return ToolExecutionResult::tool_error(
"Visible tool allowlist not available in this context. tool_search requires turn-scoped tool definitions.",
);
};
let defs: Vec<_> = registry
.tool_definitions()
.into_iter()
.filter(|d| visible_tool_names.contains(d.name()))
.collect();
let matches = Self::search(&defs, query);
if matches.is_empty() {
let names: Vec<&str> = defs
.iter()
.map(|d| d.name())
.filter(|n| *n != TOOL_SEARCH_TOOL_NAME)
.collect();
return ToolExecutionResult::success(json!({
"query": query,
"tools": [],
"message": "No tools matched the query. Try a different keyword.",
"available_tools": names,
}));
}
ToolExecutionResult::success(json!({
"query": query,
"tools": matches,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capabilities::CapabilityRegistry;
use crate::tool_types::{BuiltinTool, ToolPolicy};
fn builtin(name: &str, description: &str, deferrable: DeferrablePolicy) -> ToolDefinition {
ToolDefinition::Builtin(BuiltinTool {
name: name.to_string(),
display_name: None,
description: description.to_string(),
parameters: json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
policy: ToolPolicy::Auto,
category: None,
deferrable,
hints: ToolHints::default(),
full_parameters: None,
})
}
fn many_tools(n: usize) -> Vec<ToolDefinition> {
(0..n)
.map(|i| {
builtin(
&format!("tool_{i}"),
"does something",
DeferrablePolicy::Automatic,
)
})
.collect()
}
#[test]
fn test_capability_metadata() {
let cap = ToolSearchCapability::new();
assert_eq!(cap.id(), TOOL_SEARCH_CAPABILITY_ID);
assert_eq!(cap.name(), "Tool Search");
assert_eq!(cap.category(), Some("Optimization"));
assert!(cap.system_prompt_addition().is_some());
assert_eq!(cap.tools().len(), 1);
assert_eq!(cap.tools()[0].name(), TOOL_SEARCH_TOOL_NAME);
}
#[test]
fn test_capability_registered_in_builtins() {
let registry = CapabilityRegistry::with_builtins();
let cap = registry.get(TOOL_SEARCH_CAPABILITY_ID).unwrap();
assert_eq!(cap.id(), TOOL_SEARCH_CAPABILITY_ID);
}
#[test]
fn test_hook_noop_below_threshold() {
let hook = DeferSchemaHook { threshold: 15 };
let tools = many_tools(5);
let out = hook.transform(tools);
for t in &out {
assert!(t.parameters().get("properties").is_some());
}
}
#[test]
fn test_hook_strips_above_threshold() {
let hook = DeferSchemaHook { threshold: 15 };
let out = hook.transform(many_tools(20));
for t in &out {
assert!(t.parameters().get("properties").is_none());
assert_eq!(t.parameters()["additionalProperties"], json!(true));
let description = t.parameters()["description"].as_str().unwrap();
assert!(
description.contains("tool_search"),
"deferred stub should point the model to tool_search"
);
assert!(
description.contains(t.name()),
"deferred stub should include the search query that reveals this schema"
);
assert!(
t.full_parameters().get("properties").is_some(),
"full schema should remain available for progressive disclosure"
);
}
}
#[test]
fn test_hook_preserves_never_defer_and_search_tool() {
let hook = DeferSchemaHook { threshold: 3 };
let mut tools = many_tools(3);
tools.push(builtin("write_todos", "todos", DeferrablePolicy::Never));
tools.push(ToolSearchTool.to_definition());
let out = hook.transform(tools);
let todos = out.iter().find(|t| t.name() == "write_todos").unwrap();
assert!(
todos.parameters().get("properties").is_some(),
"never-defer tool keeps full schema"
);
let search = out
.iter()
.find(|t| t.name() == TOOL_SEARCH_TOOL_NAME)
.unwrap();
assert!(
search.parameters().get("properties").is_some(),
"search tool keeps full schema"
);
let deferred = out.iter().find(|t| t.name() == "tool_0").unwrap();
assert!(deferred.parameters().get("properties").is_none());
}
#[test]
fn test_hook_defers_mcp_tools_and_saves_full_schema() {
let hook = DeferSchemaHook { threshold: 3 };
let mut tools = many_tools(3);
tools.push(builtin(
"mcp_docs__search",
"search docs",
DeferrablePolicy::Automatic,
));
let out = hook.transform(tools);
let mcp = out.iter().find(|t| t.name() == "mcp_docs__search").unwrap();
assert!(
mcp.parameters().get("properties").is_none(),
"MCP tool schema is deferred"
);
assert!(
mcp.full_parameters().get("properties").is_some(),
"MCP tool full schema is accessible via full_parameters()"
);
}
#[test]
fn test_search_returns_full_schema_for_deferred_tools() {
let hook = DeferSchemaHook { threshold: 1 };
let tools = vec![builtin(
"read_file",
"Read a file",
DeferrablePolicy::Automatic,
)];
let deferred = hook.transform(tools);
let results = ToolSearchTool::search(&deferred, "read file");
assert_eq!(results.len(), 1);
assert_eq!(results[0]["name"], "read_file");
assert!(
results[0]["parameters"].get("properties").is_some(),
"tool_search must return the full schema, not the deferred stub"
);
}
#[test]
fn test_hook_opts_out_of_native_tool_search() {
let hook = DeferSchemaHook { threshold: 15 };
assert!(!hook.applies_with_native_tool_search());
}
#[test]
fn test_search_ranks_by_keyword_overlap() {
let defs = vec![
builtin(
"read_file",
"Read the contents of a file",
DeferrablePolicy::Automatic,
),
builtin(
"send_email",
"Send an email message",
DeferrablePolicy::Automatic,
),
builtin(
"write_file",
"Write contents to a file",
DeferrablePolicy::Automatic,
),
];
let results = ToolSearchTool::search(&defs, "read file");
assert_eq!(results[0]["name"], "read_file");
assert!(results[0]["parameters"].get("properties").is_some());
let email = ToolSearchTool::search(&defs, "email");
assert_eq!(email.len(), 1);
assert_eq!(email[0]["name"], "send_email");
}
#[test]
fn test_search_excludes_itself() {
let defs = vec![
ToolSearchTool.to_definition(),
builtin("read_file", "Read a file", DeferrablePolicy::Automatic),
];
let results = ToolSearchTool::search(&defs, "tool_search read");
assert!(results.iter().all(|r| r["name"] != TOOL_SEARCH_TOOL_NAME));
}
#[tokio::test]
async fn test_execute_without_registry_errors() {
let ctx = ToolContext::new(uuid::Uuid::new_v4().into());
let result = ToolSearchTool
.execute_with_context(json!({ "query": "file" }), &ctx)
.await;
assert!(matches!(result, ToolExecutionResult::ToolError(_)));
}
struct MiniTool;
#[async_trait]
impl Tool for MiniTool {
fn name(&self) -> &str {
"read_file"
}
fn description(&self) -> &str {
"Read the contents of a file"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
})
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::success(json!({}))
}
}
#[tokio::test]
async fn test_execute_with_registry_returns_schemas() {
use crate::tools::ToolRegistry;
let mut registry = ToolRegistry::new();
registry.register(MiniTool);
registry.register(ToolSearchTool);
let mut ctx = ToolContext::new(uuid::Uuid::new_v4().into());
ctx.tool_registry = Some(Arc::new(registry));
ctx.visible_tool_names = Some(Arc::new(
["read_file".to_string(), TOOL_SEARCH_TOOL_NAME.to_string()]
.into_iter()
.collect(),
));
let result = ToolSearchTool
.execute_with_context(json!({ "query": "file" }), &ctx)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success");
};
let tools = value["tools"].as_array().unwrap();
let read = tools.iter().find(|t| t["name"] == "read_file").unwrap();
assert!(read["parameters"]["properties"]["path"].is_object());
}
struct HiddenTool;
#[async_trait]
impl Tool for HiddenTool {
fn name(&self) -> &str {
"write_file"
}
fn description(&self) -> &str {
"Write contents to a file"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
})
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::success(json!({}))
}
}
#[tokio::test]
async fn test_execute_filters_registry_to_visible_tools() {
use crate::tools::ToolRegistry;
let mut registry = ToolRegistry::new();
registry.register(MiniTool);
registry.register(HiddenTool);
registry.register(ToolSearchTool);
let mut ctx = ToolContext::new(uuid::Uuid::new_v4().into());
ctx.tool_registry = Some(Arc::new(registry));
ctx.visible_tool_names = Some(Arc::new(
["read_file".to_string(), TOOL_SEARCH_TOOL_NAME.to_string()]
.into_iter()
.collect(),
));
let result = ToolSearchTool
.execute_with_context(json!({ "query": "file" }), &ctx)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success");
};
let tools = value["tools"].as_array().unwrap();
assert!(tools.iter().any(|t| t["name"] == "read_file"));
assert!(tools.iter().all(|t| t["name"] != "write_file"));
let result = ToolSearchTool
.execute_with_context(json!({ "query": "missing" }), &ctx)
.await;
let ToolExecutionResult::Success(value) = result else {
panic!("expected success");
};
let available = value["available_tools"].as_array().unwrap();
assert!(available.iter().any(|name| name == "read_file"));
assert!(available.iter().all(|name| name != "write_file"));
}
}