- 1
//! `find_tools`: search the turn's deferred tool catalogue. - 2
//! - 3
//! Part of docs/design/68-context-engine.md §5's tool surface: tools not in - 4
//! the always-visible core set are withheld from the prefix and reachable - 5
//! only by name-and-description search here (or, on Anthropic, by the - 6
//! provider's own tool search). No new dependency — matching is plain - 7
//! case-insensitive substring and token overlap over each candidate's name, - 8
//! description, and JSON-schema argument names/descriptions. - 9
- 10
use std::collections::HashMap; - 11
use std::sync::{Arc, Mutex}; - 12
- 13
use async_trait::async_trait; - 14
use serde_json::{Value, json}; - 15
- 16
use crate::{Tool, ToolContext, ToolOutput}; - 17
- 18
pub struct FindToolsTool { - 19
catalogue: Vec<vak_llm::ToolDefinition>, - 20
/// Search-only vocabulary supplied by the capability registry (currently - 21
/// domain labels such as `live-data`). It is deliberately separate from - 22
/// the model-visible tool schema so discovery metadata cannot change the - 23
/// callable contract. - 24
keywords: HashMap<String, Vec<String>>, - 25
/// Every match returned this run is also pushed here, so the caller - 26
/// (`vak_agent::Agent::tool_definitions`) can keep offering a - 27
/// discovered tool's full schema for the rest of the turn without the - 28
/// model needing to call `find_tools` again (docs/design/68 §5). - 29
discovered: Option<Arc<Mutex<Vec<vak_llm::ToolDefinition>>>>, - 30
} - 31
- 32
impl FindToolsTool { - 33
pub fn new(catalogue: Vec<vak_llm::ToolDefinition>) -> Self { - 34
FindToolsTool { - 35
catalogue, - 36
keywords: HashMap::new(), - 37
discovered: None, - 38
} - 39
} - 40
- 41
pub fn with_keywords(mut self, keywords: HashMap<String, Vec<String>>) -> Self { - 42
self.keywords = keywords; - 43
self - 44
} - 45
- 46
pub fn with_discovered_sink(mut self, sink: Arc<Mutex<Vec<vak_llm::ToolDefinition>>>) -> Self { - 47
self.discovered = Some(sink); - 48
self - 49
} - 50
} - 51
- 52
const DEFAULT_LIMIT: usize = 5; - 53
const MAX_LIMIT: usize = 50; - 54
- 55
#[async_trait] - 56
impl Tool for FindToolsTool { - 57
fn name(&self) -> &str { - 58
"find_tools" - 59
} - 60
- 61
fn always_loaded(&self) -> bool { - 62
true - 63
} - 64
- 65
fn description(&self) -> &str { - 66
"Search all admitted tools, including already-loaded broker tools, by name, capability domain, description, or argument names/descriptions. Returns full schemas for the best matches; deferred matches become usable for the rest of this turn." - 67
} - 68
- 69
fn schema(&self) -> Value { - 70
json!({ - 71
"type": "object", - 72
"properties": { - 73
"query": { - 74
"type": "string", - 75
"description": "Words to match against tool names, descriptions, and argument names/descriptions." - 76
}, - 77
"limit": { - 78
"type": "integer", - 79
"description": "Maximum number of matches to return (default 5)." - 80
} - 81
}, - 82
"required": ["query"] - 83
}) - 84
} - 85
- 86
async fn execute(&self, args: &Value, _ctx: &ToolContext) -> ToolOutput { - 87
let Some(query) = args.get("query").and_then(|v| v.as_str()) else { - 88
return ToolOutput::error( - 89
r#"{"type":"invalid_arguments","message":"missing required string 'query'"}"#, - 90
); - 91
}; - 92
if query.trim().is_empty() { - 93
return ToolOutput::error( - 94
r#"{"type":"invalid_arguments","message":"'query' must not be empty"}"#, - 95
); - 96
} - 97
let limit = args - 98
.get("limit") - 99
.and_then(Value::as_u64) - 100
.map(|n| (n as usize).clamp(1, MAX_LIMIT)) - 101
.unwrap_or(DEFAULT_LIMIT); - 102
let matches = rank(&self.catalogue, &self.keywords, query, limit); - 103
if let Some(sink) = &self.discovered { - 104
let mut discovered = sink - 105
.lock() - 106
.unwrap_or_else(std::sync::PoisonError::into_inner); - 107
for def in &matches { - 108
if !discovered.iter().any(|existing| existing.name == def.name) { - 109
discovered.push((*def).clone()); - 110
} - 111
} - 112
} - 113
let schemas: Vec<Value> = matches - 114
.iter() - 115
.map(|def| { - 116
json!({ - 117
"name": def.name, - 118
"description": def.description, - 119
"input_schema": def.parameters, - 120
}) - 121
}) - 122
.collect(); - 123
ToolOutput::ok(json!({"matches": schemas}).to_string()) - 124
} - 125
} - 126
- 127
fn rank<'a>( - 128
catalogue: &'a [vak_llm::ToolDefinition], - 129
keywords: &HashMap<String, Vec<String>>, - 130
query: &str, - 131
limit: usize, - 132
) -> Vec<&'a vak_llm::ToolDefinition> { - 133
let query_lower = query.to_ascii_lowercase(); - 134
let tokens: Vec<&str> = query_lower.split_whitespace().collect(); - 135
let mut scored: Vec<(i64, &vak_llm::ToolDefinition)> = catalogue - 136
.iter() - 137
.filter_map(|def| { - 138
let score = score_tool( - 139
def, - 140
keywords - 141
.get(&def.name) - 142
.map(Vec::as_slice) - 143
.unwrap_or_default(), - 144
&query_lower, - 145
&tokens, - 146
); - 147
(score > 0).then_some((score, def)) - 148
}) - 149
.collect(); - 150
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name))); - 151
scored.into_iter().take(limit).map(|(_, def)| def).collect() - 152
} - 153
- 154
fn score_tool( - 155
def: &vak_llm::ToolDefinition, - 156
keywords: &[String], - 157
query_lower: &str, - 158
tokens: &[&str], - 159
) -> i64 { - 160
let name_lower = def.name.to_ascii_lowercase(); - 161
let mut haystack = format!("{} {}", name_lower, def.description.to_ascii_lowercase()); - 162
collect_schema_text(&def.parameters, &mut haystack); - 163
for keyword in keywords { - 164
haystack.push(' '); - 165
haystack.push_str(&keyword.to_ascii_lowercase()); - 166
} - 167
- 168
let mut score = 0i64; - 169
if name_lower == query_lower { - 170
score += 100; - 171
} - 172
if name_lower.contains(query_lower) { - 173
score += 20; - 174
} - 175
if haystack.contains(query_lower) { - 176
score += 10; - 177
} - 178
for token in tokens { - 179
if !token.is_empty() && haystack.contains(token) { - 180
score += 5; - 181
} - 182
} - 183
score - 184
} - 185
- 186
fn collect_schema_text(schema: &Value, out: &mut String) { - 187
let Some(properties) = schema.get("properties").and_then(Value::as_object) else { - 188
return; - 189
}; - 190
for (name, property) in properties { - 191
out.push(' '); - 192
out.push_str(&name.to_ascii_lowercase()); - 193
if let Some(description) = property.get("description").and_then(Value::as_str) { - 194
out.push(' '); - 195
out.push_str(&description.to_ascii_lowercase()); - 196
} - 197
} - 198
} - 199
- 200
#[cfg(test)] - 201
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 202
mod tests { - 203
use super::*; - 204
use std::path::PathBuf; - 205
- 206
fn def(name: &str, description: &str, schema: Value) -> vak_llm::ToolDefinition { - 207
vak_llm::ToolDefinition::new(name, description, schema) - 208
} - 209
- 210
fn ctx() -> ToolContext { - 211
ToolContext::new(PathBuf::from(".")) - 212
} - 213
- 214
#[tokio::test] - 215
async fn matches_by_name_and_description() { - 216
let tool = FindToolsTool::new(vec![ - 217
def("webfetch", "Fetch a URL over HTTP.", json!({})), - 218
def("bash", "Run a shell command.", json!({})), - 219
]); - 220
let out = tool.execute(&json!({"query": "http"}), &ctx()).await; - 221
assert!(!out.is_error); - 222
let parsed: Value = serde_json::from_str(&out.content).unwrap(); - 223
let matches = parsed["matches"].as_array().unwrap(); - 224
assert_eq!(matches.len(), 1); - 225
assert_eq!(matches[0]["name"], "webfetch"); - 226
} - 227
- 228
#[tokio::test] - 229
async fn matches_by_argument_name_and_description() { - 230
let tool = FindToolsTool::new(vec![def( - 231
"weather_lookup", - 232
"Look something up.", - 233
json!({ - 234
"type": "object", - 235
"properties": { - 236
"city": {"type": "string", "description": "City to check the forecast for"} - 237
} - 238
}), - 239
)]); - 240
let out = tool.execute(&json!({"query": "forecast"}), &ctx()).await; - 241
let parsed: Value = serde_json::from_str(&out.content).unwrap(); - 242
assert_eq!(parsed["matches"].as_array().unwrap().len(), 1); - 243
} - 244
- 245
#[tokio::test] - 246
async fn matches_loaded_broker_by_capability_domain() { - 247
let tool = FindToolsTool::new(vec![def( - 248
"mcp", - 249
"Call tools exposed by configured servers.", - 250
json!({}), - 251
)]) - 252
.with_keywords(HashMap::from([( - 253
"mcp".into(), - 254
vec!["live-data".into(), "web".into()], - 255
)])); - 256
let out = tool - 257
.execute(&json!({"query": "live-data weather"}), &ctx()) - 258
.await; - 259
let parsed: Value = serde_json::from_str(&out.content).unwrap(); - 260
assert_eq!(parsed["matches"][0]["name"], "mcp"); - 261
} - 262
- 263
#[tokio::test] - 264
async fn returns_full_schema_for_matches() { - 265
let schema = json!({"type": "object", "properties": {"q": {"type": "string"}}}); - 266
let tool = FindToolsTool::new(vec![def("search", "Search the web.", schema.clone())]); - 267
let out = tool.execute(&json!({"query": "search"}), &ctx()).await; - 268
let parsed: Value = serde_json::from_str(&out.content).unwrap(); - 269
assert_eq!(parsed["matches"][0]["input_schema"], schema); - 270
} - 271
- 272
#[tokio::test] - 273
async fn respects_limit_and_ranks_best_matches_first() { - 274
let tool = FindToolsTool::new(vec![ - 275
def("search_exact", "search", json!({})), - 276
def("search_other", "search something else", json!({})), - 277
def("unrelated", "does nothing related", json!({})), - 278
]); - 279
let out = tool - 280
.execute(&json!({"query": "search", "limit": 1}), &ctx()) - 281
.await; - 282
let parsed: Value = serde_json::from_str(&out.content).unwrap(); - 283
let matches = parsed["matches"].as_array().unwrap(); - 284
assert_eq!(matches.len(), 1); - 285
assert_eq!(matches[0]["name"], "search_exact"); - 286
} - 287
- 288
#[tokio::test] - 289
async fn no_matches_returns_an_empty_list_not_an_error() { - 290
let tool = FindToolsTool::new(vec![def("bash", "Run a shell command.", json!({}))]); - 291
let out = tool - 292
.execute(&json!({"query": "zzz_no_such_thing"}), &ctx()) - 293
.await; - 294
assert!(!out.is_error); - 295
let parsed: Value = serde_json::from_str(&out.content).unwrap(); - 296
assert!(parsed["matches"].as_array().unwrap().is_empty()); - 297
} - 298
- 299
#[tokio::test] - 300
async fn matches_are_pushed_into_the_discovered_sink() { - 301
let sink = Arc::new(Mutex::new(Vec::new())); - 302
let tool = FindToolsTool::new(vec![def("bash", "Run a shell command.", json!({}))]) - 303
.with_discovered_sink(sink.clone()); - 304
tool.execute(&json!({"query": "bash"}), &ctx()).await; - 305
let discovered = sink.lock().unwrap(); - 306
assert_eq!(discovered.len(), 1); - 307
assert_eq!(discovered[0].name, "bash"); - 308
} - 309
- 310
#[tokio::test] - 311
async fn missing_query_is_a_correctable_error() { - 312
let tool = FindToolsTool::new(vec![]); - 313
let out = tool.execute(&json!({}), &ctx()).await; - 314
assert!(out.is_error); - 315
} - 316
} - 317
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.