1use 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
21pub const TOOLS_SEARCH_NAME: &str = "tools_search";
23pub const TOOLS_SELECT_NAME: &str = "tools_select";
25pub const TOOLS_GROUPS_NAME: &str = "tools_groups";
27
28#[derive(Debug, Clone, Default, Deserialize, Serialize)]
30pub struct ToolsSearchArgs {
31 pub query: String,
33 #[serde(default)]
35 pub limit: usize,
36}
37
38#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct ToolsOutput {
41 pub tools: Vec<FunctionDefinition>,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub groups: Vec<ToolGroup>,
53 #[serde(default)]
55 pub total_tools: usize,
56}
57
58const MAX_DISCOVERED_REQUEST_TOOLS: usize = 16;
65
66#[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 pub fn merge_policy(&self) -> Option<bool> {
90 self.merge
91 }
92
93 pub fn set_merge_policy(&mut self, merge: Option<bool>) {
95 self.merge = merge;
96 }
97
98 pub fn contains(&self, lowercase_name: &str) -> bool {
100 self.known_names.contains(lowercase_name)
101 }
102
103 pub fn reset_definitions(&mut self) {
105 self.definitions.clear();
106 self.known_names.clear();
107 self.selection_counts.clear();
108 }
109
110 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 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 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 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
249pub struct ToolsSearch {
252 tokenizer: TokenizerChain,
253}
254
255impl Default for ToolsSearch {
256 fn default() -> Self {
257 Self::new()
258 }
259}
260
261impl ToolsSearch {
262 pub const NAME: &'static str = TOOLS_SEARCH_NAME;
264
265 pub fn new() -> Self {
267 let tokenizer = jieba_tokenizer();
268 Self { tokenizer }
269 }
270
271 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 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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
398pub struct ToolsSelectArgs {
399 #[serde(default)]
401 pub tools: Vec<String>,
402 #[serde(default)]
404 pub query: String,
405 #[serde(default)]
409 pub group: String,
410 #[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
421pub 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;
435const MAX_SEARCH_RESULTS: usize = 64;
438
439impl ToolsSelect {
440 pub const NAME: &'static str = TOOLS_SELECT_NAME;
442
443 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 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 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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
628pub struct ToolsGroupsArgs {
629 #[serde(default)]
631 pub query: String,
632}
633
634#[derive(Debug, Clone, Default, Deserialize, Serialize)]
636pub struct ToolGroupSummary {
637 pub id: String,
639 pub title: String,
641 pub description: String,
643 pub member_count: usize,
645}
646
647#[derive(Debug, Clone, Default, Deserialize, Serialize)]
649pub struct ToolGroupsOutput {
650 pub groups: Vec<ToolGroupSummary>,
652 #[serde(default)]
654 pub total_groups: usize,
655}
656
657pub struct ToolsGroups;
664
665impl Default for ToolsGroups {
666 fn default() -> Self {
667 Self::new()
668 }
669}
670
671impl ToolsGroups {
672 pub const NAME: &'static str = TOOLS_GROUPS_NAME;
674
675 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
834pub 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 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
941fn 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 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 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 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 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 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 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 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 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 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 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 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 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}