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