Skip to main content

everruns_core/capabilities/
tool_search.rs

1// Generic (provider-agnostic) Tool Search Capability
2//
3// Brings deferred tool loading to models that have no native tool_search
4// support (Anthropic, Gemini, OpenAI Completions, ...). Unlike
5// `openai_tool_search`, which relies on the OpenAI Responses API to hide
6// parameter schemas server-side, this capability implements tool search
7// entirely client-side and therefore works with any provider.
8//
9// How it works:
10//   1. A `tool_definition_hook` (`DeferSchemaHook`) runs at runtime-agent build
11//      time. When the agent carries at least `threshold` tools, it replaces the
12//      parameter schema of every deferrable tool with a minimal disclosure stub.
13//      The model still sees that the tool exists, while the single capability
14//      prompt points it to `tool_search` instead of repeating that instruction
15//      in every stub. Tools marked `DeferrablePolicy::Never` (e.g. high-frequency
16//      tools) and tools in the capability's `never_defer` allowlist keep full
17//      schemas.
18//   2. A real `tool_search` tool is added to the registry. When the model calls
19//      it, the tool inspects its sibling tools via `ToolContext::tool_registry`
20//      (the same mechanism `spawn_background` uses) and returns the full
21//      parameter schemas of the tools matching the query. It also records those
22//      tools as *revealed* for the calling session (see below).
23//   3. A short system-prompt note tells the model to call `tool_search` before
24//      using a tool whose parameters it has not loaded yet.
25//
26// Because the underlying tools stay registered and executable, tool calls and
27// results work exactly as before — the only difference is how schemas reach the
28// model. No driver or agent-loop changes are required.
29//
30// Progressive disclosure (session-scoped)
31// ---------------------------------------
32// Structured tool calling makes the model emit arguments against a tool's
33// *registered* schema. If a deferred tool's registered schema stayed the stub
34// forever, the model could read the real schema from a `tool_search` *result*
35// but still have no registered schema to emit arguments against. To close that
36// gap, `tool_search` records its matches as revealed; because the hook re-runs
37// when turn context is reassembled, a later pass advertises those tools with
38// their full, authoritative schema on the *registered* definition — so the model
39// can finally pass arguments. The permissive stub (`additionalProperties: true`)
40// remains as a belt-and-suspenders for the first call before a reveal lands.
41//
42// The capability is registered once and shared across every session/agent (see
43// `CapabilityRegistry::with_builtins`), so the revealed set MUST be keyed by
44// session — otherwise reveals would leak across sessions and gradually disable
45// deferral process-wide. `DeferSchemaHook::transform` has no session context of
46// its own, so the hook captures its `session_id` at construction time via
47// `Capability::tool_definition_hooks_with_context`, and the `tool_search` tool
48// keys writes by `ToolContext::session_id`. Both share one process-global
49// `RevealRegistry` (keyed by session, bounded by `MAX_REVEAL_SESSIONS`).
50//
51// Never-defer allowlist
52// ---------------------
53// `DeferrablePolicy::Never` lets a tool *owner* opt a tool out of deferral. But
54// an embedder that composes tools it does not own (e.g. file/shell tools from
55// another crate) cannot change their policy. `ToolSearchCapability::with_never_defer`
56// (and a `never_defer` config array) lets such an embedder keep hot-path tools
57// fully loaded by name, so the agent is never forced through a `tool_search`
58// round-trip before its first read/edit/shell call. Equivalent in effect to
59// marking those tools `DeferrablePolicy::Never`, but settable from outside.
60
61use 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
75/// Capability ID for the generic (provider-agnostic) tool search.
76pub const TOOL_SEARCH_CAPABILITY_ID: &str = "tool_search";
77
78/// Name of the tool the model calls to load deferred schemas.
79pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
80
81/// Maximum number of tools returned (and revealed) by a single `tool_search`
82/// call. Kept small: every returned tool is also recorded as *revealed*, which
83/// un-defers its full schema for the rest of the session (see "Progressive
84/// disclosure" above), so a loose query must not permanently un-defer a large
85/// slice of the catalogue.
86const MAX_SEARCH_RESULTS: usize = 8;
87
88/// Per-term score for a query term hit in the tool *name* vs its *description*.
89/// A name hit is a far stronger signal of intent than an incidental word in a
90/// description, so it is weighted higher.
91const NAME_TERM_WEIGHT: usize = 3;
92const DESC_TERM_WEIGHT: usize = 1;
93
94/// Dominant bonus when the whole query is exactly a tool name. This is the
95/// common "load this specific tool" path and that tool
96/// must rank first regardless of incidental matches elsewhere.
97const EXACT_NAME_BONUS: usize = 100;
98
99/// Upper bound on the number of sessions tracked in the revealed registry. The
100/// capability is a process-global singleton with no session-end callback, so the
101/// registry evicts the oldest sessions past this bound. An evicted session
102/// simply re-runs `tool_search` if it is still active — correct, just slightly
103/// less optimal. Each entry holds only a handful of tool-name strings.
104const MAX_REVEAL_SESSIONS: usize = 4096;
105
106/// Session-keyed set of tool names revealed via `tool_search`. Shared (by `Arc`)
107/// between the capability, its `DeferSchemaHook`s, and its `tool_search` tool so
108/// a reveal during tool execution is visible to the next context assembly for
109/// the *same* session. See the "Progressive disclosure" note above.
110#[derive(Default)]
111struct RevealRegistry {
112    sets: HashMap<SessionId, HashSet<String>>,
113    /// Insertion order of session keys, for bounded eviction.
114    order: VecDeque<SessionId>,
115}
116
117impl RevealRegistry {
118    /// Record `names` as revealed for `session`, evicting the oldest sessions if
119    /// the registry has grown past `MAX_REVEAL_SESSIONS`.
120    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        // Just inserted if absent, so the entry is always present here.
126        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    /// Names revealed for `session` so far (empty if none / evicted).
141    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
148/// Lock the revealed registry, recovering from a poisoned mutex rather than
149/// panicking: the revealed set is an optimization and must never take down agent
150/// construction or a worker.
151fn 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
166/// Generic Tool Search capability.
167///
168/// Adding this capability enables client-side deferred tool loading for any
169/// model. `threshold` controls the minimum number of tools before schemas are
170/// deferred (default: [`DEFAULT_TOOL_SEARCH_THRESHOLD`]). `never_defer` names
171/// tools that always keep their full schema (see [`Self::with_never_defer`]).
172pub 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    /// Keep the named tools' full parameter schemas even above the deferral
192    /// threshold. Use for hot-path tools (file/shell) so the agent is never
193    /// forced through a `tool_search` round-trip before its first call. This is
194    /// equivalent to marking each tool `DeferrablePolicy::Never`, but it can be
195    /// set by an embedder that does not own the tool definitions. Names from
196    /// config (`never_defer`) are merged with these at hook-build time.
197    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    /// Resolve the effective threshold and never-defer allowlist from config,
207    /// merging the config `never_defer` array onto the constructor set.
208    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            // No config override: reuse the constructor set without cloning.
223            _ => self.never_defer.clone(),
224        };
225        (threshold, never_defer)
226    }
227
228    /// Build a `DeferSchemaHook` for `session`, sharing this capability's
229    /// revealed registry.
230    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        // No collection context: progressive disclosure is keyed to an ephemeral
294        // session (deferral still works; reveals just won't restore). Production
295        // goes through `tool_definition_hooks_with_context`.
296        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
317// ============================================================================
318// DeferSchemaHook — strips parameter schemas from deferrable, unrevealed tools
319// ============================================================================
320
321/// Minimal open-object schema sent in place of a deferred tool's parameters.
322///
323/// The capability prompt owns the single instruction to use `tool_search`.
324/// Repeating a tool-specific copy inside every stub scales that sentence with
325/// the size of the tool catalogue and defeats much of the deferral saving.
326fn 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 this hook was built for; used to read the right reveal set.
338    session: SessionId,
339}
340
341impl DeferSchemaHook {
342    /// A tool keeps its full schema when it is the search tool itself, opts out
343    /// via `DeferrablePolicy::Never`, is in the embedder's `never_defer`
344    /// allowlist, or has already been revealed via `tool_search` this session.
345    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        // Below the threshold full schemas fit comfortably; don't defer.
357        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    // Mutually exclusive with hosted (openai) tool_search — see build().
376    fn applies_with_native_tool_search(&self) -> bool {
377        false
378    }
379}
380
381/// Replace a tool's parameter schema with the deferred disclosure stub, keeping
382/// name, description, policy, category, and hints intact. The original schema is
383/// saved in `full_parameters` so `tool_search` can return it on demand.
384fn 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// ============================================================================
404// Tool: tool_search
405// ============================================================================
406
407/// Tool that returns full parameter schemas for tools matching a query and
408/// records them as revealed (per session) so the schema hook restores them on
409/// the next pass.
410#[derive(Default)]
411pub struct ToolSearchTool {
412    revealed: SharedReveals,
413}
414
415impl ToolSearchTool {
416    /// Rank `defs` against `query` and return the best matches (full schemas).
417    ///
418    /// Scoring is keyword overlap with field weighting: each whitespace-separated
419    /// query term scores [`NAME_TERM_WEIGHT`] if it appears in the tool's name and
420    /// [`DESC_TERM_WEIGHT`] if it only appears in the description. A query that is
421    /// exactly a tool name gets [`EXACT_NAME_BONUS`] (the deferred stub nudges the
422    /// model to query the exact name to load a specific tool). Ties keep registry
423    /// order. Only the top score band is returned — every result is also *revealed*
424    /// (un-deferred) for the session, so weak tail matches are dropped rather than
425    /// permanently un-deferring loosely related tools. An empty query lists tools
426    /// so the model can browse. The search tool itself is always excluded.
427    fn search(defs: &[ToolDefinition], query: &str) -> Vec<Value> {
428        // Strip wrapping punctuation so an exact-name query still matches when the
429        // model echoes the stub's quoted/backticked tool name, e.g. `"read_file"`.
430        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                // Whole-query exact name match dominates everything else.
461                if normalized == name {
462                    score += EXACT_NAME_BONUS;
463                }
464                (score > 0).then_some((score, d))
465            })
466            .collect();
467
468        // Stable sort by descending score; equal scores keep registry order.
469        scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
470
471        // Keep only the top score band (>= half the best score). Scores are now
472        // monotonically non-increasing, so this trims the weak tail while always
473        // retaining the best match — bounding the sticky reveal set to tools that
474        // are genuinely close to what the model asked for.
475        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    // Never defer the search tool's own schema.
545    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            // No keyword hits — surface the catalogue (names only) so the model
601            // can refine its query instead of dead-ending.
602            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        // Record the matched tools as revealed for this session so the schema
616        // hook advertises their full schema on the *registered* definition next
617        // iteration. This is what lets a structured caller actually pass
618        // arguments to them.
619        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    /// A bare hook with an empty allowlist, a fresh registry, and a fresh session.
672    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    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
690
691    #[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        // Schemas untouched below threshold.
697        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            // The shared capability prompt carries the progressive-disclosure
708            // instruction once; each stub is only the provider-valid open object.
709            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        // Deferrable tools were stripped.
742        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        // An embedder allowlist keeps a tool full even though its policy is
749        // Automatic (i.e. the embedder does not own its definition).
750        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        // Constructor allowlist plus a config-provided one are both honored.
766        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        // Config lowers the threshold so deferral activates.
786        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        // The end-to-end progressive-disclosure invariant: once a tool is
795        // revealed for a session, its *registered* schema (not just the
796        // tool_search result text) is restored on the next hook pass for that
797        // session.
798        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        // First pass: tool_0 is deferred.
803        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        // Simulate tool_search revealing it for this session.
810        lock_reveals(&cap.revealed).reveal(session, ["tool_0".to_string()]);
811
812        // Next pass (same hook, re-run by the reason atom): full schema restored.
813        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        // A reveal in one session must not affect another session's hook (the
827        // capability is a process-global singleton shared across sessions).
828        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        // The oldest session was evicted once we exceeded the bound.
857        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        // MCP tools are deferred like regular tools. The full schema is saved
864        // in full_parameters so tool_search can return it on demand.
865        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        // Stub is sent to the model (parameters stripped).
877        assert!(
878            mcp.parameters().get("properties").is_none(),
879            "MCP tool schema is deferred"
880        );
881        // Full schema is preserved for tool_search to return.
882        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        // After DeferSchemaHook strips parameters, tool_search must still return
891        // the full schema (stored in full_parameters).
892        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        // full_parameters() is used, so real schema is returned — not the stub.
904        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        // Durable execution serializes reason output before scheduling act. The
913        // saved full schema must survive that boundary for deferred MCP proxies.
914        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        // Generic (client-side) deferral is mutually exclusive with hosted
945        // tool_search; build() uses this to skip the hook when native is active.
946        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        // Full parameter schema is returned, not the stub.
973        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        // A name hit outranks a description-only hit for the same term, and the
983        // weaker match falls outside the top score band entirely.
984        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        // Querying the exact tool name (as the deferred stub instructs) ranks that
996        // tool first and drops near-duplicates whose name merely contains it.
997        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        // The deferred stub and system prompt show the tool name in quotes, so the
1013        // model may echo a quoted/backticked name. Wrapping punctuation must not
1014        // defeat the exact-name bonus (otherwise it degrades to a substring match
1015        // and reveals near-duplicates).
1016        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        // A loose query that matches many tools is capped, bounding both the
1034        // payload and the (sticky) reveal set.
1035        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        // Full schema is returned (not the deferred stub).
1113        assert!(read["parameters"]["properties"]["path"].is_object());
1114    }
1115
1116    #[tokio::test]
1117    async fn test_search_records_reveal_and_restores_registered_schema() {
1118        // The cross-cutting invariant EVE-527 asked for: a tool_search call
1119        // reveals the matched tool for its session, and the *same* capability's
1120        // hook for that session then restores its registered schema on the next
1121        // pass.
1122        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        // Precondition: read_file is deferred among a surface above threshold.
1129        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        // Run the capability's own tool_search tool, scoped to this session.
1141        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        // Next pass: the registered schema for read_file is restored.
1157        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    /// Measure the prompt-size reduction from deferral on a realistic agent
1186    /// surface built from production capabilities. Prints a breakdown (run with
1187    /// `--nocapture`) and guards that deferral keeps cutting the tool-list size
1188    /// by a wide margin. The printed numbers back the benchmark table in
1189    /// `docs/capabilities/tool-search.md`; re-run this test to refresh them.
1190    #[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        // A representative generic-agent surface: file, shell, fetch, session,
1199        // storage, todo, time, and subagent tools.
1200        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        // Add the search tool itself, as the live capability does.
1217        defs.push(ToolSearchTool::default().to_definition());
1218
1219        // What the driver serializes per tool: name + description + parameters.
1220        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        // Raw parameter-schema bytes (what stripping actually compresses) on the
1238        // full surface, before deferral.
1239        let params_full: usize = defs.iter().map(|d| d.parameters().to_string().len()).sum();
1240
1241        // First model turn at the real default threshold: a surface this size is
1242        // above it, so every deferrable schema is stubbed.
1243        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        // Deferral must shrink the surface; guard against underflow so a
1253        // regression fails with a clear message instead of a subtraction panic.
1254        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        // ~4 chars/token is the usual rule of thumb for JSON tool schemas.
1264        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        // Sanity guard: a many-tool surface must shrink substantially.
1282        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}