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, but the stub points it back
14//      to `tool_search` for the full schema instead of exposing parameters
15//      upfront. 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. The deferred stub
95/// tells the model to call `tool_search` with the exact tool name to load its
96/// schema, so this is the common "load this specific tool" path and that tool
97/// must rank first regardless of incidental matches elsewhere.
98const EXACT_NAME_BONUS: usize = 100;
99
100/// Upper bound on the number of sessions tracked in the revealed registry. The
101/// capability is a process-global singleton with no session-end callback, so the
102/// registry evicts the oldest sessions past this bound. An evicted session
103/// simply re-runs `tool_search` if it is still active — correct, just slightly
104/// less optimal. Each entry holds only a handful of tool-name strings.
105const MAX_REVEAL_SESSIONS: usize = 4096;
106
107/// Session-keyed set of tool names revealed via `tool_search`. Shared (by `Arc`)
108/// between the capability, its `DeferSchemaHook`s, and its `tool_search` tool so
109/// a reveal during tool execution is visible to the next context assembly for
110/// the *same* session. See the "Progressive disclosure" note above.
111#[derive(Default)]
112struct RevealRegistry {
113    sets: HashMap<SessionId, HashSet<String>>,
114    /// Insertion order of session keys, for bounded eviction.
115    order: VecDeque<SessionId>,
116}
117
118impl RevealRegistry {
119    /// Record `names` as revealed for `session`, evicting the oldest sessions if
120    /// the registry has grown past `MAX_REVEAL_SESSIONS`.
121    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        // Just inserted if absent, so the entry is always present here.
127        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    /// Names revealed for `session` so far (empty if none / evicted).
142    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
149/// Lock the revealed registry, recovering from a poisoned mutex rather than
150/// panicking: the revealed set is an optimization and must never take down agent
151/// construction or a worker.
152fn 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
167/// Generic Tool Search capability.
168///
169/// Adding this capability enables client-side deferred tool loading for any
170/// model. `threshold` controls the minimum number of tools before schemas are
171/// deferred (default: [`DEFAULT_TOOL_SEARCH_THRESHOLD`]). `never_defer` names
172/// tools that always keep their full schema (see [`Self::with_never_defer`]).
173pub 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    /// Keep the named tools' full parameter schemas even above the deferral
193    /// threshold. Use for hot-path tools (file/shell) so the agent is never
194    /// forced through a `tool_search` round-trip before its first call. This is
195    /// equivalent to marking each tool `DeferrablePolicy::Never`, but it can be
196    /// set by an embedder that does not own the tool definitions. Names from
197    /// config (`never_defer`) are merged with these at hook-build time.
198    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    /// Resolve the effective threshold and never-defer allowlist from config,
208    /// merging the config `never_defer` array onto the constructor set.
209    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            // No config override: reuse the constructor set without cloning.
224            _ => self.never_defer.clone(),
225        };
226        (threshold, never_defer)
227    }
228
229    /// Build a `DeferSchemaHook` for `session`, sharing this capability's
230    /// revealed registry.
231    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        // No collection context: progressive disclosure is keyed to an ephemeral
295        // session (deferral still works; reveals just won't restore). Production
296        // goes through `tool_definition_hooks_with_context`.
297        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
318// ============================================================================
319// DeferSchemaHook — strips parameter schemas from deferrable, unrevealed tools
320// ============================================================================
321
322/// Stub schema sent in place of a deferred tool's real parameters.
323///
324/// An open object so the provider still accepts the tool definition; the
325/// description nudges the model toward `tool_search` if it somehow tries to
326/// call the tool before loading the schema.
327fn 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 this hook was built for; used to read the right reveal set.
342    session: SessionId,
343}
344
345impl DeferSchemaHook {
346    /// A tool keeps its full schema when it is the search tool itself, opts out
347    /// via `DeferrablePolicy::Never`, is in the embedder's `never_defer`
348    /// allowlist, or has already been revealed via `tool_search` this session.
349    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        // Below the threshold full schemas fit comfortably; don't defer.
361        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    // Mutually exclusive with hosted (openai) tool_search — see build().
380    fn applies_with_native_tool_search(&self) -> bool {
381        false
382    }
383}
384
385/// Replace a tool's parameter schema with the deferred disclosure stub, keeping
386/// name, description, policy, category, and hints intact. The original schema is
387/// saved in `full_parameters` so `tool_search` can return it on demand.
388fn 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// ============================================================================
408// Tool: tool_search
409// ============================================================================
410
411/// Tool that returns full parameter schemas for tools matching a query and
412/// records them as revealed (per session) so the schema hook restores them on
413/// the next pass.
414#[derive(Default)]
415pub struct ToolSearchTool {
416    revealed: SharedReveals,
417}
418
419impl ToolSearchTool {
420    /// Rank `defs` against `query` and return the best matches (full schemas).
421    ///
422    /// Scoring is keyword overlap with field weighting: each whitespace-separated
423    /// query term scores [`NAME_TERM_WEIGHT`] if it appears in the tool's name and
424    /// [`DESC_TERM_WEIGHT`] if it only appears in the description. A query that is
425    /// exactly a tool name gets [`EXACT_NAME_BONUS`] (the deferred stub nudges the
426    /// model to query the exact name to load a specific tool). Ties keep registry
427    /// order. Only the top score band is returned — every result is also *revealed*
428    /// (un-deferred) for the session, so weak tail matches are dropped rather than
429    /// permanently un-deferring loosely related tools. An empty query lists tools
430    /// so the model can browse. The search tool itself is always excluded.
431    fn search(defs: &[ToolDefinition], query: &str) -> Vec<Value> {
432        // Strip wrapping punctuation so an exact-name query still matches when the
433        // model echoes the stub's quoted/backticked tool name, e.g. `"read_file"`.
434        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                // Whole-query exact name match dominates everything else.
465                if normalized == name {
466                    score += EXACT_NAME_BONUS;
467                }
468                (score > 0).then_some((score, d))
469            })
470            .collect();
471
472        // Stable sort by descending score; equal scores keep registry order.
473        scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
474
475        // Keep only the top score band (>= half the best score). Scores are now
476        // monotonically non-increasing, so this trims the weak tail while always
477        // retaining the best match — bounding the sticky reveal set to tools that
478        // are genuinely close to what the model asked for.
479        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    // Never defer the search tool's own schema.
549    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            // No keyword hits — surface the catalogue (names only) so the model
605            // can refine its query instead of dead-ending.
606            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        // Record the matched tools as revealed for this session so the schema
620        // hook advertises their full schema on the *registered* definition next
621        // iteration. This is what lets a structured caller actually pass
622        // arguments to them.
623        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    /// A bare hook with an empty allowlist, a fresh registry, and a fresh session.
676    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    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
694
695    #[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        // Schemas untouched below threshold.
701        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            // Stub schema: no real properties, carries a progressive-disclosure hint.
712            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        // Deferrable tools were stripped.
753        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        // An embedder allowlist keeps a tool full even though its policy is
760        // Automatic (i.e. the embedder does not own its definition).
761        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        // Constructor allowlist plus a config-provided one are both honored.
777        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        // Config lowers the threshold so deferral activates.
797        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        // The end-to-end progressive-disclosure invariant: once a tool is
806        // revealed for a session, its *registered* schema (not just the
807        // tool_search result text) is restored on the next hook pass for that
808        // session.
809        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        // First pass: tool_0 is deferred.
814        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        // Simulate tool_search revealing it for this session.
821        lock_reveals(&cap.revealed).reveal(session, ["tool_0".to_string()]);
822
823        // Next pass (same hook, re-run by the reason atom): full schema restored.
824        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        // A reveal in one session must not affect another session's hook (the
838        // capability is a process-global singleton shared across sessions).
839        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        // The oldest session was evicted once we exceeded the bound.
868        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        // MCP tools are deferred like regular tools. The full schema is saved
875        // in full_parameters so tool_search can return it on demand.
876        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        // Stub is sent to the model (parameters stripped).
888        assert!(
889            mcp.parameters().get("properties").is_none(),
890            "MCP tool schema is deferred"
891        );
892        // Full schema is preserved for tool_search to return.
893        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        // After DeferSchemaHook strips parameters, tool_search must still return
902        // the full schema (stored in full_parameters).
903        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        // full_parameters() is used, so real schema is returned — not the stub.
915        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        // Durable execution serializes reason output before scheduling act. The
924        // saved full schema must survive that boundary for deferred MCP proxies.
925        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        // Generic (client-side) deferral is mutually exclusive with hosted
956        // tool_search; build() uses this to skip the hook when native is active.
957        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        // Full parameter schema is returned, not the stub.
984        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        // A name hit outranks a description-only hit for the same term, and the
994        // weaker match falls outside the top score band entirely.
995        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        // Querying the exact tool name (as the deferred stub instructs) ranks that
1007        // tool first and drops near-duplicates whose name merely contains it.
1008        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        // The deferred stub and system prompt show the tool name in quotes, so the
1024        // model may echo a quoted/backticked name. Wrapping punctuation must not
1025        // defeat the exact-name bonus (otherwise it degrades to a substring match
1026        // and reveals near-duplicates).
1027        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        // A loose query that matches many tools is capped, bounding both the
1045        // payload and the (sticky) reveal set.
1046        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        // Full schema is returned (not the deferred stub).
1124        assert!(read["parameters"]["properties"]["path"].is_object());
1125    }
1126
1127    #[tokio::test]
1128    async fn test_search_records_reveal_and_restores_registered_schema() {
1129        // The cross-cutting invariant EVE-527 asked for: a tool_search call
1130        // reveals the matched tool for its session, and the *same* capability's
1131        // hook for that session then restores its registered schema on the next
1132        // pass.
1133        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        // Precondition: read_file is deferred among a surface above threshold.
1140        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        // Run the capability's own tool_search tool, scoped to this session.
1152        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        // Next pass: the registered schema for read_file is restored.
1168        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    /// Measure the prompt-size reduction from deferral on a realistic agent
1197    /// surface built from production capabilities. Prints a breakdown (run with
1198    /// `--nocapture`) and guards that deferral keeps cutting the tool-list size
1199    /// by a wide margin. The printed numbers back the benchmark table in
1200    /// `docs/capabilities/tool-search.md`; re-run this test to refresh them.
1201    #[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        // A representative generic-agent surface: file, shell, fetch, session,
1210        // storage, todo, time, and subagent tools.
1211        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        // Add the search tool itself, as the live capability does.
1228        defs.push(ToolSearchTool::default().to_definition());
1229
1230        // What the driver serializes per tool: name + description + parameters.
1231        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        // Raw parameter-schema bytes (what stripping actually compresses) on the
1249        // full surface, before deferral.
1250        let params_full: usize = defs.iter().map(|d| d.parameters().to_string().len()).sum();
1251
1252        // First model turn at the real default threshold: a surface this size is
1253        // above it, so every deferrable schema is stubbed.
1254        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        // Deferral must shrink the surface; guard against underflow so a
1264        // regression fails with a clear message instead of a subtraction panic.
1265        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        // ~4 chars/token is the usual rule of thumb for JSON tool schemas.
1275        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        // Sanity guard: a many-tool surface must shrink substantially.
1293        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}