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