Skip to main content

anda_engine/context/
tool.rs

1//! Built-in callable discovery tools.
2//!
3//! The search and select helpers keep large tool schemas out of the prompt
4//! until the model asks for them. They are registered as lightweight agents
5//! whose outputs carry the matching [`FunctionDefinition`] values.
6
7use anda_core::{
8    Agent, AgentContext, AgentOutput, BoxError, CompletionFeatures, CompletionRequest,
9    FunctionDefinition, Json, Resource, ToolGroup, Usage,
10};
11use anda_db_tfs::{TokenizerChain, collect_tokens, jieba_tokenizer};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use std::collections::{BTreeMap, BTreeSet};
15
16use crate::context::{
17    AgentCtx, REMOTE_AGENT_PREFIX, REMOTE_TOOL_PREFIX, SUB_AGENT_PREFIX,
18    strip_prefix_ignore_ascii_case,
19};
20
21/// Built-in name for keyword search across available callables.
22pub const TOOLS_SEARCH_NAME: &str = "tools_search";
23/// Built-in name for selecting callables by exact name.
24pub const TOOLS_SELECT_NAME: &str = "tools_select";
25/// Built-in name for listing capability groups (the group directory).
26pub const TOOLS_GROUPS_NAME: &str = "tools_groups";
27
28/// Arguments for [`ToolsSearch`].
29#[derive(Debug, Clone, Default, Deserialize, Serialize)]
30pub struct ToolsSearchArgs {
31    /// Search terms, or `*` to enumerate every available callable name.
32    pub query: String,
33    /// Maximum number of results to return. Defaults to `10`.
34    #[serde(default)]
35    pub limit: usize,
36}
37
38/// Output returned by callable discovery tools.
39#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct ToolsOutput {
41    /// Matching callable definitions returned as tool-output context.
42    ///
43    /// These definitions are not dynamically inserted into [`CompletionRequest::tools`].
44    pub tools: Vec<FunctionDefinition>,
45    /// Capability groups that the returned tools belong to.
46    ///
47    /// Each group bundles related tools from a single source (for example one
48    /// MCP server) and carries the bundle's purpose and usage instructions plus
49    /// the full list of sibling member names, so the model can understand how
50    /// the tools combine and select additional members it still needs.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub groups: Vec<ToolGroup>,
53    /// Total number of callables to the current model turn.
54    #[serde(default)]
55    pub total_tools: usize,
56}
57
58/// Upper bound on the definitions taken from a *single* discovery-tool output.
59///
60/// Successive rounds are additionally bounded to 128 definitions and 256 KiB
61/// of complete schemas. No schema is weakened to fit the budget.
62const MAX_DISCOVERED_REQUEST_TOOLS: usize = 16;
63const MAX_ACCUMULATED_TOOLS: usize = 128;
64const MAX_DISCOVERY_BYTES: usize = 256 * 1024;
65
66// Keep complete schemas or omit them; never silently weaken their constraints.
67fn bounded_discovery(mut output: ToolsOutput) -> ToolsOutput {
68    let mut used = 128usize;
69    output.tools.retain(|tool| {
70        let bytes = serde_json::to_vec(tool).map_or(usize::MAX, |v| v.len().saturating_add(1));
71        if bytes > MAX_DISCOVERY_BYTES.saturating_sub(used) {
72            false
73        } else {
74            used += bytes;
75            true
76        }
77    });
78    output.groups.retain(|group| {
79        let bytes = serde_json::to_vec(group).map_or(usize::MAX, |v| v.len().saturating_add(1));
80        if bytes > MAX_DISCOVERY_BYTES.saturating_sub(used) {
81            false
82        } else {
83            used += bytes;
84            true
85        }
86    });
87    output
88}
89
90/// Runner-side state and policy for tool discovery.
91///
92/// Owns everything the completion runner must otherwise know about the
93/// discovery agents: which tool names perform discovery, the shape of their
94/// output, when discovered definitions get merged into later request tool
95/// lists, and how discovery output is compacted once merged. The runner holds
96/// one of these and delegates, so the discovery vocabulary lives entirely in
97/// this module.
98///
99/// The merge policy is `Some(true)` to force request-side merging,
100/// `Some(false)` to keep schemas only in discovery-tool output context, and
101/// `None` to probe: a tool selected twice through `tools_select` signals that
102/// the current model needs request-side merging.
103#[derive(Debug, Clone, Default)]
104pub struct DiscoveredTools {
105    definitions: BTreeMap<String, FunctionDefinition>,
106    known_names: BTreeSet<String>,
107    selection_counts: BTreeMap<String, usize>,
108    merge: Option<bool>,
109}
110
111impl DiscoveredTools {
112    /// Returns the current merge policy.
113    pub fn merge_policy(&self) -> Option<bool> {
114        self.merge
115    }
116
117    /// Sets the merge policy (see the type docs for the three states).
118    pub fn set_merge_policy(&mut self, merge: Option<bool>) {
119        self.merge = merge;
120    }
121
122    /// Whether the lowercased name was legitimately discovered this round.
123    pub fn contains(&self, lowercase_name: &str) -> bool {
124        self.known_names.contains(lowercase_name)
125    }
126
127    /// Forgets accumulated definitions and probe counts, keeping the policy.
128    pub fn reset_definitions(&mut self) {
129        self.definitions.clear();
130        self.known_names.clear();
131        self.selection_counts.clear();
132    }
133
134    /// Records definitions returned by a discovery tool's output.
135    ///
136    /// Non-discovery tools and outputs that do not parse as [`ToolsOutput`]
137    /// are ignored. In probe mode (`None`), a repeated `tools_select` of the
138    /// same name flips the policy to `Some(true)`.
139    pub fn observe_output(&mut self, tool_name: &str, output: &Json) {
140        if !tool_name.eq_ignore_ascii_case(TOOLS_SELECT_NAME)
141            && !tool_name.eq_ignore_ascii_case(TOOLS_SEARCH_NAME)
142        {
143            return;
144        }
145
146        let Ok(tools_output) = ToolsOutput::deserialize(output) else {
147            return;
148        };
149
150        let count_selection =
151            tool_name.eq_ignore_ascii_case(TOOLS_SELECT_NAME) && self.merge.is_none();
152        let mut added = 0;
153        let mut seen = BTreeSet::new();
154        let mut current_size = self.definitions.values().fold(0usize, |size, definition| {
155            size.saturating_add(serde_json::to_vec(definition).map_or(usize::MAX, |v| v.len()))
156        });
157        for definition in tools_output.tools {
158            // Wildcard search returns directory entries without parameter schemas.
159            if definition.name.trim().is_empty()
160                || (tool_name.eq_ignore_ascii_case(TOOLS_SEARCH_NAME)
161                    && definition
162                        .parameters
163                        .as_object()
164                        .is_some_and(|p| p.is_empty())
165                    && definition.strict.is_none())
166            {
167                continue;
168            }
169
170            let key = definition.name.to_ascii_lowercase();
171            if !seen.insert(key.clone()) {
172                continue;
173            }
174            // The model saw this schema in the discovery output, so it stays callable even
175            // when the accumulated request-merge budget below cannot retain it.
176            self.known_names.insert(key.clone());
177            if count_selection {
178                let count = self
179                    .selection_counts
180                    .entry(key.clone())
181                    .and_modify(|count| *count += 1)
182                    .or_insert(1);
183                if *count >= 2 {
184                    self.merge = Some(true);
185                }
186            }
187            let old_size = self.definitions.get(&key).map_or(0, |definition| {
188                serde_json::to_vec(definition).map_or(usize::MAX, |v| v.len())
189            });
190            let new_size = serde_json::to_vec(&definition).map_or(usize::MAX, |v| v.len());
191            if (!self.definitions.contains_key(&key)
192                && self.definitions.len() >= MAX_ACCUMULATED_TOOLS)
193                || current_size
194                    .saturating_sub(old_size)
195                    .saturating_add(new_size)
196                    > MAX_DISCOVERY_BYTES
197            {
198                continue;
199            }
200            if self.definitions.contains_key(&key) || added < MAX_DISCOVERED_REQUEST_TOOLS {
201                let is_new = self.definitions.insert(key, definition).is_none();
202                current_size = current_size
203                    .saturating_sub(old_size)
204                    .saturating_add(new_size);
205                if is_new {
206                    added += 1;
207                }
208            }
209        }
210    }
211
212    /// Adds discovered definitions to the request tool list when merging is on.
213    pub fn merge_into_request(&self, req: &mut CompletionRequest) {
214        if self.merge != Some(true) || self.definitions.is_empty() {
215            return;
216        }
217
218        let mut seen: BTreeSet<String> = req
219            .tools
220            .iter()
221            .map(|tool| tool.name.to_ascii_lowercase())
222            .collect();
223        for (name, definition) in &self.definitions {
224            if seen.insert(name.clone()) {
225                req.tools.push(definition.clone());
226            }
227        }
228    }
229
230    /// Compacts a discovery tool output in place once discovered definitions
231    /// are merged into the request tools, so full schemas are not duplicated
232    /// in the conversation context. Non-discovery outputs are left untouched.
233    pub fn compact_output_for_context(&self, tool_name: &str, output: &mut Json) {
234        self.compact_output_for_request(tool_name, output, &[]);
235    }
236
237    pub(crate) fn compact_output_for_request(
238        &self,
239        tool_name: &str,
240        output: &mut Json,
241        request_tools: &[FunctionDefinition],
242    ) {
243        if self.merge != Some(true) {
244            return;
245        }
246
247        let keep_description = if tool_name.eq_ignore_ascii_case(TOOLS_SEARCH_NAME) {
248            true
249        } else if tool_name.eq_ignore_ascii_case(TOOLS_SELECT_NAME) {
250            false
251        } else {
252            return;
253        };
254
255        let Ok(tools_output) = ToolsOutput::deserialize(&*output) else {
256            return;
257        };
258
259        let tools = tools_output
260            .tools
261            .into_iter()
262            .map(|definition| {
263                let effective = request_tools
264                    .iter()
265                    .find(|tool| tool.name.eq_ignore_ascii_case(&definition.name))
266                    .or_else(|| self.definitions.get(&definition.name.to_ascii_lowercase()));
267                if !effective.is_some_and(|tool| {
268                    tool.parameters == definition.parameters && tool.strict == definition.strict
269                }) {
270                    return json!(definition);
271                }
272                if keep_description {
273                    json!({
274                        "name": definition.name,
275                        "description": definition.description,
276                    })
277                } else {
278                    json!({
279                        "name": definition.name,
280                    })
281                }
282            })
283            .collect::<Vec<_>>();
284
285        *output = json!({
286            "tools": tools,
287            "total_tools": tools_output.total_tools,
288        });
289        if !tools_output.groups.is_empty() {
290            output["groups"] = json!(tools_output.groups);
291        }
292    }
293}
294
295/// Searches the callable surface currently available to the model.
296/// The "tools_search" tool has been registered as a built-in agent with label "flash".
297pub struct ToolsSearch {
298    tokenizer: TokenizerChain,
299}
300
301impl Default for ToolsSearch {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307impl ToolsSearch {
308    /// Function name used when registering the search helper.
309    pub const NAME: &'static str = TOOLS_SEARCH_NAME;
310
311    /// Creates a search helper with the default tokenizer.
312    pub fn new() -> Self {
313        let tokenizer = jieba_tokenizer();
314        Self { tokenizer }
315    }
316
317    /// Searches candidate definitions by name, description, and token overlap.
318    pub fn search(&self, candidates: &[FunctionDefinition], args: &ToolsSearchArgs) -> ToolsOutput {
319        let normalized_query = args.query.trim().to_lowercase();
320        let total_tools = candidates.len();
321        if normalized_query == "*" {
322            // Wildcard enumerates names only (name + description, no schema) so
323            // listing everything stays cheap; the model calls a specific tool or
324            // a keyword search to obtain the full schema. Capped like any search.
325            let tools: Vec<FunctionDefinition> = candidates
326                .iter()
327                .take(MAX_SEARCH_RESULTS)
328                .map(|definition| FunctionDefinition {
329                    name: definition.name.clone(),
330                    description: definition.description.clone(),
331                    parameters: json!({}),
332                    strict: None,
333                })
334                .collect();
335            return ToolsOutput {
336                tools,
337                total_tools,
338                ..Default::default()
339            };
340        }
341
342        let normalized_tokens: Vec<(String, usize)> =
343            collect_tokens(&mut self.tokenizer.clone(), &normalized_query, None)
344                .into_iter()
345                .collect();
346
347        let mut tools_name =
348            rank_search_items(candidates, &normalized_query, &normalized_tokens, false);
349        let limit = if args.limit == 0 {
350            10
351        } else {
352            args.limit.min(MAX_SEARCH_RESULTS)
353        };
354        tools_name.truncate(limit);
355        let mut index = BTreeMap::new();
356        for definition in candidates {
357            index
358                .entry(definition.name.to_ascii_lowercase())
359                .or_insert(definition);
360        }
361        let tools = tools_name
362            .iter()
363            .filter_map(|name| index.remove(name).cloned())
364            .collect();
365        ToolsOutput {
366            tools,
367            total_tools,
368            ..Default::default()
369        }
370    }
371}
372
373impl Agent<AgentCtx> for ToolsSearch {
374    fn name(&self) -> String {
375        Self::NAME.to_string()
376    }
377
378    fn description(&self) -> String {
379        "Search callable tools and agents by keyword. Returns full callable schemas in this tool output; after a schema is returned, call that tool/agent directly instead of searching again. The output may also include `groups`: related tool bundles (for example one MCP server) with their purpose, usage instructions, and sibling member names to help you combine them."
380            .to_string()
381    }
382
383    fn definition(&self) -> FunctionDefinition {
384        FunctionDefinition {
385            name: self.name(),
386            description: self.description(),
387            parameters: json!({
388                "type": "object",
389                "properties": {
390                    "query": {
391                        "type": "string",
392                        "description": "Search terms for callable tools/agents, or `*` to list every available callable name. Do not repeat the same search after the needed schema is returned.",
393                    },
394                    "limit": {
395                        "type": "integer",
396                        "description": "Maximum number of matches to return. Defaults to `10`."
397                    }
398                },
399                "required": ["query", "limit"],
400                "additionalProperties": false
401            }),
402            strict: Some(true),
403        }
404    }
405
406    async fn run(
407        &self,
408        ctx: AgentCtx,
409        prompt: String,
410        _resources: Vec<Resource>,
411    ) -> Result<AgentOutput, BoxError> {
412        let args: ToolsSearchArgs = match serde_json::from_str(&prompt) {
413            Ok(v) => v,
414            Err(e) => {
415                return Ok(AgentOutput {
416                    content: format!("Invalid input: {e}"),
417                    ..Default::default()
418                });
419            }
420        };
421
422        let definitions = ctx.definitions(None).await;
423        if args.query.trim().is_empty() || definitions.is_empty() {
424            return Ok(AgentOutput {
425                content: serde_json::to_string(&ToolsOutput {
426                    tools: Vec::new(),
427                    total_tools: definitions.len(),
428                    ..Default::default()
429                })?,
430                ..Default::default()
431            });
432        }
433        let mut rt = self.search(&definitions, &args);
434        rt.groups = relevant_groups(ctx.tool_groups(), &rt.tools);
435        let rt = bounded_discovery(rt);
436        Ok(AgentOutput {
437            content: serde_json::to_string(&rt)?,
438            ..Default::default()
439        })
440    }
441}
442
443/// Arguments for [`ToolsSelect`].
444#[derive(Debug, Clone, Default, Deserialize, Serialize)]
445pub struct ToolsSelectArgs {
446    /// Callable names whose schemas should be returned for direct calls.
447    #[serde(default)]
448    pub tools: Vec<String>,
449    /// Natural-language intent used to select tools when exact names are unknown.
450    #[serde(default)]
451    pub query: String,
452    /// Capability group id to expand. When set, every member of that group is
453    /// returned, on top of any names listed in `tools`. Discover group ids with
454    /// `tools_groups`.
455    #[serde(default)]
456    pub group: String,
457    /// Maximum number of resolved definitions to return. Defaults to `5`, and is capped at `16` to prevent overloading the next model turn.
458    #[serde(default)]
459    pub limit: usize,
460}
461
462#[derive(Debug, Clone, Default, Deserialize, Serialize)]
463struct ToolsSelectNamesOutput {
464    #[serde(default)]
465    tools: Vec<String>,
466}
467
468/// Returns a subset of callable definitions for direct use in the next reasoning turn.
469/// The "tools_select" tool has been registered as a built-in agent with label "flash".
470pub struct ToolsSelect {
471    tokenizer: TokenizerChain,
472}
473
474impl Default for ToolsSelect {
475    fn default() -> Self {
476        Self::new()
477    }
478}
479
480const MAX_SELECTOR_LIMIT: usize = 16;
481const MAX_SELECTOR_CANDIDATE_LIMIT: usize = 1000;
482/// Upper bound on the number of matches [`ToolsSearch`] returns, so a single
483/// search (including the `*` wildcard) cannot blow up the context window.
484const MAX_SEARCH_RESULTS: usize = 64;
485
486impl ToolsSelect {
487    /// Function name used when registering the selection helper.
488    pub const NAME: &'static str = TOOLS_SELECT_NAME;
489
490    /// Creates a callable selection helper.
491    pub fn new() -> Self {
492        Self {
493            tokenizer: jieba_tokenizer(),
494        }
495    }
496
497    async fn select_requested_definitions_by_query(
498        &self,
499        ctx: &AgentCtx,
500        definitions: Vec<FunctionDefinition>,
501        args: &ToolsSelectArgs,
502    ) -> (Vec<FunctionDefinition>, Usage) {
503        let normalized_query = args.query.trim().to_lowercase();
504        let limit = if args.limit > 0 {
505            args.limit.min(MAX_SELECTOR_LIMIT)
506        } else {
507            5
508        };
509        let names = self.collect_query_candidates(
510            &definitions,
511            &normalized_query,
512            MAX_SELECTOR_CANDIDATE_LIMIT,
513        );
514        let candidates = select_requested_definitions(definitions, &names);
515        if candidates.is_empty() || normalized_query.len() <= 3 {
516            return (
517                candidates.into_iter().take(limit).collect(),
518                Usage::default(),
519            );
520        }
521        let (requested, usage) =
522            select_requested_names_with_model(ctx, &candidates, &normalized_query, limit).await;
523        let allowed: BTreeSet<_> = candidates
524            .iter()
525            .map(|def| def.name.to_ascii_lowercase())
526            .collect();
527        let requested: Vec<_> = requested
528            .into_iter()
529            .filter(|name| allowed.contains(&name.trim().to_ascii_lowercase()))
530            .collect();
531        let tools = if requested.is_empty() {
532            candidates.into_iter().take(limit).collect()
533        } else {
534            select_requested_definitions(candidates, &requested)
535                .into_iter()
536                .take(limit)
537                .collect()
538        };
539        (tools, usage)
540    }
541
542    fn collect_query_candidates(
543        &self,
544        items: &[FunctionDefinition],
545        query: &str,
546        candidate_limit: usize,
547    ) -> Vec<String> {
548        let mut rt = if !query.is_empty() && query != "*" {
549            // (lowercase token, weight)
550            let normalized_tokens: Vec<(String, usize)> =
551                collect_tokens(&mut self.tokenizer.clone(), query, None)
552                    .into_iter()
553                    .collect();
554
555            rank_search_items(items, query, &normalized_tokens, true)
556        } else {
557            items
558                .iter()
559                .take(candidate_limit)
560                .map(|item| item.name.clone())
561                .collect::<Vec<_>>()
562        };
563
564        rt.truncate(candidate_limit);
565        rt
566    }
567}
568
569impl Agent<AgentCtx> for ToolsSelect {
570    fn name(&self) -> String {
571        Self::NAME.to_string()
572    }
573
574    fn description(&self) -> String {
575        "Select callable tools or agents and return full schemas in this tool output for direct tool calls. Use exact names via `tools`; use `query` only when exact names are unknown; use `group` to expand a capability group within the discovery byte budget (discover group ids with `tools_groups`). Do not call tools_select again for the same returned tools. The output may also include `groups`: related tool bundles (for example one MCP server) with their purpose, usage instructions, and sibling member names.".to_string()
576    }
577
578    fn definition(&self) -> FunctionDefinition {
579        FunctionDefinition {
580            name: self.name(),
581            description: self.description(),
582            parameters: json!({
583                "type": "object",
584                "properties": {
585                    "tools": {
586                        "type": "array",
587                        "items": {
588                            "type": "string"
589                        },
590                        "description": "Exact callable names to select. After these schemas are returned in this tool output, call the selected tools/agents directly."
591                    },
592                    "query": {
593                        "type": "string",
594                        "description": "Natural-language intent for selecting relevant callables when exact names are unknown. Prefer `tools` when exact names are known."
595                    },
596                    "group": {
597                        "type": "string",
598                        "description": "Capability group id to expand. Returns member tool schemas within the discovery byte budget; select omitted tools by exact name. Use an empty string when not selecting by group."
599                    },
600                    "limit": {
601                        "type": "integer",
602                        "description": "Maximum number of resolved callables to return for `query` selection. Defaults to `5`, and is capped at `16`."
603                    }
604                },
605                "required": ["tools", "query", "group", "limit"],
606                "additionalProperties": false
607            }),
608            strict: Some(true),
609        }
610    }
611
612    async fn run(
613        &self,
614        ctx: AgentCtx,
615        prompt: String,
616        _resources: Vec<Resource>,
617    ) -> Result<AgentOutput, BoxError> {
618        let args: ToolsSelectArgs = match serde_json::from_str(&prompt) {
619            Ok(v) => v,
620            Err(e) => {
621                return Ok(AgentOutput {
622                    content: format!("Invalid input: {e}"),
623                    ..Default::default()
624                });
625            }
626        };
627
628        if args.tools.is_empty() && args.query.trim().is_empty() && args.group.trim().is_empty() {
629            return Ok(AgentOutput {
630                content: "Invalid input: one of `tools`, `query`, or `group` must be provided"
631                    .to_string(),
632                ..Default::default()
633            });
634        }
635
636        let definitions = ctx.definitions(None).await;
637        let total_tools = definitions.len();
638        let all_groups = ctx.tool_groups();
639
640        // Explicit names plus, when a group is named, every member of that group.
641        let mut requested = args.tools.clone();
642        if !args.group.trim().is_empty()
643            && let Some(group) = all_groups
644                .iter()
645                .find(|group| group.id.eq_ignore_ascii_case(args.group.trim()))
646        {
647            requested.extend(group.members.iter().cloned());
648        }
649
650        let (tool_definitions, usage) = if !requested.is_empty() {
651            (
652                select_requested_definitions(definitions, &requested),
653                Usage::default(),
654            )
655        } else {
656            self.select_requested_definitions_by_query(&ctx, definitions, &args)
657                .await
658        };
659
660        let groups = relevant_groups(all_groups, &tool_definitions);
661        Ok(AgentOutput {
662            content: serde_json::to_string(&bounded_discovery(ToolsOutput {
663                tools: tool_definitions,
664                groups,
665                total_tools,
666            }))?,
667            usage,
668            ..Default::default()
669        })
670    }
671}
672
673/// Arguments for [`ToolsGroups`].
674#[derive(Debug, Clone, Default, Deserialize, Serialize)]
675pub struct ToolsGroupsArgs {
676    /// Optional keyword filter. Empty or `*` lists every group.
677    #[serde(default)]
678    pub query: String,
679}
680
681/// One entry in the capability group directory returned by [`ToolsGroups`].
682#[derive(Debug, Clone, Default, Deserialize, Serialize)]
683pub struct ToolGroupSummary {
684    /// Stable group id; pass it to `tools_select`'s `group` to expand the bundle.
685    pub id: String,
686    /// Human-facing group title.
687    pub title: String,
688    /// Concise summary of what this bundle of tools does.
689    pub description: String,
690    /// Number of member tools in the group.
691    pub member_count: usize,
692}
693
694/// Output returned by [`ToolsGroups`].
695#[derive(Debug, Clone, Default, Deserialize, Serialize)]
696pub struct ToolGroupsOutput {
697    /// The matching capability groups, without per-tool schemas.
698    pub groups: Vec<ToolGroupSummary>,
699    /// Total number of capability groups available this turn.
700    #[serde(default)]
701    pub total_groups: usize,
702}
703
704/// Lists the capability groups available to the model as a compact directory.
705///
706/// This is the top of the discovery funnel: the model sees which related tool
707/// bundles exist (one MCP server, the filesystem tools, …) without paying for
708/// every tool schema, then expands a chosen bundle with `tools_select`'s `group`
709/// argument. Registered as a built-in agent with label "flash".
710pub struct ToolsGroups;
711
712impl Default for ToolsGroups {
713    fn default() -> Self {
714        Self::new()
715    }
716}
717
718impl ToolsGroups {
719    /// Function name used when registering the group directory helper.
720    pub const NAME: &'static str = TOOLS_GROUPS_NAME;
721
722    /// Creates a group directory helper.
723    pub fn new() -> Self {
724        Self
725    }
726}
727
728impl Agent<AgentCtx> for ToolsGroups {
729    fn name(&self) -> String {
730        Self::NAME.to_string()
731    }
732
733    fn description(&self) -> String {
734        "List the capability groups available this turn as a compact directory (no tool schemas). A group is a related bundle of tools from one source, such as a single MCP server or the filesystem tools. Use this first to see which bundles exist, then call `tools_select` with the `group` id to expand a bundle into its tool schemas.".to_string()
735    }
736
737    fn definition(&self) -> FunctionDefinition {
738        FunctionDefinition {
739            name: self.name(),
740            description: self.description(),
741            parameters: json!({
742                "type": "object",
743                "properties": {
744                    "query": {
745                        "type": "string",
746                        "description": "Optional keyword to filter groups by id, title, or description. Use an empty string or `*` to list every group."
747                    }
748                },
749                "required": ["query"],
750                "additionalProperties": false
751            }),
752            strict: Some(true),
753        }
754    }
755
756    async fn run(
757        &self,
758        ctx: AgentCtx,
759        prompt: String,
760        _resources: Vec<Resource>,
761    ) -> Result<AgentOutput, BoxError> {
762        let args: ToolsGroupsArgs = match serde_json::from_str(&prompt) {
763            Ok(v) => v,
764            Err(e) => {
765                return Ok(AgentOutput {
766                    content: format!("Invalid input: {e}"),
767                    ..Default::default()
768                });
769            }
770        };
771
772        let groups = ctx.tool_groups();
773        let total_groups = groups.len();
774        let normalized = args.query.trim().to_lowercase();
775        let summaries = groups
776            .into_iter()
777            .filter(|group| {
778                normalized.is_empty()
779                    || normalized == "*"
780                    || group.id.to_lowercase().contains(&normalized)
781                    || group.title.to_lowercase().contains(&normalized)
782                    || group.description.to_lowercase().contains(&normalized)
783            })
784            .map(|group| ToolGroupSummary {
785                id: group.id,
786                title: group.title,
787                description: group.description,
788                member_count: group.members.len(),
789            })
790            .collect();
791
792        Ok(AgentOutput {
793            content: serde_json::to_string(&ToolGroupsOutput {
794                groups: summaries,
795                total_groups,
796            })?,
797            ..Default::default()
798        })
799    }
800}
801
802#[derive(Debug, Serialize)]
803struct ToolItemRef<'a> {
804    pub name: &'a str,
805    pub description: &'a str,
806}
807
808impl<'a> From<&'a FunctionDefinition> for ToolItemRef<'a> {
809    fn from(def: &'a FunctionDefinition) -> Self {
810        Self {
811            name: &def.name,
812            description: &def.description,
813        }
814    }
815}
816
817async fn select_requested_names_with_model(
818    ctx: &AgentCtx,
819    candidates: &[FunctionDefinition],
820    query: &str,
821    limit: usize,
822) -> (Vec<String>, Usage) {
823    let tools = candidates.iter().map(ToolItemRef::from).collect::<Vec<_>>();
824    let req = CompletionRequest {
825        instructions: "You are selecting callable tools or agents for the next model turn. Choose only from the provided candidates. Prefer the smallest set that can plausibly help with the user intent. Return exact candidate names only. Never invent names. If no candidate is relevant, return an empty list.".to_string(),
826        prompt: format!(
827            "# User intent:\n{:?}\n\n---\n\n# Task:\n\nSelect up to {} callable names from the candidate list below. Return JSON only.\n\nCandidate callables:\n{}",
828            query,
829            limit,
830            serde_json::to_string(&tools).unwrap_or_default()
831        ),
832        output_schema: Some(json!({
833            "type": "object",
834            "properties": {
835                "tools": {
836                    "type": "array",
837                    "items": {
838                        "type": "string"
839                    }
840                }
841            },
842            "required": ["tools"],
843            "additionalProperties": false
844        })),
845        temperature: Some(0.0),
846        ..Default::default()
847    };
848
849    match ctx.completion(req, Vec::new()).await {
850        Ok(output) => {
851            let names = if output.failed_reason.is_none() {
852                parse_selector_tool_names(output.content.trim())
853            } else {
854                Vec::new()
855            };
856            (names, output.usage)
857        }
858        Err(_) => (
859            Vec::new(),
860            Usage {
861                requests: 1,
862                ..Default::default()
863            },
864        ),
865    }
866}
867
868fn parse_selector_tool_names(content: &str) -> Vec<String> {
869    for candidate in json_candidates(content) {
870        if let Ok(output) = serde_json::from_str::<ToolsSelectNamesOutput>(&candidate) {
871            return output.tools;
872        }
873        if let Ok(output) = serde_json::from_str::<Vec<String>>(&candidate) {
874            return output;
875        }
876    }
877
878    Vec::new()
879}
880
881/// Extracts JSON candidates from the input string by applying various heuristics, such as stripping markdown code blocks and extracting JSON-like substrings. This increases the chances of successfully parsing the model output even when it is not perfectly formatted.
882pub fn json_candidates(content: &str) -> Vec<String> {
883    let mut candidates = Vec::new();
884    let mut seen = BTreeSet::new();
885
886    for candidate in [
887        Some(content.to_string()),
888        strip_markdown_code_block(content),
889        extract_json_block(content, '{', '}'),
890        extract_json_block(content, '[', ']'),
891    ]
892    .into_iter()
893    .flatten()
894    {
895        if !candidate.is_empty() && seen.insert(candidate.clone()) {
896            candidates.push(candidate);
897        }
898    }
899
900    candidates
901}
902
903fn strip_markdown_code_block(content: &str) -> Option<String> {
904    if !content.starts_with("```") || !content.ends_with("```") {
905        return None;
906    }
907
908    let stripped = content
909        .strip_prefix("```json")
910        .or_else(|| content.strip_prefix("```JSON"))
911        .or_else(|| content.strip_prefix("```"))?
912        .strip_suffix("```")?
913        .trim();
914
915    Some(stripped.to_string())
916}
917
918fn extract_json_block(content: &str, open: char, close: char) -> Option<String> {
919    let start = content.find(open)?;
920    let end = content.rfind(close)?;
921    if end < start {
922        return None;
923    }
924
925    Some(content[start..=end].trim().to_string())
926}
927
928const NAME_EXACT_MATCH_BONUS: usize = 10_000;
929const NAME_PREFIX_MATCH_BONUS: usize = 2_500;
930const NAME_SUBSTRING_MATCH_BONUS: usize = 500;
931const TOKEN_NAME_MATCH_WEIGHT: usize = 100;
932const TOKEN_DESCRIPTION_MATCH_WEIGHT: usize = 10;
933
934fn rank_search_items(
935    items: &[FunctionDefinition],
936    normalized_query: &str,
937    normalized_tokens: &[(String, usize)],
938    fallback: bool,
939) -> Vec<String> {
940    let mut candidates: Vec<(bool, usize, String)> = Vec::new();
941    for item in items {
942        // Returned names must stay the full callable names so that definition
943        // lookups succeed; the routing prefix is only ignored for matching.
944        let normalized_name = item.name.to_lowercase();
945        let stripped_name = strip_prefix_ignore_ascii_case(&normalized_name, SUB_AGENT_PREFIX)
946            .or_else(|| strip_prefix_ignore_ascii_case(&normalized_name, REMOTE_AGENT_PREFIX))
947            .or_else(|| strip_prefix_ignore_ascii_case(&normalized_name, REMOTE_TOOL_PREFIX))
948            .unwrap_or(&normalized_name);
949        let normalized_description = item.description.to_lowercase();
950        let mut score = 0usize;
951
952        let exact_name_match =
953            normalized_name == normalized_query || stripped_name == normalized_query;
954        if exact_name_match {
955            score += NAME_EXACT_MATCH_BONUS;
956        } else if normalized_name.starts_with(normalized_query)
957            || stripped_name.starts_with(normalized_query)
958        {
959            score += NAME_PREFIX_MATCH_BONUS;
960        } else if normalized_name.contains(normalized_query) {
961            score += NAME_SUBSTRING_MATCH_BONUS;
962        }
963
964        for (token, weight) in normalized_tokens {
965            score +=
966                weight * normalized_name.match_indices(token).count() * TOKEN_NAME_MATCH_WEIGHT;
967            score += weight
968                * normalized_description.match_indices(token).count()
969                * TOKEN_DESCRIPTION_MATCH_WEIGHT;
970        }
971
972        if score > 0 {
973            candidates.push((exact_name_match, score, normalized_name));
974        } else if fallback {
975            candidates.push((false, 0, normalized_name));
976        }
977    }
978
979    candidates.sort_by(|a, b| {
980        b.0.cmp(&a.0)
981            .then_with(|| b.1.cmp(&a.1))
982            .then_with(|| a.2.cmp(&b.2))
983    });
984
985    candidates.into_iter().map(|(_, _, name)| name).collect()
986}
987
988/// Returns the capability groups that any of the `selected` definitions belong
989/// to, so the discovery output can explain the bundle a tool came from and list
990/// its sibling members. A group is included when at least one of its members is
991/// among the selected definitions.
992fn relevant_groups(groups: Vec<ToolGroup>, selected: &[FunctionDefinition]) -> Vec<ToolGroup> {
993    if groups.is_empty() || selected.is_empty() {
994        return Vec::new();
995    }
996
997    let selected_names: BTreeSet<String> = selected
998        .iter()
999        .map(|def| def.name.to_ascii_lowercase())
1000        .collect();
1001
1002    groups
1003        .into_iter()
1004        .filter(|group| {
1005            group
1006                .members
1007                .iter()
1008                .any(|member| selected_names.contains(&member.to_ascii_lowercase()))
1009        })
1010        .collect()
1011}
1012
1013fn select_requested_definitions(
1014    definitions: Vec<FunctionDefinition>,
1015    requested: &[String],
1016) -> Vec<FunctionDefinition> {
1017    let mut index = BTreeMap::new();
1018    for def in definitions {
1019        index
1020            .entry(def.name.to_ascii_lowercase())
1021            .or_insert_with(|| def);
1022    }
1023
1024    let mut seen_requests = BTreeSet::new();
1025    let mut selected = Vec::new();
1026
1027    for name in requested {
1028        let lookup = name.trim().to_ascii_lowercase();
1029        if lookup.is_empty() || !seen_requests.insert(lookup.clone()) {
1030            continue;
1031        }
1032
1033        if let Some(def) = index.remove(&lookup) {
1034            selected.push(def);
1035        }
1036    }
1037
1038    selected
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use anda_core::{
1044        Agent, AgentOutput, BoxError, BoxFut, CompletionRequest, FunctionDefinition, Json,
1045        Resource, Tool, ToolGroup, ToolGroupInfo, ToolInput, ToolOutput, ToolProvider,
1046    };
1047    use candid::Principal;
1048    use serde::Deserialize;
1049    use serde_json::json;
1050    use std::sync::Arc;
1051
1052    use super::*;
1053    use crate::{
1054        context::BaseCtx,
1055        engine::{Engine, EngineBuilder},
1056        management::{BaseManagement, Visibility},
1057        model::{CompletionFeaturesDyn, Model, Models},
1058    };
1059    use std::collections::BTreeSet;
1060
1061    struct EchoTool;
1062
1063    #[derive(Debug, Default, Deserialize)]
1064    struct EchoToolArgs {
1065        #[serde(default)]
1066        input: String,
1067    }
1068
1069    impl Tool<BaseCtx> for EchoTool {
1070        type Args = EchoToolArgs;
1071        type Output = String;
1072
1073        fn name(&self) -> String {
1074            "echo_tool".to_string()
1075        }
1076
1077        fn description(&self) -> String {
1078            "Echoes the input back to the caller".to_string()
1079        }
1080
1081        fn definition(&self) -> FunctionDefinition {
1082            FunctionDefinition {
1083                name: self.name(),
1084                description: self.description(),
1085                parameters: json!({
1086                    "type": "object",
1087                    "properties": {
1088                        "input": { "type": "string" }
1089                    },
1090                    "required": ["input"],
1091                    "additionalProperties": false
1092                }),
1093                strict: Some(true),
1094            }
1095        }
1096
1097        async fn call(
1098            &self,
1099            _ctx: BaseCtx,
1100            args: Self::Args,
1101            _resources: Vec<Resource>,
1102        ) -> Result<ToolOutput<Self::Output>, BoxError> {
1103            Ok(ToolOutput {
1104                output: args.input,
1105                ..Default::default()
1106            })
1107        }
1108    }
1109
1110    struct HelpTool;
1111
1112    #[derive(Debug, Default, Deserialize)]
1113    struct HelpToolArgs;
1114
1115    impl Tool<BaseCtx> for HelpTool {
1116        type Args = HelpToolArgs;
1117        type Output = String;
1118
1119        fn name(&self) -> String {
1120            "help_tool".to_string()
1121        }
1122
1123        fn description(&self) -> String {
1124            "Useful when the user needs echo-oriented support".to_string()
1125        }
1126
1127        fn definition(&self) -> FunctionDefinition {
1128            FunctionDefinition {
1129                name: self.name(),
1130                description: self.description(),
1131                parameters: json!({
1132                    "type": "object",
1133                    "properties": {},
1134                    "required": [],
1135                    "additionalProperties": false
1136                }),
1137                strict: Some(true),
1138            }
1139        }
1140
1141        async fn call(
1142            &self,
1143            _ctx: BaseCtx,
1144            _args: Self::Args,
1145            _resources: Vec<Resource>,
1146        ) -> Result<ToolOutput<Self::Output>, BoxError> {
1147            Ok(ToolOutput {
1148                output: "help".to_string(),
1149                ..Default::default()
1150            })
1151        }
1152    }
1153
1154    struct EchoAgent;
1155
1156    impl Agent<AgentCtx> for EchoAgent {
1157        fn name(&self) -> String {
1158            "echo_agent".to_string()
1159        }
1160
1161        fn description(&self) -> String {
1162            "Handles echo tasks as an agent".to_string()
1163        }
1164
1165        async fn run(
1166            &self,
1167            _ctx: AgentCtx,
1168            prompt: String,
1169            _resources: Vec<Resource>,
1170        ) -> Result<AgentOutput, BoxError> {
1171            Ok(AgentOutput {
1172                content: prompt,
1173                ..Default::default()
1174            })
1175        }
1176    }
1177
1178    /// Two agents bundled into one capability group via [`Agent::group`].
1179    fn grouped_agent_info() -> ToolGroupInfo {
1180        ToolGroupInfo {
1181            id: "agents:media".to_string(),
1182            title: "Media agents".to_string(),
1183            description: "Agents that understand media".to_string(),
1184            instructions: Some("Pick the agent matching the media kind.".to_string()),
1185        }
1186    }
1187
1188    struct GroupedImageAgent;
1189
1190    impl Agent<AgentCtx> for GroupedImageAgent {
1191        fn name(&self) -> String {
1192            "grouped_image".to_string()
1193        }
1194
1195        fn description(&self) -> String {
1196            "Understands image media".to_string()
1197        }
1198
1199        fn group(&self) -> Option<ToolGroupInfo> {
1200            Some(grouped_agent_info())
1201        }
1202
1203        async fn run(
1204            &self,
1205            _ctx: AgentCtx,
1206            prompt: String,
1207            _resources: Vec<Resource>,
1208        ) -> Result<AgentOutput, BoxError> {
1209            Ok(AgentOutput {
1210                content: prompt,
1211                ..Default::default()
1212            })
1213        }
1214    }
1215
1216    struct GroupedAudioAgent;
1217
1218    impl Agent<AgentCtx> for GroupedAudioAgent {
1219        fn name(&self) -> String {
1220            "grouped_audio".to_string()
1221        }
1222
1223        fn description(&self) -> String {
1224            "Understands audio media".to_string()
1225        }
1226
1227        fn group(&self) -> Option<ToolGroupInfo> {
1228            Some(grouped_agent_info())
1229        }
1230
1231        async fn run(
1232            &self,
1233            _ctx: AgentCtx,
1234            prompt: String,
1235            _resources: Vec<Resource>,
1236        ) -> Result<AgentOutput, BoxError> {
1237            Ok(AgentOutput {
1238                content: prompt,
1239                ..Default::default()
1240            })
1241        }
1242    }
1243
1244    /// A tool provider that exposes two tools bundled into one capability group.
1245    struct GroupedToolProvider;
1246
1247    impl GroupedToolProvider {
1248        fn defs() -> Vec<FunctionDefinition> {
1249            ["grouped_read", "grouped_write"]
1250                .into_iter()
1251                .map(|name| FunctionDefinition {
1252                    name: name.to_string(),
1253                    description: format!("{name} from the test bundle"),
1254                    parameters: json!({
1255                        "type": "object",
1256                        "properties": {},
1257                        "additionalProperties": false
1258                    }),
1259                    strict: Some(false),
1260                })
1261                .collect()
1262        }
1263    }
1264
1265    impl ToolProvider<BaseCtx> for GroupedToolProvider {
1266        fn name(&self) -> String {
1267            "grouped".to_string()
1268        }
1269
1270        fn definitions(&self, names: Option<&[String]>) -> Vec<FunctionDefinition> {
1271            match names {
1272                Some([]) => Vec::new(),
1273                Some(names) => Self::defs()
1274                    .into_iter()
1275                    .filter(|def| {
1276                        names
1277                            .iter()
1278                            .any(|name| name.eq_ignore_ascii_case(&def.name))
1279                    })
1280                    .collect(),
1281                None => Self::defs(),
1282            }
1283        }
1284
1285        fn groups(&self) -> Vec<ToolGroup> {
1286            vec![ToolGroup {
1287                id: "grouped:bundle".to_string(),
1288                title: "Test bundle".to_string(),
1289                description: "A related bundle of tools".to_string(),
1290                instructions: Some("Read before write.".to_string()),
1291                members: vec!["grouped_read".to_string(), "grouped_write".to_string()],
1292            }]
1293        }
1294
1295        fn call(
1296            &self,
1297            _ctx: BaseCtx,
1298            input: ToolInput<Json>,
1299        ) -> BoxFut<'_, Result<ToolOutput<Json>, BoxError>> {
1300            Box::pin(async move { Ok(ToolOutput::new(json!({ "called": input.name }))) })
1301        }
1302    }
1303
1304    /// A provider that advertises a stale group member and a member shadowed by
1305    /// a static tool. Discovery should only expose the provider-backed member
1306    /// that is actually visible in the current callable set.
1307    struct OverlappingToolProvider;
1308
1309    impl OverlappingToolProvider {
1310        fn defs() -> Vec<FunctionDefinition> {
1311            ["echo_tool", "provider_only"]
1312                .into_iter()
1313                .map(|name| FunctionDefinition {
1314                    name: name.to_string(),
1315                    description: format!("{name} from an overlapping provider"),
1316                    parameters: json!({
1317                        "type": "object",
1318                        "properties": {},
1319                        "additionalProperties": false
1320                    }),
1321                    strict: Some(false),
1322                })
1323                .collect()
1324        }
1325    }
1326
1327    impl ToolProvider<BaseCtx> for OverlappingToolProvider {
1328        fn name(&self) -> String {
1329            "overlap".to_string()
1330        }
1331
1332        fn definitions(&self, names: Option<&[String]>) -> Vec<FunctionDefinition> {
1333            match names {
1334                Some([]) => Vec::new(),
1335                Some(names) => Self::defs()
1336                    .into_iter()
1337                    .filter(|def| {
1338                        names
1339                            .iter()
1340                            .any(|name| name.eq_ignore_ascii_case(&def.name))
1341                    })
1342                    .collect(),
1343                None => Self::defs(),
1344            }
1345        }
1346
1347        fn groups(&self) -> Vec<ToolGroup> {
1348            vec![ToolGroup {
1349                id: "overlap:bundle".to_string(),
1350                title: "Overlap bundle".to_string(),
1351                description: "Contains visible, stale, and shadowed members".to_string(),
1352                members: vec![
1353                    "echo_tool".to_string(),
1354                    "provider_only".to_string(),
1355                    "missing_provider_member".to_string(),
1356                ],
1357                ..Default::default()
1358            }]
1359        }
1360
1361        fn call(
1362            &self,
1363            _ctx: BaseCtx,
1364            input: ToolInput<Json>,
1365        ) -> BoxFut<'_, Result<ToolOutput<Json>, BoxError>> {
1366            Box::pin(async move { Ok(ToolOutput::new(json!({ "called": input.name }))) })
1367        }
1368    }
1369
1370    struct SharedGroupProvider {
1371        provider_name: &'static str,
1372        tool_name: &'static str,
1373    }
1374
1375    impl ToolProvider<BaseCtx> for SharedGroupProvider {
1376        fn name(&self) -> String {
1377            self.provider_name.to_string()
1378        }
1379
1380        fn definitions(&self, names: Option<&[String]>) -> Vec<FunctionDefinition> {
1381            if names.is_some_and(|names| {
1382                !names
1383                    .iter()
1384                    .any(|name| name.eq_ignore_ascii_case(self.tool_name))
1385            }) {
1386                return Vec::new();
1387            }
1388
1389            vec![FunctionDefinition {
1390                name: self.tool_name.to_string(),
1391                description: format!("{} from a duplicate group id provider", self.tool_name),
1392                parameters: json!({
1393                    "type": "object",
1394                    "properties": {},
1395                    "additionalProperties": false
1396                }),
1397                strict: Some(false),
1398            }]
1399        }
1400
1401        fn groups(&self) -> Vec<ToolGroup> {
1402            vec![ToolGroup {
1403                id: "shared:bundle".to_string(),
1404                title: "Shared bundle".to_string(),
1405                description: "A group id shared across providers".to_string(),
1406                members: vec![self.tool_name.to_string()],
1407                ..Default::default()
1408            }]
1409        }
1410
1411        fn call(
1412            &self,
1413            _ctx: BaseCtx,
1414            input: ToolInput<Json>,
1415        ) -> BoxFut<'_, Result<ToolOutput<Json>, BoxError>> {
1416            Box::pin(async move { Ok(ToolOutput::new(json!({ "called": input.name }))) })
1417        }
1418    }
1419
1420    #[derive(Clone, Debug)]
1421    struct SelectorCompleter {
1422        content: String,
1423    }
1424
1425    impl CompletionFeaturesDyn for SelectorCompleter {
1426        fn model_name(&self) -> String {
1427            TOOLS_SELECT_NAME.to_string()
1428        }
1429
1430        fn completion(
1431            &self,
1432            req: CompletionRequest,
1433        ) -> anda_core::BoxPinFut<Result<AgentOutput, BoxError>> {
1434            assert!(req.tools.is_empty());
1435            assert!(req.prompt.contains("Candidate callables:"));
1436
1437            let content = self.content.clone();
1438            Box::pin(futures::future::ready(Ok(AgentOutput {
1439                content,
1440                ..Default::default()
1441            })))
1442        }
1443    }
1444
1445    async fn run_search(ctx: AgentCtx, args: ToolsSearchArgs) -> ToolsOutput {
1446        let output = ToolsSearch::new()
1447            .run(ctx, serde_json::to_string(&args).unwrap(), Vec::new())
1448            .await
1449            .unwrap();
1450        serde_json::from_str(&output.content).unwrap()
1451    }
1452
1453    async fn run_select(ctx: AgentCtx, args: ToolsSelectArgs) -> ToolsOutput {
1454        let output = ToolsSelect::new()
1455            .run(ctx, serde_json::to_string(&args).unwrap(), Vec::new())
1456            .await
1457            .unwrap();
1458        serde_json::from_str(&output.content).unwrap()
1459    }
1460
1461    async fn run_groups(ctx: AgentCtx, args: ToolsGroupsArgs) -> ToolGroupsOutput {
1462        let output = ToolsGroups::new()
1463            .run(ctx, serde_json::to_string(&args).unwrap(), Vec::new())
1464            .await
1465            .unwrap();
1466        serde_json::from_str(&output.content).unwrap()
1467    }
1468
1469    async fn build_engine(builder: EngineBuilder) -> Engine {
1470        // Use a public engine so the anonymous test caller passes the
1471        // visibility checks now enforced by `ctx_with`.
1472        builder
1473            .with_management(Arc::new(BaseManagement {
1474                controller: Principal::management_canister(),
1475                managers: BTreeSet::new(),
1476                visibility: Visibility::Public,
1477            }))
1478            .build("echo_agent".to_string())
1479            .await
1480            .unwrap()
1481    }
1482
1483    #[test]
1484    fn tools_search_and_tools_select_definitions_constrain_inputs() {
1485        let search_definition = ToolsSearch::new().definition();
1486        assert_eq!(
1487            search_definition.parameters["additionalProperties"],
1488            json!(false)
1489        );
1490        assert_eq!(
1491            search_definition.parameters["required"],
1492            json!(["query", "limit"])
1493        );
1494
1495        let select_definition = ToolsSelect::new().definition();
1496        assert_eq!(
1497            select_definition.parameters["required"],
1498            json!(["tools", "query", "group", "limit"])
1499        );
1500        assert_eq!(
1501            select_definition.parameters["additionalProperties"],
1502            json!(false)
1503        );
1504
1505        let groups_definition = ToolsGroups::new().definition();
1506        assert_eq!(groups_definition.parameters["required"], json!(["query"]));
1507        assert_eq!(
1508            groups_definition.parameters["additionalProperties"],
1509            json!(false)
1510        );
1511    }
1512
1513    #[tokio::test(flavor = "current_thread")]
1514    async fn tools_search_wildcard_returns_local_tools_and_agents() {
1515        let engine = build_engine(
1516            EngineBuilder::new()
1517                .register_tool(Arc::new(EchoTool))
1518                .unwrap()
1519                .register_agent(Arc::new(EchoAgent), None)
1520                .unwrap(),
1521        )
1522        .await;
1523        let ctx = engine
1524            .ctx_with(
1525                Principal::anonymous(),
1526                "echo_agent",
1527                "echo_agent",
1528                Default::default(),
1529            )
1530            .unwrap();
1531
1532        let output = run_search(
1533            ctx,
1534            ToolsSearchArgs {
1535                query: "*".to_string(),
1536                limit: 0,
1537            },
1538        )
1539        .await;
1540
1541        let names: Vec<&str> = output.tools.iter().map(|tool| tool.name.as_str()).collect();
1542        assert_eq!(
1543            names,
1544            vec![
1545                "echo_tool",
1546                "echo_agent",
1547                "subagents_manager",
1548                "tools_groups",
1549                "tools_search",
1550                "tools_select"
1551            ]
1552        );
1553        // Wildcard enumerates names only: the full parameter schema is omitted.
1554        assert!(
1555            output.tools.iter().all(|tool| tool.parameters == json!({})),
1556            "wildcard search must not return full schemas"
1557        );
1558    }
1559
1560    #[tokio::test(flavor = "current_thread")]
1561    async fn tools_search_prioritizes_name_matches_over_description_matches() {
1562        let engine = build_engine(
1563            EngineBuilder::new()
1564                .register_tool(Arc::new(EchoTool))
1565                .unwrap()
1566                .register_tool(Arc::new(HelpTool))
1567                .unwrap()
1568                .register_agent(Arc::new(EchoAgent), None)
1569                .unwrap(),
1570        )
1571        .await;
1572        let ctx = engine
1573            .ctx_with(
1574                Principal::anonymous(),
1575                "echo_agent",
1576                "echo_agent",
1577                Default::default(),
1578            )
1579            .unwrap();
1580
1581        let output = run_search(
1582            ctx,
1583            ToolsSearchArgs {
1584                query: "echo".to_string(),
1585                limit: 0,
1586            },
1587        )
1588        .await;
1589
1590        let names: Vec<&str> = output.tools.iter().map(|tool| tool.name.as_str()).collect();
1591        assert_eq!(names.first().copied(), Some("echo_agent"));
1592        assert!(names.contains(&"help_tool"));
1593    }
1594
1595    #[tokio::test(flavor = "current_thread")]
1596    async fn tools_select_resolves_prefixed_agents_and_deduplicates_requests() {
1597        let engine = build_engine(
1598            EngineBuilder::new()
1599                .register_tool(Arc::new(EchoTool))
1600                .unwrap()
1601                .register_agent(Arc::new(EchoAgent), None)
1602                .unwrap(),
1603        )
1604        .await;
1605        let ctx = engine
1606            .ctx_with(
1607                Principal::anonymous(),
1608                "echo_agent",
1609                "echo_agent",
1610                Default::default(),
1611            )
1612            .unwrap();
1613
1614        let output = run_select(
1615            ctx,
1616            ToolsSelectArgs {
1617                tools: vec![
1618                    "echo_agent".to_string(),
1619                    "echo_tool".to_string(),
1620                    "missing".to_string(),
1621                ],
1622                query: String::new(),
1623                group: String::new(),
1624                limit: 0,
1625            },
1626        )
1627        .await;
1628
1629        let names: Vec<&str> = output.tools.iter().map(|tool| tool.name.as_str()).collect();
1630        assert_eq!(names, vec!["echo_agent", "echo_tool"]);
1631    }
1632
1633    #[tokio::test(flavor = "current_thread")]
1634    async fn tools_select_attaches_capability_group_for_provider_tools() {
1635        let engine = build_engine(
1636            EngineBuilder::new()
1637                .register_tool_provider(Arc::new(GroupedToolProvider))
1638                .unwrap()
1639                .register_agent(Arc::new(EchoAgent), None)
1640                .unwrap(),
1641        )
1642        .await;
1643        let ctx = engine
1644            .ctx_with(
1645                Principal::anonymous(),
1646                "echo_agent",
1647                "echo_agent",
1648                Default::default(),
1649            )
1650            .unwrap();
1651
1652        // Selecting one bundle member surfaces the group so the model learns the
1653        // bundle's purpose, instructions, and the sibling it has not selected.
1654        let output = run_select(
1655            ctx,
1656            ToolsSelectArgs {
1657                tools: vec!["grouped_read".to_string()],
1658                query: String::new(),
1659                group: String::new(),
1660                limit: 0,
1661            },
1662        )
1663        .await;
1664
1665        let names: Vec<&str> = output.tools.iter().map(|tool| tool.name.as_str()).collect();
1666        assert_eq!(names, vec!["grouped_read"]);
1667        assert_eq!(output.groups.len(), 1);
1668        let group = &output.groups[0];
1669        assert_eq!(group.id, "grouped:bundle");
1670        assert_eq!(group.instructions.as_deref(), Some("Read before write."));
1671        assert_eq!(
1672            group.members,
1673            vec!["grouped_read".to_string(), "grouped_write".to_string()]
1674        );
1675    }
1676
1677    #[tokio::test(flavor = "current_thread")]
1678    async fn tools_select_attaches_capability_group_for_agents() {
1679        let engine = build_engine(
1680            EngineBuilder::new()
1681                .register_agent(Arc::new(EchoAgent), None)
1682                .unwrap()
1683                .register_agent(Arc::new(GroupedImageAgent), None)
1684                .unwrap()
1685                .register_agent(Arc::new(GroupedAudioAgent), None)
1686                .unwrap(),
1687        )
1688        .await;
1689        let ctx = engine
1690            .ctx_with(
1691                Principal::anonymous(),
1692                "echo_agent",
1693                "echo_agent",
1694                Default::default(),
1695            )
1696            .unwrap();
1697
1698        // Selecting one grouped agent surfaces the agent group so the model
1699        // learns the bundle's purpose, instructions, and the sibling agent.
1700        let output = run_select(
1701            ctx,
1702            ToolsSelectArgs {
1703                tools: vec!["grouped_image".to_string()],
1704                query: String::new(),
1705                group: String::new(),
1706                limit: 0,
1707            },
1708        )
1709        .await;
1710
1711        let names: Vec<&str> = output.tools.iter().map(|tool| tool.name.as_str()).collect();
1712        assert_eq!(names, vec!["grouped_image"]);
1713        assert_eq!(output.groups.len(), 1);
1714        let group = &output.groups[0];
1715        assert_eq!(group.id, "agents:media");
1716        assert_eq!(
1717            group.instructions.as_deref(),
1718            Some("Pick the agent matching the media kind.")
1719        );
1720        assert_eq!(
1721            group.members,
1722            vec!["grouped_audio".to_string(), "grouped_image".to_string()]
1723        );
1724    }
1725
1726    #[tokio::test(flavor = "current_thread")]
1727    async fn tools_select_group_expands_all_members() {
1728        let engine = build_engine(
1729            EngineBuilder::new()
1730                .register_tool_provider(Arc::new(GroupedToolProvider))
1731                .unwrap()
1732                .register_agent(Arc::new(EchoAgent), None)
1733                .unwrap(),
1734        )
1735        .await;
1736        let ctx = engine
1737            .ctx_with(
1738                Principal::anonymous(),
1739                "echo_agent",
1740                "echo_agent",
1741                Default::default(),
1742            )
1743            .unwrap();
1744
1745        // Naming the group expands every member's schema in one call.
1746        let output = run_select(
1747            ctx,
1748            ToolsSelectArgs {
1749                tools: Vec::new(),
1750                query: String::new(),
1751                group: "grouped:bundle".to_string(),
1752                limit: 0,
1753            },
1754        )
1755        .await;
1756
1757        let mut names: Vec<&str> = output.tools.iter().map(|tool| tool.name.as_str()).collect();
1758        names.sort();
1759        assert_eq!(names, vec!["grouped_read", "grouped_write"]);
1760        assert_eq!(output.groups.len(), 1);
1761        assert_eq!(output.groups[0].id, "grouped:bundle");
1762    }
1763
1764    #[tokio::test(flavor = "current_thread")]
1765    async fn tool_groups_hide_unavailable_provider_members() {
1766        let engine = build_engine(
1767            EngineBuilder::new()
1768                .register_tool(Arc::new(EchoTool))
1769                .unwrap()
1770                .register_tool_provider(Arc::new(OverlappingToolProvider))
1771                .unwrap()
1772                .register_agent(Arc::new(EchoAgent), None)
1773                .unwrap(),
1774        )
1775        .await;
1776        let ctx = engine
1777            .ctx_with(
1778                Principal::anonymous(),
1779                "echo_agent",
1780                "echo_agent",
1781                Default::default(),
1782            )
1783            .unwrap();
1784
1785        let directory = run_groups(
1786            ctx.clone(),
1787            ToolsGroupsArgs {
1788                query: "overlap".to_string(),
1789            },
1790        )
1791        .await;
1792        assert_eq!(directory.groups.len(), 1);
1793        assert_eq!(directory.groups[0].member_count, 1);
1794
1795        let selected = run_select(
1796            ctx,
1797            ToolsSelectArgs {
1798                tools: Vec::new(),
1799                query: String::new(),
1800                group: "overlap:bundle".to_string(),
1801                limit: 0,
1802            },
1803        )
1804        .await;
1805
1806        let names: Vec<&str> = selected
1807            .tools
1808            .iter()
1809            .map(|tool| tool.name.as_str())
1810            .collect();
1811        assert_eq!(names, vec!["provider_only"]);
1812        assert_eq!(selected.groups.len(), 1);
1813        assert_eq!(
1814            selected.groups[0].members,
1815            vec!["provider_only".to_string()]
1816        );
1817    }
1818
1819    #[tokio::test(flavor = "current_thread")]
1820    async fn duplicate_tool_group_ids_merge_visible_members() {
1821        let engine = build_engine(
1822            EngineBuilder::new()
1823                .register_tool_provider(Arc::new(SharedGroupProvider {
1824                    provider_name: "shared_a_provider",
1825                    tool_name: "shared_a_tool",
1826                }))
1827                .unwrap()
1828                .register_tool_provider(Arc::new(SharedGroupProvider {
1829                    provider_name: "shared_b_provider",
1830                    tool_name: "shared_b_tool",
1831                }))
1832                .unwrap()
1833                .register_agent(Arc::new(EchoAgent), None)
1834                .unwrap(),
1835        )
1836        .await;
1837        let ctx = engine
1838            .ctx_with(
1839                Principal::anonymous(),
1840                "echo_agent",
1841                "echo_agent",
1842                Default::default(),
1843            )
1844            .unwrap();
1845
1846        let directory = run_groups(
1847            ctx.clone(),
1848            ToolsGroupsArgs {
1849                query: "shared".to_string(),
1850            },
1851        )
1852        .await;
1853        assert_eq!(directory.total_groups, 1);
1854        assert_eq!(directory.groups.len(), 1);
1855        assert_eq!(directory.groups[0].member_count, 2);
1856
1857        let selected = run_select(
1858            ctx,
1859            ToolsSelectArgs {
1860                tools: Vec::new(),
1861                query: String::new(),
1862                group: "SHARED:BUNDLE".to_string(),
1863                limit: 0,
1864            },
1865        )
1866        .await;
1867
1868        let names: Vec<&str> = selected
1869            .tools
1870            .iter()
1871            .map(|tool| tool.name.as_str())
1872            .collect();
1873        assert_eq!(names, vec!["shared_a_tool", "shared_b_tool"]);
1874        assert_eq!(selected.groups.len(), 1);
1875        assert_eq!(
1876            selected.groups[0].members,
1877            vec!["shared_a_tool".to_string(), "shared_b_tool".to_string()]
1878        );
1879    }
1880
1881    #[tokio::test(flavor = "current_thread")]
1882    async fn tools_groups_lists_capability_group_directory() {
1883        let engine = build_engine(
1884            EngineBuilder::new()
1885                .register_tool_provider(Arc::new(GroupedToolProvider))
1886                .unwrap()
1887                .register_agent(Arc::new(EchoAgent), None)
1888                .unwrap(),
1889        )
1890        .await;
1891        let ctx = engine
1892            .ctx_with(
1893                Principal::anonymous(),
1894                "echo_agent",
1895                "echo_agent",
1896                Default::default(),
1897            )
1898            .unwrap();
1899
1900        // Wildcard lists the bundle as a compact entry without tool schemas.
1901        let output = run_groups(
1902            ctx.clone(),
1903            ToolsGroupsArgs {
1904                query: "*".to_string(),
1905            },
1906        )
1907        .await;
1908        assert_eq!(output.total_groups, 1);
1909        assert_eq!(output.groups.len(), 1);
1910        assert_eq!(output.groups[0].id, "grouped:bundle");
1911        assert_eq!(output.groups[0].member_count, 2);
1912
1913        // A keyword that matches nothing filters the directory to empty, while
1914        // the total still reflects every available group.
1915        let filtered = run_groups(
1916            ctx,
1917            ToolsGroupsArgs {
1918                query: "no_such_group".to_string(),
1919            },
1920        )
1921        .await;
1922        assert!(filtered.groups.is_empty());
1923        assert_eq!(filtered.total_groups, 1);
1924    }
1925
1926    #[test]
1927    fn relevant_groups_filters_by_membership_and_ignores_empty_inputs() {
1928        let groups = vec![
1929            ToolGroup {
1930                id: "a".to_string(),
1931                members: vec!["mcp_a_read".to_string(), "mcp_a_write".to_string()],
1932                ..Default::default()
1933            },
1934            ToolGroup {
1935                id: "b".to_string(),
1936                members: vec!["mcp_b_run".to_string()],
1937                ..Default::default()
1938            },
1939        ];
1940        let selected = vec![FunctionDefinition {
1941            // Membership matching is case-insensitive.
1942            name: "MCP_A_READ".to_string(),
1943            ..Default::default()
1944        }];
1945
1946        let relevant = relevant_groups(groups.clone(), &selected);
1947        assert_eq!(relevant.len(), 1);
1948        assert_eq!(relevant[0].id, "a");
1949
1950        assert!(relevant_groups(groups, &[]).is_empty());
1951        assert!(relevant_groups(Vec::new(), &selected).is_empty());
1952    }
1953
1954    #[tokio::test(flavor = "current_thread")]
1955    async fn tools_select_prioritizes_explicit_tools_over_query_selection() {
1956        let engine = build_engine(
1957            EngineBuilder::new()
1958                .register_tool(Arc::new(EchoTool))
1959                .unwrap()
1960                .register_tool(Arc::new(HelpTool))
1961                .unwrap()
1962                .register_agent(Arc::new(EchoAgent), None)
1963                .unwrap(),
1964        )
1965        .await;
1966        let ctx = engine
1967            .ctx_with(
1968                Principal::anonymous(),
1969                "echo_agent",
1970                "echo_agent",
1971                Default::default(),
1972            )
1973            .unwrap();
1974
1975        let output = run_select(
1976            ctx,
1977            ToolsSelectArgs {
1978                tools: vec!["echo_tool".to_string()],
1979                query: "mirror my text".to_string(),
1980                group: String::new(),
1981                limit: 1,
1982            },
1983        )
1984        .await;
1985
1986        let names: Vec<&str> = output.tools.iter().map(|tool| tool.name.as_str()).collect();
1987        assert_eq!(names, vec!["echo_tool"]);
1988    }
1989
1990    #[tokio::test(flavor = "current_thread")]
1991    async fn tools_select_query_falls_back_to_ranked_candidates_on_invalid_selector_output() {
1992        let models = Arc::new(Models::default());
1993        models.set_model(Model::not_implemented());
1994        models.set(
1995            TOOLS_SELECT_NAME.to_string(),
1996            Model::with_completer(Arc::new(SelectorCompleter {
1997                content: "not json".to_string(),
1998            })),
1999        );
2000
2001        let engine = build_engine(
2002            EngineBuilder::new()
2003                .with_models(models)
2004                .register_tool(Arc::new(EchoTool))
2005                .unwrap()
2006                .register_tool(Arc::new(HelpTool))
2007                .unwrap()
2008                .register_agent(Arc::new(EchoAgent), None)
2009                .unwrap(),
2010        )
2011        .await;
2012        let ctx = engine
2013            .ctx_with(
2014                Principal::anonymous(),
2015                "echo_agent",
2016                "echo_agent",
2017                Default::default(),
2018            )
2019            .unwrap();
2020
2021        let output = run_select(
2022            ctx,
2023            ToolsSelectArgs {
2024                tools: Vec::new(),
2025                query: "echo".to_string(),
2026                group: String::new(),
2027                limit: 1,
2028            },
2029        )
2030        .await;
2031
2032        let names: Vec<&str> = output.tools.iter().map(|tool| tool.name.as_str()).collect();
2033        assert_eq!(names, vec!["echo_agent"]);
2034    }
2035
2036    #[tokio::test(flavor = "current_thread")]
2037    async fn tools_search_and_select_report_invalid_or_empty_inputs() {
2038        let _search = ToolsSearch::default();
2039        let _select = ToolsSelect::default();
2040        let engine = build_engine(
2041            EngineBuilder::new()
2042                .register_tool(Arc::new(EchoTool))
2043                .unwrap()
2044                .register_agent(Arc::new(EchoAgent), None)
2045                .unwrap(),
2046        )
2047        .await;
2048        let ctx = engine
2049            .ctx_with(
2050                Principal::anonymous(),
2051                "echo_agent",
2052                "echo_agent",
2053                Default::default(),
2054            )
2055            .unwrap();
2056
2057        let output = ToolsSearch::new()
2058            .run(ctx.clone(), "not json".to_string(), Vec::new())
2059            .await
2060            .unwrap();
2061        assert!(output.content.contains("Invalid input"));
2062
2063        let output = run_search(
2064            ctx.clone(),
2065            ToolsSearchArgs {
2066                query: "   ".to_string(),
2067                limit: 4,
2068            },
2069        )
2070        .await;
2071        assert!(output.tools.is_empty());
2072        assert!(output.total_tools > 0);
2073
2074        let output = ToolsSelect::new()
2075            .run(ctx.clone(), "not json".to_string(), Vec::new())
2076            .await
2077            .unwrap();
2078        assert!(output.content.contains("Invalid input"));
2079
2080        let output = ToolsSelect::new()
2081            .run(
2082                ctx,
2083                serde_json::to_string(&ToolsSelectArgs::default()).unwrap(),
2084                Vec::new(),
2085            )
2086            .await
2087            .unwrap();
2088        assert!(
2089            output
2090                .content
2091                .contains("one of `tools`, `query`, or `group`")
2092        );
2093    }
2094
2095    #[tokio::test(flavor = "current_thread")]
2096    async fn tools_select_query_covers_empty_candidates_short_queries_and_selector_success() {
2097        let models = Arc::new(Models::default());
2098        models.set_model(Model::not_implemented());
2099        models.set(
2100            TOOLS_SELECT_NAME.to_string(),
2101            Model::with_completer(Arc::new(SelectorCompleter {
2102                content: "```json\n{\"tools\":[\"help_tool\"]}\n```".to_string(),
2103            })),
2104        );
2105        let engine = build_engine(
2106            EngineBuilder::new()
2107                .with_models(models)
2108                .register_tool(Arc::new(EchoTool))
2109                .unwrap()
2110                .register_tool(Arc::new(HelpTool))
2111                .unwrap()
2112                .register_agent(Arc::new(EchoAgent), None)
2113                .unwrap(),
2114        )
2115        .await;
2116        let ctx = engine
2117            .ctx_with(
2118                Principal::anonymous(),
2119                "echo_agent",
2120                TOOLS_SELECT_NAME,
2121                Default::default(),
2122            )
2123            .unwrap();
2124        let selector = ToolsSelect::new();
2125
2126        let (selected, _) = selector
2127            .select_requested_definitions_by_query(
2128                &ctx,
2129                Vec::new(),
2130                &ToolsSelectArgs {
2131                    query: "echo".to_string(),
2132                    limit: 0,
2133                    ..Default::default()
2134                },
2135            )
2136            .await;
2137        assert!(selected.is_empty());
2138
2139        let definitions = vec![EchoTool.definition(), HelpTool.definition()];
2140        let (selected, _) = selector
2141            .select_requested_definitions_by_query(
2142                &ctx,
2143                definitions.clone(),
2144                &ToolsSelectArgs {
2145                    query: "he".to_string(),
2146                    limit: 1,
2147                    ..Default::default()
2148                },
2149            )
2150            .await;
2151        assert_eq!(selected[0].name, "help_tool");
2152
2153        let (selected, _) = selector
2154            .select_requested_definitions_by_query(
2155                &ctx,
2156                definitions,
2157                &ToolsSelectArgs {
2158                    query: "echo support".to_string(),
2159                    limit: 2,
2160                    ..Default::default()
2161                },
2162            )
2163            .await;
2164        assert_eq!(selected[0].name, "help_tool");
2165    }
2166
2167    #[test]
2168    fn selector_json_candidate_parsing_ranking_and_deduping_are_stable() {
2169        assert_eq!(
2170            parse_selector_tool_names("```JSON\n[\"echo_tool\", \"help_tool\"]\n```"),
2171            vec!["echo_tool", "help_tool"]
2172        );
2173        assert_eq!(
2174            parse_selector_tool_names("prefix {\"tools\":[\"echo_tool\"]} suffix"),
2175            vec!["echo_tool"]
2176        );
2177        assert!(parse_selector_tool_names("not json").is_empty());
2178
2179        let candidates = json_candidates("prefix [\"echo_tool\"] suffix");
2180        assert!(candidates.contains(&"[\"echo_tool\"]".to_string()));
2181
2182        let definitions = vec![
2183            FunctionDefinition {
2184                name: "alpha".to_string(),
2185                description: "first".to_string(),
2186                ..Default::default()
2187            },
2188            FunctionDefinition {
2189                name: "my_echo_tool".to_string(),
2190                description: "second".to_string(),
2191                ..Default::default()
2192            },
2193        ];
2194        assert_eq!(
2195            rank_search_items(&definitions, "alpha", &[], false)[0],
2196            "alpha"
2197        );
2198        assert_eq!(
2199            rank_search_items(&definitions, "echo", &[], false),
2200            vec!["my_echo_tool"]
2201        );
2202
2203        let selected = select_requested_definitions(
2204            vec![EchoTool.definition()],
2205            &[
2206                String::new(),
2207                "echo_tool".to_string(),
2208                "echo_tool".to_string(),
2209                "missing".to_string(),
2210            ],
2211        );
2212        assert_eq!(selected.len(), 1);
2213        assert_eq!(selected[0].name, "echo_tool");
2214    }
2215
2216    #[test]
2217    fn rank_search_items_keeps_prefixed_names_resolvable() {
2218        let definitions = vec![
2219            FunctionDefinition {
2220                name: "RT_remote_lookup".to_string(),
2221                description: "Remote lookup tool".to_string(),
2222                ..Default::default()
2223            },
2224            FunctionDefinition {
2225                name: "SA_researcher".to_string(),
2226                description: "Research subagent".to_string(),
2227                ..Default::default()
2228            },
2229            FunctionDefinition {
2230                name: "RA_remote_chat".to_string(),
2231                description: "Remote chat agent".to_string(),
2232                ..Default::default()
2233            },
2234        ];
2235
2236        // The routing prefix is ignored for matching, and the ranked names stay
2237        // resolvable through select_requested_definitions.
2238        let ranked = rank_search_items(&definitions, "researcher", &[], false);
2239        assert_eq!(ranked, vec!["sa_researcher"]);
2240        let ranked = rank_search_items(&definitions, "remote_lookup", &[], false);
2241        assert_eq!(ranked.first().map(String::as_str), Some("rt_remote_lookup"));
2242        let ranked = rank_search_items(&definitions, "remote_chat", &[], false);
2243        assert_eq!(ranked.first().map(String::as_str), Some("ra_remote_chat"));
2244
2245        let ranked = rank_search_items(&definitions, "remote_lookup", &[], false);
2246        let selected = select_requested_definitions(definitions, &ranked);
2247        assert_eq!(selected[0].name, "RT_remote_lookup");
2248    }
2249}
2250
2251#[cfg(test)]
2252mod budget_tests {
2253    use super::*;
2254    #[test]
2255    fn discovery_and_accumulated_schemas_stay_bounded() {
2256        let tool = |n: usize| FunctionDefinition {
2257            name: format!("tool_{n}"),
2258            description: "x".repeat(10_000),
2259            parameters: json!({"type":"object"}),
2260            strict: Some(false),
2261        };
2262        let output = bounded_discovery(ToolsOutput {
2263            tools: (0..100).map(tool).collect(),
2264            total_tools: 100,
2265            ..Default::default()
2266        });
2267        assert!(serde_json::to_vec(&output).unwrap().len() <= MAX_DISCOVERY_BYTES);
2268        let mut discovered = DiscoveredTools::default();
2269        discovered.set_merge_policy(Some(true));
2270        for n in 0..200 {
2271            discovered.observe_output(
2272                TOOLS_SELECT_NAME,
2273                &json!(ToolsOutput {
2274                    tools: vec![tool(n)],
2275                    ..Default::default()
2276                }),
2277            );
2278        }
2279        let mut request = CompletionRequest::default();
2280        discovered.merge_into_request(&mut request);
2281        assert!(serde_json::to_vec(&request.tools).unwrap().len() <= MAX_DISCOVERY_BYTES);
2282        assert!(request.tools.len() < 200);
2283        // Schemas beyond the merge budget were still shown to the model, so allowlisted
2284        // runners must keep accepting calls to them.
2285        assert!(!request.tools.iter().any(|tool| tool.name == "tool_199"));
2286        assert!(discovered.contains("tool_199"));
2287    }
2288}