use async_trait::async_trait;
use everruns_core::capabilities::{Capability, CapabilityStatus, ToolDefinitionHook};
use everruns_core::mcp_server::is_mcp_tool;
use everruns_core::tool_types::{BuiltinTool, DeferrablePolicy, ToolDefinition, ToolHints};
use everruns_core::tools::{Tool, ToolExecutionResult};
use everruns_core::traits::ToolContext;
use serde_json::{Value, json};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
pub const TOOL_SEARCH_CAPABILITY_ID: &str = "yolop_tool_search";
pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
pub const DEFAULT_TOOL_SEARCH_THRESHOLD: usize = 15;
const MAX_SEARCH_RESULTS: usize = 12;
const ALWAYS_FULL: &[&str] = &[
"read_file",
"write_file",
"edit_file",
"list_directory",
"grep_files",
"bash",
];
type RevealedTools = Arc<Mutex<HashSet<String>>>;
pub struct ToolSearchCapability {
threshold: usize,
revealed: RevealedTools,
}
impl ToolSearchCapability {
pub fn new() -> Self {
Self::with_threshold(DEFAULT_TOOL_SEARCH_THRESHOLD)
}
pub fn with_threshold(threshold: usize) -> Self {
Self {
threshold,
revealed: Arc::new(Mutex::new(HashSet::new())),
}
}
}
impl Default for ToolSearchCapability {
fn default() -> Self {
Self::new()
}
}
const SYSTEM_PROMPT: &str = "Some of your tools are loaded lazily to save context: you can see their \
names and descriptions, but their parameter schemas show as \"hidden\" until you load them. To use a \
hidden tool, first call `tool_search` with a short query (for example \"search the web\" or \"read \
memory\"); it returns the matching tools with their full JSON parameter schemas. On your next step \
the tool's real parameters become available — then call it with correct arguments. Core file and \
shell tools are always fully loaded and never need a search.";
#[async_trait]
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 long-tail tool parameter \
schemas until the model loads them via the tool_search tool, reducing \
token usage. Works on 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 {
revealed: self.revealed.clone(),
})]
}
fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
vec![Arc::new(DeferSchemaHook {
threshold: self.threshold,
revealed: self.revealed.clone(),
})]
}
}
fn deferred_stub_schema() -> Value {
json!({
"type": "object",
"description": "Parameters hidden to save context. Call tool_search to load the full schema before using this tool.",
})
}
fn keep_full(tool: &ToolDefinition, revealed: &HashSet<String>) -> bool {
let name = tool.name();
name == TOOL_SEARCH_TOOL_NAME
|| ALWAYS_FULL.contains(&name)
|| matches!(tool.deferrable(), DeferrablePolicy::Never)
|| is_mcp_tool(name)
|| revealed.contains(name)
}
struct DeferSchemaHook {
threshold: usize,
revealed: RevealedTools,
}
impl ToolDefinitionHook for DeferSchemaHook {
fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
if tools.len() <= self.threshold {
return tools;
}
let revealed = self.revealed.lock().expect("revealed tools lock poisoned");
tools
.into_iter()
.map(|tool| {
if keep_full(&tool, &revealed) {
tool
} else {
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) => {
b.parameters = deferred_stub_schema();
ToolDefinition::Builtin(b)
}
ToolDefinition::ClientSide(mut c) => {
c.parameters = deferred_stub_schema();
ToolDefinition::ClientSide(c)
}
}
}
pub struct ToolSearchTool {
revealed: RevealedTools,
}
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.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 show \
as hidden."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keywords describing the tool or capability you need (e.g. 'search the web', 'read memory', 'list skills')."
}
},
"required": ["query"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_readonly(true)
.with_idempotent(true)
}
fn to_definition(&self) -> ToolDefinition {
ToolDefinition::Builtin(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 defs = registry.tool_definitions();
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,
}));
}
let revealed_now: Vec<String> = matches
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str).map(str::to_string))
.collect();
{
let mut revealed = self.revealed.lock().expect("revealed tools lock poisoned");
for name in &revealed_now {
revealed.insert(name.clone());
}
}
ToolExecutionResult::success(json!({
"query": query,
"tools": matches,
"loaded": revealed_now,
"message": "Full schemas loaded. You can now call these tools with correct arguments.",
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use everruns_core::tool_types::ToolPolicy;
fn builtin(name: &str, deferrable: DeferrablePolicy) -> ToolDefinition {
ToolDefinition::Builtin(BuiltinTool {
name: name.to_string(),
display_name: None,
description: format!("{name} description"),
parameters: json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
policy: ToolPolicy::default(),
category: None,
deferrable,
hints: ToolHints::default(),
full_parameters: None,
})
}
fn is_stubbed(tool: &ToolDefinition) -> bool {
tool.parameters()
.get("properties")
.and_then(Value::as_object)
.is_none_or(|p| p.is_empty())
}
fn tool_set(extra: usize) -> Vec<ToolDefinition> {
let mut tools: Vec<ToolDefinition> = ALWAYS_FULL
.iter()
.map(|n| builtin(n, DeferrablePolicy::default()))
.collect();
for i in 0..extra {
tools.push(builtin(
&format!("longtail_{i}"),
DeferrablePolicy::default(),
));
}
tools
}
#[test]
fn below_threshold_keeps_all_full_schemas() {
let cap = ToolSearchCapability::new();
let hook = &cap.tool_definition_hooks()[0];
let out = hook.transform(tool_set(3));
assert!(
out.iter().all(|t| !is_stubbed(t)),
"nothing should defer below threshold"
);
}
#[test]
fn deferral_activates_strictly_above_threshold() {
let cap = ToolSearchCapability::with_threshold(15);
let hook = &cap.tool_definition_hooks()[0];
let at = hook.transform(tool_set(9));
assert_eq!(at.len(), 15);
assert!(
at.iter().all(|t| !is_stubbed(t)),
"at the threshold the full catalogue must fit; no deferral"
);
let over = hook.transform(tool_set(10));
assert!(
over.iter().any(is_stubbed),
"strictly above the threshold the long tail must defer"
);
}
#[test]
fn core_tools_keep_full_schemas_long_tail_is_deferred() {
let cap = ToolSearchCapability::new();
let hook = &cap.tool_definition_hooks()[0];
let out = hook.transform(tool_set(12)); for t in &out {
if ALWAYS_FULL.contains(&t.name()) {
assert!(
!is_stubbed(t),
"core tool {} must keep full schema",
t.name()
);
} else {
assert!(
is_stubbed(t),
"long-tail tool {} must be deferred",
t.name()
);
}
}
}
#[test]
fn revealing_a_tool_restores_its_full_schema_next_pass() {
let cap = ToolSearchCapability::new();
let hook = &cap.tool_definition_hooks()[0];
let before = hook.transform(tool_set(12));
let deferred = before.iter().find(|t| t.name() == "longtail_0").unwrap();
assert!(
is_stubbed(deferred),
"precondition: longtail_0 starts deferred"
);
cap.revealed
.lock()
.unwrap()
.insert("longtail_0".to_string());
let after = hook.transform(tool_set(12));
let revealed = after.iter().find(|t| t.name() == "longtail_0").unwrap();
assert!(
!is_stubbed(revealed),
"revealed tool must regain its full schema"
);
let other = after.iter().find(|t| t.name() == "longtail_1").unwrap();
assert!(
is_stubbed(other),
"unrevealed long-tail tools stay deferred"
);
}
#[test]
fn search_ranks_by_keyword_and_returns_full_schema() {
let defs = tool_set(12);
let results = ToolSearchTool::search(&defs, "grep");
assert!(!results.is_empty());
assert_eq!(results[0]["name"], "grep_files");
assert!(results[0]["parameters"]["properties"]["path"].is_object());
}
#[test]
fn search_excludes_itself_and_lists_on_empty_query() {
let defs = tool_set(2);
let results = ToolSearchTool::search(&defs, "");
let names: Vec<&str> = results.iter().filter_map(|r| r["name"].as_str()).collect();
assert!(!names.contains(&TOOL_SEARCH_TOOL_NAME));
assert!(names.contains(&"read_file"));
}
}