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