1use super::{
62 Capability, CapabilityLocalization, CapabilityStatus, SystemPromptContext, ToolDefinitionHook,
63};
64use crate::tool_types::{DeferrablePolicy, ToolDefinition, ToolHints};
65use crate::tools::{Tool, ToolExecutionResult};
66use crate::traits::ToolContext;
67use crate::typed_id::SessionId;
68use async_trait::async_trait;
69use serde_json::{Value, json};
70use std::collections::{HashMap, HashSet, VecDeque};
71use std::sync::{Arc, Mutex, MutexGuard};
72
73pub use super::openai_tool_search::DEFAULT_TOOL_SEARCH_THRESHOLD;
74
75pub const TOOL_SEARCH_CAPABILITY_ID: &str = "tool_search";
77
78pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
80
81const MAX_SEARCH_RESULTS: usize = 8;
87
88const NAME_TERM_WEIGHT: usize = 3;
92const DESC_TERM_WEIGHT: usize = 1;
93
94const EXACT_NAME_BONUS: usize = 100;
98
99const MAX_REVEAL_SESSIONS: usize = 4096;
105
106#[derive(Default)]
111struct RevealRegistry {
112 sets: HashMap<SessionId, HashSet<String>>,
113 order: VecDeque<SessionId>,
115}
116
117impl RevealRegistry {
118 fn reveal(&mut self, session: SessionId, names: impl IntoIterator<Item = String>) {
121 if !self.sets.contains_key(&session) {
122 self.order.push_back(session);
123 self.sets.insert(session, HashSet::new());
124 }
125 if let Some(set) = self.sets.get_mut(&session) {
127 set.extend(names);
128 }
129
130 while self.sets.len() > MAX_REVEAL_SESSIONS {
131 match self.order.pop_front() {
132 Some(old) => {
133 self.sets.remove(&old);
134 }
135 None => break,
136 }
137 }
138 }
139
140 fn revealed(&self, session: SessionId) -> HashSet<String> {
142 self.sets.get(&session).cloned().unwrap_or_default()
143 }
144}
145
146type SharedReveals = Arc<Mutex<RevealRegistry>>;
147
148fn lock_reveals(reveals: &SharedReveals) -> MutexGuard<'_, RevealRegistry> {
152 reveals
153 .lock()
154 .unwrap_or_else(|poisoned| poisoned.into_inner())
155}
156
157const SYSTEM_PROMPT: &str = "Many of your tools are loaded lazily to save context: \
158you can see their names and descriptions, but their parameter schemas are hidden \
159until you ask for them. Before calling a tool whose parameters you have not yet \
160loaded, call `tool_search` with a short query describing what you need (for example \
161\"read file\" or \"send email\"). It returns the matching tools with their full JSON \
162parameter schemas, and on your next step those tools become callable with their full \
163parameters. Frequently used tools keep their full schemas and do not need to be \
164searched for.";
165
166pub struct ToolSearchCapability {
173 threshold: usize,
174 never_defer: Arc<HashSet<String>>,
175 revealed: SharedReveals,
176}
177
178impl ToolSearchCapability {
179 pub fn new() -> Self {
180 Self::with_threshold(DEFAULT_TOOL_SEARCH_THRESHOLD)
181 }
182
183 pub fn with_threshold(threshold: usize) -> Self {
184 Self {
185 threshold,
186 never_defer: Arc::new(HashSet::new()),
187 revealed: SharedReveals::default(),
188 }
189 }
190
191 pub fn with_never_defer<I, S>(mut self, names: I) -> Self
198 where
199 I: IntoIterator<Item = S>,
200 S: Into<String>,
201 {
202 self.never_defer = Arc::new(names.into_iter().map(Into::into).collect());
203 self
204 }
205
206 fn resolve_config(&self, config: &Value) -> (usize, Arc<HashSet<String>>) {
209 let threshold = config
210 .get("threshold")
211 .and_then(|v| v.as_u64())
212 .map(|v| v as usize)
213 .unwrap_or(self.threshold);
214
215 let extra = config.get("never_defer").and_then(|v| v.as_array());
216 let never_defer = match extra {
217 Some(arr) if !arr.is_empty() => {
218 let mut merged: HashSet<String> = self.never_defer.as_ref().clone();
219 merged.extend(arr.iter().filter_map(|v| v.as_str().map(str::to_string)));
220 Arc::new(merged)
221 }
222 _ => self.never_defer.clone(),
224 };
225 (threshold, never_defer)
226 }
227
228 fn hook(
231 &self,
232 threshold: usize,
233 never_defer: Arc<HashSet<String>>,
234 session: SessionId,
235 ) -> Arc<dyn ToolDefinitionHook> {
236 Arc::new(DeferSchemaHook {
237 threshold,
238 never_defer,
239 revealed: self.revealed.clone(),
240 session,
241 })
242 }
243}
244
245impl Default for ToolSearchCapability {
246 fn default() -> Self {
247 Self::new()
248 }
249}
250
251impl Capability for ToolSearchCapability {
252 fn id(&self) -> &str {
253 TOOL_SEARCH_CAPABILITY_ID
254 }
255
256 fn name(&self) -> &str {
257 "Tool Search"
258 }
259
260 fn description(&self) -> &str {
261 "Provider-agnostic deferred tool loading. Hides tool parameter schemas \
262 until the model loads them via the tool_search tool, reducing token \
263 usage for agents with many tools. Works with any model."
264 }
265
266 fn localizations(&self) -> Vec<CapabilityLocalization> {
267 vec![CapabilityLocalization::text(
268 "uk",
269 "Пошук інструментів",
270 "Відкладене завантаження інструментів незалежно від провайдера. Приховує схеми параметрів інструментів, доки модель не завантажить їх через інструмент tool_search, що зменшує використання токенів для агентів із багатьма інструментами. Працює з будь-якою моделлю.",
271 )]
272 }
273
274 fn status(&self) -> CapabilityStatus {
275 CapabilityStatus::Available
276 }
277
278 fn category(&self) -> Option<&str> {
279 Some("Optimization")
280 }
281
282 fn system_prompt_addition(&self) -> Option<&str> {
283 Some(SYSTEM_PROMPT)
284 }
285
286 fn tools(&self) -> Vec<Box<dyn Tool>> {
287 vec![Box::new(ToolSearchTool {
288 revealed: self.revealed.clone(),
289 })]
290 }
291
292 fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
293 vec![self.hook(self.threshold, self.never_defer.clone(), SessionId::new())]
297 }
298
299 fn tool_definition_hooks_with_config(
300 &self,
301 config: &Value,
302 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
303 let (threshold, never_defer) = self.resolve_config(config);
304 vec![self.hook(threshold, never_defer, SessionId::new())]
305 }
306
307 fn tool_definition_hooks_with_context(
308 &self,
309 ctx: &SystemPromptContext,
310 config: &Value,
311 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
312 let (threshold, never_defer) = self.resolve_config(config);
313 vec![self.hook(threshold, never_defer, ctx.session_id)]
314 }
315}
316
317fn deferred_stub_schema() -> Value {
327 json!({
328 "type": "object",
329 "additionalProperties": true,
330 })
331}
332
333pub(crate) struct DeferSchemaHook {
334 threshold: usize,
335 never_defer: Arc<HashSet<String>>,
336 revealed: SharedReveals,
337 session: SessionId,
339}
340
341impl DeferSchemaHook {
342 fn keep_full(&self, tool: &ToolDefinition, revealed: &HashSet<String>) -> bool {
346 let name = tool.name();
347 name == TOOL_SEARCH_TOOL_NAME
348 || matches!(tool.deferrable(), DeferrablePolicy::Never)
349 || self.never_defer.contains(name)
350 || revealed.contains(name)
351 }
352}
353
354impl ToolDefinitionHook for DeferSchemaHook {
355 fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
356 if tools.len() < self.threshold {
358 return tools;
359 }
360
361 let revealed = lock_reveals(&self.revealed).revealed(self.session);
362
363 tools
364 .into_iter()
365 .map(|tool| {
366 if self.keep_full(&tool, &revealed) {
367 tool
368 } else {
369 strip_parameters(tool)
370 }
371 })
372 .collect()
373 }
374
375 fn applies_with_native_tool_search(&self) -> bool {
377 false
378 }
379}
380
381fn strip_parameters(tool: ToolDefinition) -> ToolDefinition {
385 match tool {
386 ToolDefinition::Builtin(mut b) => {
387 if b.full_parameters.is_none() {
388 b.full_parameters = Some(b.parameters.clone());
389 }
390 b.parameters = deferred_stub_schema();
391 ToolDefinition::Builtin(b)
392 }
393 ToolDefinition::ClientSide(mut c) => {
394 if c.full_parameters.is_none() {
395 c.full_parameters = Some(c.parameters.clone());
396 }
397 c.parameters = deferred_stub_schema();
398 ToolDefinition::ClientSide(c)
399 }
400 }
401}
402
403#[derive(Default)]
411pub struct ToolSearchTool {
412 revealed: SharedReveals,
413}
414
415impl ToolSearchTool {
416 fn search(defs: &[ToolDefinition], query: &str) -> Vec<Value> {
428 let normalized = query
431 .trim()
432 .trim_matches(|c: char| !c.is_alphanumeric())
433 .to_lowercase();
434 let terms: Vec<String> = query
435 .split_whitespace()
436 .map(|t| {
437 t.trim_matches(|c: char| !c.is_alphanumeric())
438 .to_lowercase()
439 })
440 .filter(|t| !t.is_empty())
441 .collect();
442
443 let mut scored: Vec<(usize, &ToolDefinition)> = defs
444 .iter()
445 .filter(|d| d.name() != TOOL_SEARCH_TOOL_NAME)
446 .filter_map(|d| {
447 if terms.is_empty() {
448 return Some((0, d));
449 }
450 let name = d.name().to_lowercase();
451 let desc = d.description().to_lowercase();
452 let mut score = 0;
453 for t in &terms {
454 if name.contains(t) {
455 score += NAME_TERM_WEIGHT;
456 } else if desc.contains(t) {
457 score += DESC_TERM_WEIGHT;
458 }
459 }
460 if normalized == name {
462 score += EXACT_NAME_BONUS;
463 }
464 (score > 0).then_some((score, d))
465 })
466 .collect();
467
468 scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
470
471 let max_score = scored.first().map(|(s, _)| *s).unwrap_or(0);
476 let cutoff = max_score.div_ceil(2);
477 scored.retain(|(s, _)| *s >= cutoff);
478
479 scored
480 .into_iter()
481 .take(MAX_SEARCH_RESULTS)
482 .map(|(_, d)| {
483 json!({
484 "name": d.name(),
485 "description": d.description(),
486 "parameters": d.full_parameters(),
487 })
488 })
489 .collect()
490 }
491}
492
493#[async_trait]
494impl Tool for ToolSearchTool {
495 fn narrate(
496 &self,
497 tool_call: &crate::tool_types::ToolCall,
498 phase: crate::tool_narration::ToolNarrationPhase,
499 locale: Option<&str>,
500 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
501 ) -> Option<String> {
502 Some(crate::tool_narration::narrate_tool_search(
503 &tool_call.arguments,
504 phase,
505 locale,
506 ))
507 }
508
509 fn name(&self) -> &str {
510 TOOL_SEARCH_TOOL_NAME
511 }
512
513 fn display_name(&self) -> Option<&str> {
514 Some("Tool Search")
515 }
516
517 fn description(&self) -> &str {
518 "Search the available tools by keyword and load their full parameter \
519 schemas. Returns matching tools with their names, descriptions, and JSON \
520 parameter schemas. Call this before using any tool whose parameters you \
521 have not loaded yet."
522 }
523
524 fn parameters_schema(&self) -> Value {
525 json!({
526 "type": "object",
527 "properties": {
528 "query": {
529 "type": "string",
530 "description": "Keywords describing the tool or capability you need (e.g. 'read file', 'run sql', 'send message')."
531 }
532 },
533 "required": ["query"],
534 "additionalProperties": false
535 })
536 }
537
538 fn hints(&self) -> ToolHints {
539 ToolHints::default()
540 .with_readonly(true)
541 .with_idempotent(true)
542 }
543
544 fn to_definition(&self) -> ToolDefinition {
546 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
547 name: self.name().to_string(),
548 display_name: self.display_name().map(str::to_string),
549 description: self.description().to_string(),
550 parameters: self.parameters_schema(),
551 policy: self.policy(),
552 category: None,
553 deferrable: DeferrablePolicy::Never,
554 hints: self.hints(),
555 full_parameters: None,
556 })
557 }
558
559 fn requires_context(&self) -> bool {
560 true
561 }
562
563 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
564 ToolExecutionResult::tool_error(
565 "tool_search requires tool execution context and cannot run standalone.",
566 )
567 }
568
569 async fn execute_with_context(
570 &self,
571 arguments: Value,
572 context: &ToolContext,
573 ) -> ToolExecutionResult {
574 let query = arguments
575 .get("query")
576 .and_then(|v| v.as_str())
577 .unwrap_or("")
578 .trim();
579
580 let Some(registry) = &context.tool_registry else {
581 return ToolExecutionResult::tool_error(
582 "Tool registry not available in this context. tool_search requires worker-side tool execution.",
583 );
584 };
585
586 let Some(visible_tool_names) = &context.visible_tool_names else {
587 return ToolExecutionResult::tool_error(
588 "Visible tool allowlist not available in this context. tool_search requires turn-scoped tool definitions.",
589 );
590 };
591
592 let defs: Vec<_> = registry
593 .tool_definitions()
594 .into_iter()
595 .filter(|d| visible_tool_names.contains(d.name()))
596 .collect();
597 let matches = Self::search(&defs, query);
598
599 if matches.is_empty() {
600 let names: Vec<&str> = defs
603 .iter()
604 .map(|d| d.name())
605 .filter(|n| *n != TOOL_SEARCH_TOOL_NAME)
606 .collect();
607 return ToolExecutionResult::success(json!({
608 "query": query,
609 "tools": [],
610 "message": "No tools matched the query. Try a different keyword.",
611 "available_tools": names,
612 }));
613 }
614
615 let loaded: Vec<String> = matches
620 .iter()
621 .filter_map(|t| t.get("name").and_then(Value::as_str).map(str::to_string))
622 .collect();
623 if !loaded.is_empty() {
624 lock_reveals(&self.revealed).reveal(context.session_id, loaded.iter().cloned());
625 }
626
627 ToolExecutionResult::success(json!({
628 "query": query,
629 "tools": matches,
630 "loaded": loaded,
631 "message": "Full schemas loaded; these tools are callable with their full parameters on your next step.",
632 }))
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use crate::tool_types::{BuiltinTool, ToolPolicy};
640
641 fn builtin(name: &str, description: &str, deferrable: DeferrablePolicy) -> ToolDefinition {
642 ToolDefinition::Builtin(BuiltinTool {
643 name: name.to_string(),
644 display_name: None,
645 description: description.to_string(),
646 parameters: json!({
647 "type": "object",
648 "properties": { "path": { "type": "string" } },
649 "required": ["path"]
650 }),
651 policy: ToolPolicy::Auto,
652 category: None,
653 deferrable,
654 hints: ToolHints::default(),
655 full_parameters: None,
656 })
657 }
658
659 fn many_tools(n: usize) -> Vec<ToolDefinition> {
660 (0..n)
661 .map(|i| {
662 builtin(
663 &format!("tool_{i}"),
664 "does something",
665 DeferrablePolicy::Automatic,
666 )
667 })
668 .collect()
669 }
670
671 fn hook(threshold: usize) -> DeferSchemaHook {
673 DeferSchemaHook {
674 threshold,
675 never_defer: Arc::new(HashSet::new()),
676 revealed: SharedReveals::default(),
677 session: SessionId::new(),
678 }
679 }
680
681 fn ctx_for(session: SessionId) -> SystemPromptContext {
682 SystemPromptContext::without_file_store(session)
683 }
684
685 fn is_stubbed(tool: &ToolDefinition) -> bool {
686 tool.parameters().get("properties").is_none()
687 }
688
689 #[test]
692 fn test_hook_noop_below_threshold() {
693 let hook = hook(15);
694 let tools = many_tools(5);
695 let out = hook.transform(tools);
696 for t in &out {
698 assert!(t.parameters().get("properties").is_some());
699 }
700 }
701
702 #[test]
703 fn test_hook_strips_above_threshold() {
704 let hook = hook(15);
705 let out = hook.transform(many_tools(20));
706 for t in &out {
707 assert!(t.parameters().get("properties").is_none());
710 assert_eq!(t.parameters()["additionalProperties"], json!(true));
711 assert!(t.parameters().get("description").is_none());
712 assert!(
713 t.full_parameters().get("properties").is_some(),
714 "full schema should remain available for progressive disclosure"
715 );
716 }
717 }
718
719 #[test]
720 fn test_hook_preserves_never_defer_and_search_tool() {
721 let hook = hook(3);
722 let mut tools = many_tools(3);
723 tools.push(builtin("write_todos", "todos", DeferrablePolicy::Never));
724 tools.push(ToolSearchTool::default().to_definition());
725
726 let out = hook.transform(tools);
727
728 let todos = out.iter().find(|t| t.name() == "write_todos").unwrap();
729 assert!(
730 todos.parameters().get("properties").is_some(),
731 "never-defer tool keeps full schema"
732 );
733 let search = out
734 .iter()
735 .find(|t| t.name() == TOOL_SEARCH_TOOL_NAME)
736 .unwrap();
737 assert!(
738 search.parameters().get("properties").is_some(),
739 "search tool keeps full schema"
740 );
741 let deferred = out.iter().find(|t| t.name() == "tool_0").unwrap();
743 assert!(deferred.parameters().get("properties").is_none());
744 }
745
746 #[test]
747 fn test_never_defer_allowlist_keeps_full_schema() {
748 let cap = ToolSearchCapability::with_threshold(3).with_never_defer(["tool_1"]);
751 let hooks = cap.tool_definition_hooks_with_context(&ctx_for(SessionId::new()), &json!({}));
752 let out = hooks[0].transform(many_tools(5));
753
754 let kept = out.iter().find(|t| t.name() == "tool_1").unwrap();
755 assert!(
756 !is_stubbed(kept),
757 "allowlisted tool must keep its full schema"
758 );
759 let deferred = out.iter().find(|t| t.name() == "tool_0").unwrap();
760 assert!(is_stubbed(deferred), "non-allowlisted tool must defer");
761 }
762
763 #[test]
764 fn test_config_never_defer_augments_constructor() {
765 let cap = ToolSearchCapability::with_threshold(3).with_never_defer(["tool_0"]);
767 let config = json!({ "never_defer": ["tool_2"] });
768 let hooks = cap.tool_definition_hooks_with_context(&ctx_for(SessionId::new()), &config);
769 let out = hooks[0].transform(many_tools(5));
770
771 assert!(!is_stubbed(
772 out.iter().find(|t| t.name() == "tool_0").unwrap()
773 ));
774 assert!(!is_stubbed(
775 out.iter().find(|t| t.name() == "tool_2").unwrap()
776 ));
777 assert!(is_stubbed(
778 out.iter().find(|t| t.name() == "tool_1").unwrap()
779 ));
780 }
781
782 #[test]
783 fn test_config_threshold_override() {
784 let cap = ToolSearchCapability::with_threshold(100);
785 let config = json!({ "threshold": 3 });
787 let hooks = cap.tool_definition_hooks_with_context(&ctx_for(SessionId::new()), &config);
788 let out = hooks[0].transform(many_tools(5));
789 assert!(out.iter().any(is_stubbed));
790 }
791
792 #[test]
793 fn test_revealed_tool_regains_full_schema_next_pass() {
794 let cap = ToolSearchCapability::with_threshold(3);
799 let session = SessionId::new();
800 let hooks = cap.tool_definition_hooks_with_context(&ctx_for(session), &json!({}));
801
802 let before = hooks[0].transform(many_tools(5));
804 assert!(
805 is_stubbed(before.iter().find(|t| t.name() == "tool_0").unwrap()),
806 "precondition: tool_0 starts deferred"
807 );
808
809 lock_reveals(&cap.revealed).reveal(session, ["tool_0".to_string()]);
811
812 let after = hooks[0].transform(many_tools(5));
814 assert!(
815 !is_stubbed(after.iter().find(|t| t.name() == "tool_0").unwrap()),
816 "revealed tool must regain its full registered schema"
817 );
818 assert!(
819 is_stubbed(after.iter().find(|t| t.name() == "tool_1").unwrap()),
820 "unrevealed tools stay deferred"
821 );
822 }
823
824 #[test]
825 fn test_reveals_are_isolated_per_session() {
826 let cap = ToolSearchCapability::with_threshold(3);
829 let session_a = SessionId::new();
830 let session_b = SessionId::new();
831 let hook_a = cap.tool_definition_hooks_with_context(&ctx_for(session_a), &json!({}));
832 let hook_b = cap.tool_definition_hooks_with_context(&ctx_for(session_b), &json!({}));
833
834 lock_reveals(&cap.revealed).reveal(session_a, ["tool_0".to_string()]);
835
836 let out_a = hook_a[0].transform(many_tools(5));
837 let out_b = hook_b[0].transform(many_tools(5));
838 assert!(
839 !is_stubbed(out_a.iter().find(|t| t.name() == "tool_0").unwrap()),
840 "session A revealed tool_0"
841 );
842 assert!(
843 is_stubbed(out_b.iter().find(|t| t.name() == "tool_0").unwrap()),
844 "session B must not see session A's reveal"
845 );
846 }
847
848 #[test]
849 fn test_reveal_registry_evicts_oldest_sessions() {
850 let mut reg = RevealRegistry::default();
851 let first = SessionId::new();
852 reg.reveal(first, ["tool_0".to_string()]);
853 for _ in 0..MAX_REVEAL_SESSIONS {
854 reg.reveal(SessionId::new(), ["tool_x".to_string()]);
855 }
856 assert!(reg.revealed(first).is_empty());
858 assert!(reg.sets.len() <= MAX_REVEAL_SESSIONS);
859 }
860
861 #[test]
862 fn test_hook_defers_mcp_tools_and_saves_full_schema() {
863 let hook = hook(3);
866 let mut tools = many_tools(3);
867 tools.push(builtin(
868 "mcp_docs__search",
869 "search docs",
870 DeferrablePolicy::Automatic,
871 ));
872
873 let out = hook.transform(tools);
874
875 let mcp = out.iter().find(|t| t.name() == "mcp_docs__search").unwrap();
876 assert!(
878 mcp.parameters().get("properties").is_none(),
879 "MCP tool schema is deferred"
880 );
881 assert!(
883 mcp.full_parameters().get("properties").is_some(),
884 "MCP tool full schema is accessible via full_parameters()"
885 );
886 }
887
888 #[test]
889 fn test_search_returns_full_schema_for_deferred_tools() {
890 let hook = hook(1);
893 let tools = vec![builtin(
894 "read_file",
895 "Read a file",
896 DeferrablePolicy::Automatic,
897 )];
898 let deferred = hook.transform(tools);
899
900 let results = ToolSearchTool::search(&deferred, "read file");
901 assert_eq!(results.len(), 1);
902 assert_eq!(results[0]["name"], "read_file");
903 assert!(
905 results[0]["parameters"].get("properties").is_some(),
906 "tool_search must return the full schema, not the deferred stub"
907 );
908 }
909
910 #[test]
911 fn test_search_returns_full_schema_after_serde_round_trip() {
912 let hook = hook(1);
915 let tools = vec![builtin(
916 "mcp_docs__search",
917 "Search MCP docs",
918 DeferrablePolicy::Automatic,
919 )];
920 let deferred = hook.transform(tools);
921 let round_tripped: Vec<ToolDefinition> =
922 serde_json::from_value(serde_json::to_value(&deferred).unwrap()).unwrap();
923
924 let mcp = round_tripped
925 .iter()
926 .find(|t| t.name() == "mcp_docs__search")
927 .unwrap();
928 assert!(
929 mcp.parameters().get("properties").is_none(),
930 "visible MCP schema remains deferred after serde"
931 );
932
933 let results = ToolSearchTool::search(&round_tripped, "docs search");
934 assert_eq!(results.len(), 1);
935 assert_eq!(results[0]["name"], "mcp_docs__search");
936 assert!(
937 results[0]["parameters"].get("properties").is_some(),
938 "tool_search must return the full MCP schema after durable serde"
939 );
940 }
941
942 #[test]
943 fn test_hook_opts_out_of_native_tool_search() {
944 let hook = hook(15);
947 assert!(!hook.applies_with_native_tool_search());
948 }
949
950 #[test]
951 fn test_search_ranks_by_keyword_overlap() {
952 let defs = vec![
953 builtin(
954 "read_file",
955 "Read the contents of a file",
956 DeferrablePolicy::Automatic,
957 ),
958 builtin(
959 "send_email",
960 "Send an email message",
961 DeferrablePolicy::Automatic,
962 ),
963 builtin(
964 "write_file",
965 "Write contents to a file",
966 DeferrablePolicy::Automatic,
967 ),
968 ];
969
970 let results = ToolSearchTool::search(&defs, "read file");
971 assert_eq!(results[0]["name"], "read_file");
972 assert!(results[0]["parameters"].get("properties").is_some());
974
975 let email = ToolSearchTool::search(&defs, "email");
976 assert_eq!(email.len(), 1);
977 assert_eq!(email[0]["name"], "send_email");
978 }
979
980 #[test]
981 fn test_search_weights_name_above_description() {
982 let defs = vec![
985 builtin("find_user", "Search the logs", DeferrablePolicy::Automatic),
986 builtin("search_logs", "Find stuff", DeferrablePolicy::Automatic),
987 ];
988 let results = ToolSearchTool::search(&defs, "search");
989 assert_eq!(results.len(), 1, "description-only match is below the band");
990 assert_eq!(results[0]["name"], "search_logs");
991 }
992
993 #[test]
994 fn test_search_exact_name_match_dominates() {
995 let defs = vec![
998 builtin(
999 "read_file_lines",
1000 "Read selected lines",
1001 DeferrablePolicy::Automatic,
1002 ),
1003 builtin("read_file", "Read a file", DeferrablePolicy::Automatic),
1004 ];
1005 let results = ToolSearchTool::search(&defs, "read_file");
1006 assert_eq!(results.len(), 1, "exact name match dominates the band");
1007 assert_eq!(results[0]["name"], "read_file");
1008 }
1009
1010 #[test]
1011 fn test_search_exact_name_match_tolerates_quoting() {
1012 let defs = vec![
1017 builtin(
1018 "read_file_lines",
1019 "Read selected lines",
1020 DeferrablePolicy::Automatic,
1021 ),
1022 builtin("read_file", "Read a file", DeferrablePolicy::Automatic),
1023 ];
1024 for q in ["\"read_file\"", "`read_file`", "'read_file'"] {
1025 let results = ToolSearchTool::search(&defs, q);
1026 assert_eq!(results.len(), 1, "query {q:?} should dominate");
1027 assert_eq!(results[0]["name"], "read_file", "query {q:?}");
1028 }
1029 }
1030
1031 #[test]
1032 fn test_search_caps_results_and_reveal_set() {
1033 let mut defs = Vec::new();
1036 for i in 0..20 {
1037 defs.push(builtin(
1038 &format!("tool_{i}"),
1039 "does a thing",
1040 DeferrablePolicy::Automatic,
1041 ));
1042 }
1043 let results = ToolSearchTool::search(&defs, "thing");
1044 assert_eq!(results.len(), MAX_SEARCH_RESULTS);
1045 }
1046
1047 #[test]
1048 fn test_search_excludes_itself() {
1049 let defs = vec![
1050 ToolSearchTool::default().to_definition(),
1051 builtin("read_file", "Read a file", DeferrablePolicy::Automatic),
1052 ];
1053 let results = ToolSearchTool::search(&defs, "tool_search read");
1054 assert!(results.iter().all(|r| r["name"] != TOOL_SEARCH_TOOL_NAME));
1055 }
1056
1057 #[tokio::test]
1058 async fn test_execute_without_registry_errors() {
1059 let ctx = ToolContext::new(uuid::Uuid::new_v4().into());
1060 let result = ToolSearchTool::default()
1061 .execute_with_context(json!({ "query": "file" }), &ctx)
1062 .await;
1063 assert!(matches!(result, ToolExecutionResult::ToolError(_)));
1064 }
1065
1066 struct MiniTool;
1067 #[async_trait]
1068 impl Tool for MiniTool {
1069 fn name(&self) -> &str {
1070 "read_file"
1071 }
1072 fn description(&self) -> &str {
1073 "Read the contents of a file"
1074 }
1075 fn parameters_schema(&self) -> Value {
1076 json!({
1077 "type": "object",
1078 "properties": { "path": { "type": "string" } },
1079 "required": ["path"]
1080 })
1081 }
1082 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1083 ToolExecutionResult::success(json!({}))
1084 }
1085 }
1086
1087 #[tokio::test]
1088 async fn test_execute_with_registry_returns_schemas() {
1089 use crate::tools::ToolRegistry;
1090
1091 let mut registry = ToolRegistry::new();
1092 registry.register(MiniTool);
1093 registry.register(ToolSearchTool::default());
1094
1095 let mut ctx = ToolContext::new(uuid::Uuid::new_v4().into());
1096 ctx.tool_registry = Some(Arc::new(registry));
1097 ctx.visible_tool_names = Some(Arc::new(
1098 ["read_file".to_string(), TOOL_SEARCH_TOOL_NAME.to_string()]
1099 .into_iter()
1100 .collect(),
1101 ));
1102
1103 let result = ToolSearchTool::default()
1104 .execute_with_context(json!({ "query": "file" }), &ctx)
1105 .await;
1106
1107 let ToolExecutionResult::Success(value) = result else {
1108 panic!("expected success");
1109 };
1110 let tools = value["tools"].as_array().unwrap();
1111 let read = tools.iter().find(|t| t["name"] == "read_file").unwrap();
1112 assert!(read["parameters"]["properties"]["path"].is_object());
1114 }
1115
1116 #[tokio::test]
1117 async fn test_search_records_reveal_and_restores_registered_schema() {
1118 use crate::tools::ToolRegistry;
1123
1124 let cap = ToolSearchCapability::with_threshold(3);
1125 let session: SessionId = uuid::Uuid::new_v4().into();
1126 let hooks = cap.tool_definition_hooks_with_context(&ctx_for(session), &json!({}));
1127
1128 let mut surface = many_tools(4);
1130 surface.push(builtin(
1131 "read_file",
1132 "Read the contents of a file",
1133 DeferrablePolicy::Automatic,
1134 ));
1135 let before = hooks[0].transform(surface.clone());
1136 assert!(is_stubbed(
1137 before.iter().find(|t| t.name() == "read_file").unwrap()
1138 ));
1139
1140 let mut registry = ToolRegistry::new();
1142 registry.register(MiniTool);
1143 let tool = &cap.tools()[0];
1144 let mut ctx = ToolContext::new(session);
1145 ctx.tool_registry = Some(Arc::new(registry));
1146 ctx.visible_tool_names = Some(Arc::new(["read_file".to_string()].into_iter().collect()));
1147
1148 let result = tool
1149 .execute_with_context(json!({ "query": "read file" }), &ctx)
1150 .await;
1151 let ToolExecutionResult::Success(value) = result else {
1152 panic!("expected success");
1153 };
1154 assert_eq!(value["loaded"][0], "read_file");
1155
1156 let after = hooks[0].transform(surface);
1158 assert!(
1159 !is_stubbed(after.iter().find(|t| t.name() == "read_file").unwrap()),
1160 "revealed tool's registered schema must be restored after tool_search"
1161 );
1162 }
1163
1164 struct HiddenTool;
1165 #[async_trait]
1166 impl Tool for HiddenTool {
1167 fn name(&self) -> &str {
1168 "write_file"
1169 }
1170 fn description(&self) -> &str {
1171 "Write contents to a file"
1172 }
1173 fn parameters_schema(&self) -> Value {
1174 json!({
1175 "type": "object",
1176 "properties": { "path": { "type": "string" } },
1177 "required": ["path"]
1178 })
1179 }
1180 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1181 ToolExecutionResult::success(json!({}))
1182 }
1183 }
1184
1185 #[test]
1191 fn benchmark_prompt_size_reduction() {
1192 use crate::capabilities::{
1193 BashkitShellCapability, Capability, CurrentTimeCapability, FileSystemCapability,
1194 SessionCapability, SessionStorageCapability, StatelessTodoListCapability,
1195 SubagentCapability, WebFetchCapability,
1196 };
1197
1198 let caps: Vec<Box<dyn Capability>> = vec![
1201 Box::new(CurrentTimeCapability),
1202 Box::new(FileSystemCapability),
1203 Box::new(BashkitShellCapability),
1204 Box::new(WebFetchCapability::from_env()),
1205 Box::new(SessionCapability),
1206 Box::new(SessionStorageCapability),
1207 Box::new(StatelessTodoListCapability),
1208 Box::new(SubagentCapability),
1209 ];
1210
1211 let mut defs: Vec<ToolDefinition> = caps
1212 .iter()
1213 .flat_map(|c| c.tools())
1214 .map(|t| t.to_definition())
1215 .collect();
1216 defs.push(ToolSearchTool::default().to_definition());
1218
1219 let llm_view = |defs: &[ToolDefinition]| -> usize {
1221 defs.iter()
1222 .map(|d| {
1223 json!({
1224 "name": d.name(),
1225 "description": d.description(),
1226 "parameters": d.parameters(),
1227 })
1228 .to_string()
1229 .len()
1230 })
1231 .sum()
1232 };
1233
1234 let total = defs.len();
1235 let full_bytes = llm_view(&defs);
1236
1237 let params_full: usize = defs.iter().map(|d| d.parameters().to_string().len()).sum();
1240
1241 let threshold = DEFAULT_TOOL_SEARCH_THRESHOLD;
1244 let deferred = hook(threshold).transform(defs);
1245 let deferred_count = deferred.iter().filter(|d| is_stubbed(d)).count();
1246 let deferred_bytes = llm_view(&deferred);
1247 let params_deferred: usize = deferred
1248 .iter()
1249 .map(|d| d.parameters().to_string().len())
1250 .sum();
1251
1252 assert!(
1255 deferred_bytes < full_bytes && params_deferred < params_full,
1256 "deferral must not grow the serialized surface \
1257 (tool list {full_bytes}->{deferred_bytes}, params {params_full}->{params_deferred})"
1258 );
1259
1260 let saved = full_bytes - deferred_bytes;
1261 let pct = (saved as f64 / full_bytes as f64) * 100.0;
1262 let params_pct = ((params_full - params_deferred) as f64 / params_full as f64) * 100.0;
1263 let approx_tokens_full = full_bytes / 4;
1265 let approx_tokens_deferred = deferred_bytes / 4;
1266
1267 eprintln!("tool-search prompt-size benchmark");
1268 eprintln!(" tools on surface .......... {total}");
1269 eprintln!(" schemas deferred .......... {deferred_count}");
1270 eprintln!(
1271 " full tool list ............ {full_bytes} bytes (~{approx_tokens_full} tokens)"
1272 );
1273 eprintln!(
1274 " deferred tool list ........ {deferred_bytes} bytes (~{approx_tokens_deferred} tokens)"
1275 );
1276 eprintln!(" tool-list saved ........... {saved} bytes ({pct:.0}%)");
1277 eprintln!(
1278 " parameter schemas ......... {params_full} -> {params_deferred} bytes ({params_pct:.0}% smaller)"
1279 );
1280
1281 assert!(
1283 total >= threshold,
1284 "surface should meet or exceed the default threshold ({total} < {threshold})"
1285 );
1286 assert!(
1287 pct > 45.0,
1288 "deferral should cut the whole tool list by a wide margin (was {pct:.0}%)"
1289 );
1290 assert!(
1291 params_pct > 70.0,
1292 "parameter schemas should compress substantially (was {params_pct:.0}%)"
1293 );
1294 }
1295
1296 #[tokio::test]
1297 async fn test_execute_filters_registry_to_visible_tools() {
1298 use crate::tools::ToolRegistry;
1299
1300 let mut registry = ToolRegistry::new();
1301 registry.register(MiniTool);
1302 registry.register(HiddenTool);
1303 registry.register(ToolSearchTool::default());
1304
1305 let mut ctx = ToolContext::new(uuid::Uuid::new_v4().into());
1306 ctx.tool_registry = Some(Arc::new(registry));
1307 ctx.visible_tool_names = Some(Arc::new(
1308 ["read_file".to_string(), TOOL_SEARCH_TOOL_NAME.to_string()]
1309 .into_iter()
1310 .collect(),
1311 ));
1312
1313 let result = ToolSearchTool::default()
1314 .execute_with_context(json!({ "query": "file" }), &ctx)
1315 .await;
1316
1317 let ToolExecutionResult::Success(value) = result else {
1318 panic!("expected success");
1319 };
1320 let tools = value["tools"].as_array().unwrap();
1321 assert!(tools.iter().any(|t| t["name"] == "read_file"));
1322 assert!(tools.iter().all(|t| t["name"] != "write_file"));
1323
1324 let result = ToolSearchTool::default()
1325 .execute_with_context(json!({ "query": "missing" }), &ctx)
1326 .await;
1327 let ToolExecutionResult::Success(value) = result else {
1328 panic!("expected success");
1329 };
1330 let available = value["available_tools"].as_array().unwrap();
1331 assert!(available.iter().any(|name| name == "read_file"));
1332 assert!(available.iter().all(|name| name != "write_file"));
1333 }
1334}