1use crate::tools::catalog::{tool_id_for, CapabilityRecord, CatalogError, ToolCatalog};
3use crate::tools::Tool;
4use serde_json::Value;
5use std::collections::{HashMap, HashSet};
6use std::sync::Arc;
7
8#[derive(Clone)]
11pub struct ToolRegistry {
12 tools: HashMap<String, Arc<dyn Tool>>,
13 cached_schema: Arc<Vec<Value>>,
15 api_to_runtime_names: HashMap<String, String>,
17 input_name_maps: HashMap<String, SchemaNameMap>,
19 catalog: ToolCatalog,
23}
24
25#[derive(Clone, Debug, Default)]
26struct SchemaNameMap {
27 api_to_runtime: HashMap<String, String>,
28 children: HashMap<String, SchemaNameMap>,
29 items: Option<Box<SchemaNameMap>>,
31}
32
33impl Default for ToolRegistry {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl ToolRegistry {
40 pub fn new() -> Self {
41 let tools: Vec<Arc<dyn Tool>> = vec![
42 Arc::new(crate::tools::bash::BashTool),
43 #[cfg(windows)]
44 Arc::new(crate::tools::powershell::PowerShellTool),
45 Arc::new(crate::tools::read::ReadTool),
46 Arc::new(crate::tools::write::WriteTool),
47 Arc::new(crate::tools::edit::EditTool),
48 Arc::new(crate::tools::grep::GrepTool),
49 Arc::new(crate::tools::find::FindTool),
50 Arc::new(crate::tools::ls::LsTool),
51 Arc::new(crate::tools::subagent::SubagentTool),
52 Arc::new(crate::tools::subagent::authorize_model::SubagentModelAuthorizeTool),
53 Arc::new(crate::tools::subagent::models::SubagentModelsTool),
54 Arc::new(crate::tools::subagent::start::SubagentStartTool),
55 Arc::new(crate::tools::subagent::status::SubagentStatusTool),
56 Arc::new(crate::tools::subagent::steer::SubagentSteerTool),
57 Arc::new(crate::tools::subagent::collect::SubagentCollectTool),
58 Arc::new(crate::tools::subagent::resume::SubagentResumeTool),
59 Arc::new(crate::tools::memory_context::MemoryContextTool),
64 Arc::new(crate::tools::shell::ShellStartTool),
65 Arc::new(crate::tools::shell::ShellSendTool),
66 Arc::new(crate::tools::shell::ShellEndTool),
67 Arc::new(crate::tools::discovery::SearchToolsTool),
72 Arc::new(crate::tools::discovery::ActivateToolsTool),
73 Arc::new(crate::tools::memory::MemorySearchTool),
77 Arc::new(crate::tools::memory::MemoryFetchTool),
78 Arc::new(crate::tools::memory::MemoryStoreTool),
79 Arc::new(crate::tools::memory::MemoryForgetTool),
80 Arc::new(crate::tools::forum::ForumPostTool),
83 Arc::new(crate::tools::forum::ForumReadTool),
84 Arc::new(crate::tools::forum::ForumForgetTool),
85 ];
86 Self::from_tools(tools)
87 }
88
89 pub fn empty() -> Self {
92 Self::from_tools(Vec::new())
93 }
94
95 pub fn disable(&mut self, names: &[String]) {
101 self.try_disable(names).expect(CATALOG_REJECTED_MSG);
102 }
103
104 pub fn try_disable(&mut self, names: &[String]) -> Result<(), CatalogError> {
117 if names.is_empty() {
118 return Ok(());
119 }
120 let remaining: Vec<Arc<dyn Tool>> = self
121 .tools
122 .values()
123 .filter(|t| !names.iter().any(|n| n == t.name()))
124 .cloned()
125 .collect();
126 if remaining.len() == self.tools.len() {
127 return Ok(());
128 }
129 let prior = self.catalog.generation();
130 let mut candidate = Self::try_from_tools(remaining)?;
131 candidate.catalog.rebase_past(prior)?;
132 *self = candidate;
133 Ok(())
134 }
135
136 pub fn without_subagent() -> Self {
138 let tools: Vec<Arc<dyn Tool>> = vec![
139 Arc::new(crate::tools::bash::BashTool),
140 #[cfg(windows)]
141 Arc::new(crate::tools::powershell::PowerShellTool),
142 Arc::new(crate::tools::read::ReadTool),
143 Arc::new(crate::tools::write::WriteTool),
144 Arc::new(crate::tools::edit::EditTool),
145 Arc::new(crate::tools::grep::GrepTool),
146 Arc::new(crate::tools::find::FindTool),
147 Arc::new(crate::tools::ls::LsTool),
148 Arc::new(crate::tools::shell::ShellStartTool),
149 Arc::new(crate::tools::shell::ShellSendTool),
150 Arc::new(crate::tools::shell::ShellEndTool),
151 Arc::new(crate::tools::forum::ForumPostTool),
152 Arc::new(crate::tools::forum::ForumReadTool),
153 Arc::new(crate::tools::forum::ForumForgetTool),
154 ];
155 Self::from_tools(tools)
156 }
157
158 pub fn without_subagent_with_extensions(extension_tools: &ToolRegistry) -> Self {
166 let mut combined = Self::without_subagent();
167 for tool in extension_tools.tools.values() {
168 if tool.extension_id().is_some() && !is_recursive_subagent_tool_name(tool.name()) {
169 combined
170 .insert_tool_with_catalog(tool.clone())
171 .expect(CATALOG_REJECTED_MSG);
172 }
173 }
174 combined.rebuild_schema();
175 combined
176 }
177
178 fn from_tools(tool_list: Vec<Arc<dyn Tool>>) -> Self {
184 Self::try_from_tools(tool_list).expect(CATALOG_REJECTED_MSG)
185 }
186
187 #[doc(hidden)]
191 pub fn from_tools_for_tests(tool_list: Vec<Arc<dyn Tool>>) -> Self {
192 Self::from_tools(tool_list)
193 }
194
195 fn try_from_tools(tool_list: Vec<Arc<dyn Tool>>) -> Result<Self, CatalogError> {
196 let mut registry = ToolRegistry {
197 tools: HashMap::new(),
198 cached_schema: Arc::new(Vec::new()),
199 api_to_runtime_names: HashMap::new(),
200 input_name_maps: HashMap::new(),
201 catalog: ToolCatalog::empty(),
202 };
203 for tool in tool_list {
207 let name = tool.name().to_string();
208 registry.tools.insert(name, tool);
209 }
210 let sorted: Vec<Arc<dyn Tool>> =
216 registry.iter_tools_sorted().into_iter().cloned().collect();
217 for tool in sorted {
218 let record = CapabilityRecord::for_registered_tool(&tool);
219 registry.catalog.upsert(None, record)?;
220 }
221 registry.rebuild_schema();
222 Ok(registry)
223 }
224
225 fn api_safe_name(name: &str, used: &HashSet<String>) -> String {
226 Self::api_safe_identifier(name, used, 128, false)
227 }
228
229 fn api_safe_property_name(name: &str, used: &HashSet<String>) -> String {
230 Self::api_safe_identifier(name, used, 64, true)
231 }
232
233 fn api_safe_identifier(
234 name: &str,
235 used: &HashSet<String>,
236 max_len: usize,
237 allow_dot: bool,
238 ) -> String {
239 let mut sanitized = String::with_capacity(name.len());
240 for ch in name.chars() {
241 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || (allow_dot && ch == '.') {
242 sanitized.push(ch);
243 } else {
244 sanitized.push('_');
245 }
246 }
247 if sanitized.is_empty() {
248 sanitized.push_str("field");
249 }
250 if sanitized.len() > max_len {
251 sanitized.truncate(max_len);
252 }
253
254 let base = sanitized.clone();
255 let mut suffix = 2;
256 while used.contains(&sanitized) {
257 let suffix_str = format!("_{suffix}");
258 let keep = max_len.saturating_sub(suffix_str.len());
259 sanitized = format!("{}{}", &base[..base.len().min(keep)], suffix_str);
260 suffix += 1;
261 }
262 sanitized
263 }
264
265 fn sanitize_schema(mut schema: Value) -> (Value, SchemaNameMap) {
266 let mut map = SchemaNameMap::default();
267 let Some(obj) = schema.as_object_mut() else {
268 return (schema, map);
269 };
270
271 let mut required_name_map = HashMap::new();
272 if let Some(props_value) = obj.get_mut("properties") {
273 if let Some(props) = props_value.as_object_mut() {
274 let original = std::mem::take(props);
275 let mut used = HashSet::new();
276 for (runtime_name, child_schema) in original {
277 let api_name = Self::api_safe_property_name(&runtime_name, &used);
278 used.insert(api_name.clone());
279 required_name_map.insert(runtime_name.clone(), api_name.clone());
280 map.api_to_runtime.insert(api_name.clone(), runtime_name);
281
282 let (sanitized_child, child_map) = Self::sanitize_schema(child_schema);
283 if !child_map.api_to_runtime.is_empty() || !child_map.children.is_empty() {
284 map.children.insert(api_name.clone(), child_map);
285 }
286 props.insert(api_name, sanitized_child);
287 }
288 }
289 }
290
291 if let Some(required) = obj.get_mut("required").and_then(Value::as_array_mut) {
292 for item in required.iter_mut() {
293 if let Some(name) = item.as_str() {
294 if let Some(api_name) = required_name_map.get(name) {
295 *item = Value::String(api_name.clone());
296 }
297 }
298 }
299 }
300
301 if let Some(items) = obj.get_mut("items") {
304 let (sanitized_items, items_map) = Self::sanitize_schema(std::mem::take(items));
305 if !items_map.api_to_runtime.is_empty()
306 || !items_map.children.is_empty()
307 || items_map.items.is_some()
308 {
309 map.items = Some(Box::new(items_map));
310 }
311 *items = sanitized_items;
312 }
313
314 (schema, map)
315 }
316
317 fn translate_input_names(input: Value, map: &SchemaNameMap) -> Value {
318 match input {
319 Value::Object(obj) => {
320 let mut out = serde_json::Map::new();
321 for (api_name, value) in obj {
322 let runtime_name = map
323 .api_to_runtime
324 .get(&api_name)
325 .cloned()
326 .unwrap_or_else(|| api_name.clone());
327 let value = if let Some(child) = map.children.get(&api_name) {
328 Self::translate_input_names(value, child)
329 } else {
330 value
331 };
332 out.insert(runtime_name, value);
333 }
334 Value::Object(out)
335 }
336 Value::Array(arr) => {
337 if let Some(items_map) = &map.items {
339 Value::Array(
340 arr.into_iter()
341 .map(|v| Self::translate_input_names(v, items_map))
342 .collect(),
343 )
344 } else {
345 Value::Array(arr)
346 }
347 }
348 other => other,
349 }
350 }
351
352 fn rebuild_schema(&mut self) {
353 let mut used = HashSet::new();
354 let mut api_to_runtime_names = HashMap::new();
355 let mut input_name_maps = HashMap::new();
356 let mut schema = Vec::with_capacity(self.tools.len());
357
358 let mut sorted_tools: Vec<_> = self.tools.values().collect();
373 sorted_tools.sort_by_key(|t| {
374 let builtin_first = match t.origin() {
375 crate::tools::ToolOrigin::Builtin => 0u8,
376 _ => 1u8,
377 };
378 (builtin_first, t.name().to_string())
379 });
380
381 for tool in sorted_tools {
382 let runtime_name = tool.name();
383 let api_name = Self::api_safe_name(runtime_name, &used);
384 used.insert(api_name.clone());
385 api_to_runtime_names.insert(api_name.clone(), runtime_name.to_string());
386 let (input_schema, input_map) = Self::sanitize_schema(tool.parameters());
387 input_name_maps.insert(api_name.clone(), input_map);
388 schema.push(serde_json::json!({
389 "name": api_name,
390 "description": tool.description(),
391 "input_schema": input_schema
392 }));
393 }
394
395 self.api_to_runtime_names = api_to_runtime_names;
396 self.input_name_maps = input_name_maps;
397 self.cached_schema = Arc::new(schema);
398 }
399
400 pub fn register(&mut self, tool: Arc<dyn Tool>) {
410 self.try_register(tool).expect(CATALOG_REJECTED_MSG);
411 }
412
413 pub fn try_register(&mut self, tool: Arc<dyn Tool>) -> Result<(), CatalogError> {
419 self.insert_tool_with_catalog(tool)?;
420 self.rebuild_schema();
421 Ok(())
422 }
423
424 pub fn try_register_batch(
438 &mut self,
439 tool_list: Vec<Arc<dyn Tool>>,
440 ) -> Result<usize, CatalogError> {
441 if tool_list.is_empty() {
442 return Ok(0);
443 }
444 let mut batch_names: HashSet<String> = HashSet::new();
445 let mut batch_ids: HashSet<crate::tools::catalog::ToolId> = HashSet::new();
446 for tool in &tool_list {
447 let name = tool.name();
448 if self.tools.contains_key(name) || !batch_names.insert(name.to_string()) {
449 return Err(CatalogError::DuplicateRuntimeName(
450 agent_core::BoundedText::new(name, 128).text,
451 ));
452 }
453 let id = tool_id_for(tool.as_ref());
454 if self.catalog.get(&id).is_some() || !batch_ids.insert(id.clone()) {
455 return Err(CatalogError::DuplicateToolId(id));
456 }
457 }
458 let count = tool_list.len();
459 let mut candidate = self.clone();
460 for tool in tool_list {
461 candidate.insert_tool_with_catalog(tool)?;
462 }
463 candidate.rebuild_schema();
464 *self = candidate;
465 Ok(count)
466 }
467
468 fn insert_tool_with_catalog(&mut self, tool: Arc<dyn Tool>) -> Result<(), CatalogError> {
477 let record = CapabilityRecord::for_registered_tool(&tool);
478 let replaced = self
479 .tools
480 .get(tool.name())
481 .map(|old| tool_id_for(old.as_ref()));
482 self.catalog.upsert(replaced.as_ref(), record)?;
483 self.tools.insert(tool.name().to_string(), tool);
484 Ok(())
485 }
486
487 pub fn catalog(&self) -> &ToolCatalog {
490 &self.catalog
491 }
492
493 #[doc(hidden)]
501 pub fn resume_catalog_generation_for_tests(
502 &mut self,
503 generation: crate::tools::catalog::CatalogGeneration,
504 ) {
505 self.catalog.set_generation_for_tests(generation);
506 }
507
508 #[doc(hidden)]
514 pub fn upsert_catalog_record_for_tests(
515 &mut self,
516 replaced: Option<&crate::tools::catalog::ToolId>,
517 record: crate::tools::catalog::CapabilityRecord,
518 ) {
519 self.catalog
520 .upsert(replaced, record)
521 .expect("test upsert must be structurally valid");
522 }
523
524 pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
525 let runtime_name = self
526 .api_to_runtime_names
527 .get(name)
528 .map(String::as_str)
529 .unwrap_or(name);
530 self.tools.get(runtime_name)
531 }
532
533 pub fn runtime_name_for_api<'a>(&'a self, name: &'a str) -> &'a str {
534 self.api_to_runtime_names
535 .get(name)
536 .map(String::as_str)
537 .unwrap_or(name)
538 }
539
540 pub fn resolve_wire_call(
550 &self,
551 wire_name: &str,
552 ) -> Option<crate::tools::activation::ResolvedToolCall> {
553 let runtime_name = self
554 .api_to_runtime_names
555 .get(wire_name)
556 .map(String::as_str)
557 .unwrap_or(wire_name);
558 let tool = self.tools.get(runtime_name)?;
559 Some(crate::tools::activation::ResolvedToolCall::new(
560 wire_name,
561 runtime_name,
562 tool_id_for(tool.as_ref()),
563 ))
564 }
565
566 pub fn translate_input_for_api_tool(&self, tool_name: &str, input: Value) -> Value {
567 if let Some(map) = self.input_name_maps.get(tool_name) {
568 Self::translate_input_names(input, map)
569 } else {
570 input
571 }
572 }
573
574 pub fn tools_schema(&self) -> Arc<Vec<Value>> {
575 Arc::clone(&self.cached_schema)
576 }
577
578 pub fn session_tools_schema(
597 &self,
598 session: &crate::tools::activation::SessionToolSet,
599 ) -> SessionSchemaProjection {
600 let mut projected = Vec::new();
601 let mut dropped = Vec::new();
602 for entry in self.cached_schema.iter() {
603 let Some(api_name) = entry["name"].as_str() else {
604 continue;
605 };
606 let runtime_name = self.runtime_name_for_api(api_name);
607 let Some(tool) = self.tools.get(runtime_name) else {
608 continue;
609 };
610 let id = tool_id_for(tool.as_ref());
611 let pinned = session
612 .core_pin(&id)
613 .map(|pin| (pin.schema_digest(), pin.provenance()))
614 .or_else(|| {
615 session
616 .activation(&id)
617 .map(|a| (a.schema_digest(), a.provenance()))
618 });
619 let Some((pinned_digest, pinned_provenance)) = pinned else {
620 continue; };
622 let Some(record) = self.catalog.get(&id) else {
623 tracing::warn!(
624 tool = %id,
625 "session schema projection drops uncataloged session member \
626 (removed since the session pinned it)"
627 );
628 dropped.push((id, DroppedSessionMember::Uncataloged));
629 continue;
630 };
631 if pinned_digest != record.schema_digest() {
632 tracing::warn!(
633 tool = %id,
634 "session schema projection drops drifted session member \
635 (schema digest changed since the session pinned it)"
636 );
637 dropped.push((id, DroppedSessionMember::SchemaDigestMismatch));
638 continue;
639 }
640 if pinned_provenance != record.provenance() {
641 tracing::warn!(
642 tool = %id,
643 "session schema projection drops drifted session member \
644 (trust provenance changed since the session pinned it)"
645 );
646 dropped.push((id, DroppedSessionMember::ProvenanceMismatch));
647 continue;
648 }
649 projected.push(entry.clone());
650 }
651 let seen: HashSet<&crate::tools::catalog::ToolId> =
658 dropped.iter().map(|(id, _)| id).collect();
659 let vanished: Vec<crate::tools::catalog::ToolId> = session
660 .core_ids()
661 .chain(session.activated().map(|a| a.grant().tool_id()))
662 .filter(|id| !seen.contains(id) && self.catalog.get(id).is_none())
663 .cloned()
664 .collect();
665 for id in vanished {
666 tracing::warn!(
667 tool = %id,
668 "session schema projection drops uncataloged session member (removed from the registry since the session pinned it)"
669 );
670 dropped.push((id, DroppedSessionMember::Uncataloged));
671 }
672 SessionSchemaProjection {
673 schema: projected,
674 dropped,
675 }
676 }
677
678 pub fn tool_names_for_extension(&self, extension_id: &str) -> Vec<String> {
681 let mut names: Vec<String> = self
682 .tools
683 .values()
684 .filter(|t| t.extension_id() == Some(extension_id))
685 .map(|t| t.name().to_string())
686 .collect();
687 names.sort();
688 names
689 }
690
691 pub fn iter_tools_sorted(&self) -> Vec<&Arc<dyn Tool>> {
694 let mut tools: Vec<_> = self.tools.values().collect();
695 tools.sort_by_key(|t| t.name());
696 tools
697 }
698}
699
700#[derive(Clone, Debug, PartialEq, Eq)]
706pub struct SessionSchemaProjection {
707 pub schema: Vec<Value>,
709 pub dropped: Vec<(crate::tools::catalog::ToolId, DroppedSessionMember)>,
713}
714
715#[derive(Clone, Copy, Debug, PartialEq, Eq)]
717pub enum DroppedSessionMember {
718 Uncataloged,
720 SchemaDigestMismatch,
722 ProvenanceMismatch,
725}
726
727const CATALOG_REJECTED_MSG: &str =
733 "tool catalog rejected mutation: refusing to expose a tool the catalog could not record";
734
735fn is_recursive_subagent_tool_name(name: &str) -> bool {
736 let api_name = ToolRegistry::api_safe_name(name, &HashSet::new());
737 matches!(
738 api_name.as_str(),
739 "subagent"
740 | "subagent_start"
741 | "subagent_status"
742 | "subagent_steer"
743 | "subagent_collect"
744 | "subagent_resume"
745 | "subagent_model_authorize"
746 | "subagent_models"
747 | "memory_context"
751 )
752}
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use crate::{Result, ToolContext};
757 use serde_json::json;
758
759 struct NamedTool(&'static str);
760
761 #[async_trait::async_trait]
762 impl Tool for NamedTool {
763 fn name(&self) -> &str {
764 self.0
765 }
766 fn description(&self) -> &str {
767 "test tool"
768 }
769 fn parameters(&self) -> Value {
770 json!({"type": "object"})
771 }
772 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
773 Ok("ok".to_string())
774 }
775 }
776
777 struct SchemaTool;
778
779 #[async_trait::async_trait]
780 impl Tool for SchemaTool {
781 fn name(&self) -> &str {
782 "schema_tool"
783 }
784 fn description(&self) -> &str {
785 "schema tool"
786 }
787 fn parameters(&self) -> Value {
788 json!({
789 "type": "object",
790 "properties": {
791 "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going": {"type": "string"},
792 "nested:obj": {
793 "type": "object",
794 "properties": {"inner/key": {"type": "string"}},
795 "required": ["inner/key"]
796 }
797 },
798 "required": [
799 "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going",
800 "nested:obj"
801 ]
802 })
803 }
804 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
805 Ok("ok".to_string())
806 }
807 }
808
809 #[test]
810 fn tool_schema_uses_api_safe_names_and_maps_back() {
811 let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool("plugin:skill.tool"))]);
812
813 assert_eq!(registry.tools_schema()[0]["name"], "plugin_skill_tool");
814 assert!(registry.get("plugin:skill.tool").is_some());
815 assert!(registry.get("plugin_skill_tool").is_some());
816 assert_eq!(
817 registry.runtime_name_for_api("plugin_skill_tool"),
818 "plugin:skill.tool"
819 );
820 }
821
822 #[test]
832 fn extension_leaf_name_never_shadows_builtin_memory_fetch_api_name() {
833 struct ExtMemoryTool {
834 runtime: &'static str,
835 ext: &'static str,
836 }
837 #[async_trait::async_trait]
838 impl Tool for ExtMemoryTool {
839 fn origin(&self) -> crate::tools::ToolOrigin {
840 crate::tools::ToolOrigin::Extension {
841 extension_id: self.ext.to_string(),
842 }
843 }
844 fn extension_id(&self) -> Option<&str> {
845 Some(self.ext)
846 }
847 fn name(&self) -> &str {
848 self.runtime
849 }
850 fn description(&self) -> &str {
851 "extension memory tool (single-id schema)"
852 }
853 fn parameters(&self) -> Value {
854 json!({
855 "type": "object",
856 "properties": {"id": {"type": "string"}},
857 "required": ["id"]
858 })
859 }
860 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
861 Ok("ok".to_string())
862 }
863 }
864
865 let registry = ToolRegistry::from_tools(vec![
866 Arc::new(crate::tools::memory::MemoryFetchTool),
867 Arc::new(ExtMemoryTool {
869 runtime: "memory:fetch",
870 ext: "memory",
871 }),
872 Arc::new(ExtMemoryTool {
874 runtime: "axel-memory-manager:memory_fetch",
875 ext: "axel-memory-manager",
876 }),
877 ]);
878
879 assert_eq!(
881 registry.runtime_name_for_api("memory_fetch"),
882 "memory_fetch",
883 "wire name `memory_fetch` must resolve to the built-in"
884 );
885 let schema = registry.tools_schema();
886 let builtin = schema
887 .iter()
888 .find(|e| e["name"] == "memory_fetch")
889 .expect("built-in memory_fetch exposed");
890 assert_eq!(
891 builtin["input_schema"]["required"],
892 json!(["ids"]),
893 "built-in memory_fetch schema must remain {{ids: [...]}}"
894 );
895
896 let axel = schema
898 .iter()
899 .find(|e| e["name"] == "axel-memory-manager_memory_fetch")
900 .expect("extension tool exposed under namespaced API name");
901 assert_eq!(axel["input_schema"]["required"], json!(["id"]));
902 assert_eq!(
905 registry.runtime_name_for_api("memory_fetch_2"),
906 "memory:fetch"
907 );
908 let resolved = registry.resolve_wire_call("memory_fetch").unwrap();
911 assert_eq!(resolved.runtime_name(), "memory_fetch");
912 }
913
914 #[test]
915 fn tool_schema_disambiguates_sanitized_name_collisions() {
916 let registry =
917 ToolRegistry::from_tools(vec![Arc::new(NamedTool("a:b")), Arc::new(NamedTool("a.b"))]);
918 let names: HashSet<String> = registry
919 .tools_schema()
920 .iter()
921 .filter_map(|s| s["name"].as_str().map(str::to_string))
922 .collect();
923
924 assert_eq!(names.len(), 2);
925 assert!(names.contains("a_b"));
926 assert!(names.contains("a_b_2"));
927 assert!(registry.get("a_b").is_some());
928 assert!(registry.get("a_b_2").is_some());
929 }
930
931 #[test]
932 fn tool_schema_truncates_long_names_to_anthropic_limit() {
933 let long = "x".repeat(140);
934 let leaked: &'static str = Box::leak(long.into_boxed_str());
935 let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool(leaked))]);
936 let schema = registry.tools_schema();
937 let name = schema[0]["name"].as_str().unwrap();
938
939 assert_eq!(name.len(), 128);
940 assert!(name
941 .chars()
942 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
943 assert!(registry.get(name).is_some());
944 }
945
946 #[test]
947 fn tool_schema_sanitizes_input_property_names_and_translates_inputs_back() {
948 let registry = ToolRegistry::from_tools(vec![Arc::new(SchemaTool)]);
949 let schema = registry.tools_schema();
950 let input_schema = &schema[0]["input_schema"];
951 let props = input_schema["properties"].as_object().unwrap();
952
953 assert!(props.keys().all(|k| k.len() <= 64
954 && k.chars()
955 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')));
956 assert_eq!(
957 input_schema["required"].as_array().unwrap()[0]
958 .as_str()
959 .unwrap()
960 .len(),
961 64
962 );
963 assert_eq!(input_schema["required"][1], "nested_obj");
964 assert!(props["nested_obj"]["properties"]
965 .as_object()
966 .unwrap()
967 .contains_key("inner_key"));
968 assert_eq!(props["nested_obj"]["required"][0], "inner_key");
969
970 let first_required = input_schema["required"][0].as_str().unwrap();
971 let translated = registry.translate_input_for_api_tool(
972 "schema_tool",
973 json!({
974 first_required: "value",
975 "nested_obj": {"inner_key": "nested"}
976 }),
977 );
978
979 assert_eq!(
980 translated["bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going"],
981 "value"
982 );
983 assert_eq!(translated["nested:obj"]["inner/key"], "nested");
984 }
985
986 #[test]
987 fn test_tool_registry_new() {
988 let registry = ToolRegistry::new();
989
990 assert_eq!(
994 registry.tools_schema().len(),
995 28 + usize::from(cfg!(windows))
996 );
997 for name in ["forum_post", "forum_read", "forum_forget"] {
998 assert!(registry.get(name).is_some(), "missing {name}");
999 }
1000
1001 assert!(registry.get("bash").is_some());
1003
1004 assert!(registry.get("nonexistent").is_none());
1006
1007 assert!(registry.get("bash").is_some());
1009 assert!(registry.get("read").is_some());
1010 assert!(registry.get("write").is_some());
1011 assert!(registry.get("edit").is_some());
1012 assert!(registry.get("grep").is_some());
1013 assert!(registry.get("find").is_some());
1014 assert!(registry.get("ls").is_some());
1015 assert!(registry.get("subagent").is_some());
1016 assert!(registry.get("subagent_model_authorize").is_some());
1017 assert!(registry.get("subagent_models").is_some());
1018 }
1019
1020 #[test]
1021 fn test_disable_removes_tools_from_registry_and_schema() {
1022 let mut registry = ToolRegistry::new();
1023 let before = registry.tools_schema().len();
1024
1025 registry.disable(&["bash".to_string(), "ls".to_string()]);
1026
1027 assert!(registry.get("bash").is_none());
1029 assert!(registry.get("ls").is_none());
1030 assert!(registry.get("read").is_some());
1032 assert!(registry.get("subagent").is_some());
1033 assert_eq!(registry.tools_schema().len(), before - 2);
1035 }
1036
1037 #[test]
1038 fn test_disable_empty_and_unknown_are_noops() {
1039 let mut registry = ToolRegistry::new();
1040 let n = registry.tools_schema().len();
1041 registry.disable(&[]); assert_eq!(registry.tools_schema().len(), n);
1043 registry.disable(&["not_a_real_tool".to_string()]); assert_eq!(registry.tools_schema().len(), n);
1045 assert!(registry.get("bash").is_some());
1046 }
1047
1048 #[test]
1049 fn test_tool_registry_without_subagent() {
1050 let registry = ToolRegistry::without_subagent();
1051
1052 assert_eq!(
1054 registry.tools_schema().len(),
1055 13 + usize::from(cfg!(windows))
1056 );
1057 for name in ["forum_post", "forum_read", "forum_forget"] {
1058 assert!(registry.get(name).is_some(), "missing {name}");
1059 }
1060 assert!(registry.get("subagent").is_none());
1062
1063 assert!(registry.get("bash").is_some());
1065
1066 assert!(registry.get("bash").is_some());
1068 assert!(registry.get("read").is_some());
1069 assert!(registry.get("write").is_some());
1070 assert!(registry.get("edit").is_some());
1071 assert!(registry.get("grep").is_some());
1072 assert!(registry.get("find").is_some());
1073 assert!(registry.get("ls").is_some());
1074 }
1075
1076 #[test]
1077 fn test_tool_registry_register() {
1078 let mut registry = ToolRegistry::without_subagent();
1079 let initial_count = registry.tools_schema().len();
1080
1081 struct TestTool;
1083 #[async_trait::async_trait]
1084 impl Tool for TestTool {
1085 fn name(&self) -> &str {
1086 "test_tool"
1087 }
1088 fn description(&self) -> &str {
1089 "A test tool"
1090 }
1091 fn parameters(&self) -> Value {
1092 json!({"type": "object"})
1093 }
1094 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1095 Ok("test result".to_string())
1096 }
1097 }
1098
1099 registry.register(Arc::new(TestTool));
1100
1101 assert_eq!(registry.tools_schema().len(), initial_count + 1);
1103
1104 assert!(registry.get("test_tool").is_some());
1106 }
1107
1108 #[test]
1109 fn tool_names_for_extension_filters_by_owner_and_sorts() {
1110 struct OwnedTool(&'static str, Option<&'static str>);
1111 #[async_trait::async_trait]
1112 impl Tool for OwnedTool {
1113 fn name(&self) -> &str {
1114 self.0
1115 }
1116 fn description(&self) -> &str {
1117 "owned"
1118 }
1119 fn parameters(&self) -> Value {
1120 json!({"type": "object"})
1121 }
1122 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1123 Ok("ok".to_string())
1124 }
1125 fn extension_id(&self) -> Option<&str> {
1126 self.1
1127 }
1128 }
1129
1130 let mut registry = ToolRegistry::without_subagent();
1131 registry.register(Arc::new(OwnedTool("alpha:zed", Some("alpha"))));
1132 registry.register(Arc::new(OwnedTool("alpha:bar", Some("alpha"))));
1133 registry.register(Arc::new(OwnedTool("beta:thing", Some("beta"))));
1134
1135 assert_eq!(
1136 registry.tool_names_for_extension("alpha"),
1137 vec!["alpha:bar".to_string(), "alpha:zed".to_string()]
1138 );
1139 assert_eq!(
1140 registry.tool_names_for_extension("beta"),
1141 vec!["beta:thing".to_string()]
1142 );
1143 assert!(registry.tool_names_for_extension("ghost").is_empty());
1144 assert!(registry.tool_names_for_extension("bash").is_empty());
1146 }
1147
1148 struct OwnedTool(&'static str, Option<&'static str>);
1149 #[async_trait::async_trait]
1150 impl Tool for OwnedTool {
1151 fn name(&self) -> &str {
1152 self.0
1153 }
1154 fn description(&self) -> &str {
1155 "owned"
1156 }
1157 fn parameters(&self) -> Value {
1158 json!({"type": "object"})
1159 }
1160 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1161 Ok("ok".to_string())
1162 }
1163 fn extension_id(&self) -> Option<&str> {
1164 self.1
1165 }
1166 }
1167
1168 #[test]
1169 fn without_subagent_excludes_subagent_tools() {
1170 let registry = ToolRegistry::without_subagent();
1171 assert!(registry.get("subagent").is_none());
1172 assert!(registry.get("subagent_start").is_none());
1173 assert!(registry.get("subagent_status").is_none());
1174 assert!(registry.get("subagent_steer").is_none());
1175 assert!(registry.get("subagent_collect").is_none());
1176 assert!(registry.get("subagent_resume").is_none());
1177 assert!(registry.get("bash").is_some());
1179 assert!(registry.get("read").is_some());
1180 }
1181
1182 #[test]
1183 fn without_subagent_with_extensions_includes_extension_tools() {
1184 let mut other = ToolRegistry::empty();
1185 other.register(Arc::new(OwnedTool("alpha:do_thing", Some("alpha"))));
1186
1187 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1188
1189 assert!(merged.get("alpha:do_thing").is_some());
1191 assert!(merged.get("bash").is_some());
1193 assert!(merged.get("read").is_some());
1194 assert!(merged.get("subagent_start").is_none());
1196 }
1197
1198 #[test]
1199 fn forum_membership_preserves_worker_bounds_and_foreground_disclosure() {
1200 use crate::tools::activation::{SessionId, SessionToolSet};
1201 use crate::tools::catalog::{ToolEffect, ToolId};
1202 let main = ToolRegistry::new();
1203 let mut extensions = ToolRegistry::empty();
1204 extensions.register(Arc::new(OwnedTool("peer:probe", Some("peer"))));
1205 let merged = ToolRegistry::without_subagent_with_extensions(&extensions);
1206 assert_eq!(merged.tools_schema().len(), 14 + usize::from(cfg!(windows)));
1207 assert!(merged.get("peer:probe").is_some());
1208 for registry in [&main, &ToolRegistry::without_subagent(), &merged] {
1209 for (name, effect) in [
1210 ("forum_post", ToolEffect::NonIdempotent),
1211 ("forum_read", ToolEffect::ReadOnly),
1212 ("forum_forget", ToolEffect::NonIdempotent),
1213 ] {
1214 let tool = registry.get(name).unwrap();
1215 assert_eq!(tool.origin(), crate::tools::ToolOrigin::Builtin);
1216 assert_eq!(tool.effect(), effect);
1217 assert!(registry.catalog().get(&ToolId::builtin(name)).is_some());
1218 assert_eq!(
1219 registry
1220 .tools_schema()
1221 .iter()
1222 .filter(|schema| schema["name"] == name)
1223 .count(),
1224 1
1225 );
1226 }
1227 }
1228 for registry in [ToolRegistry::without_subagent(), merged] {
1229 for name in [
1230 "subagent",
1231 "subagent_start",
1232 "subagent_status",
1233 "subagent_steer",
1234 "subagent_collect",
1235 "subagent_resume",
1236 "subagent_model_authorize",
1237 "subagent_models",
1238 "search_tools",
1239 "activate_tools",
1240 "memory_context",
1241 "memory_store",
1242 "memory_search",
1243 "memory_fetch",
1244 "memory_forget",
1245 ] {
1246 assert!(registry.get(name).is_none(), "worker must not gain {name}");
1247 }
1248 for name in ["bash", "write", "edit"] {
1249 assert!(registry.get(name).is_some(), "worker must retain {name}");
1250 }
1251 }
1252 let session = SessionToolSet::progressive_core_for_catalog(
1253 SessionId::parse("forum-foreground-test").unwrap(),
1254 main.catalog(),
1255 );
1256 let projection = main.session_tools_schema(&session);
1257 for name in ["forum_post", "forum_read", "forum_forget"] {
1258 assert!(!session.is_core(&ToolId::builtin(name)));
1259 assert!(!projection
1260 .schema
1261 .iter()
1262 .any(|schema| schema["name"] == name));
1263 }
1264 }
1265
1266 #[test]
1267 fn without_subagent_with_extensions_excludes_built_ins_from_other_registry() {
1268 let other = ToolRegistry::new();
1273
1274 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1275
1276 let bash = merged.get("bash").expect("bash present");
1278 assert!(bash.extension_id().is_none());
1279 assert!(merged.get("subagent_start").is_none());
1281 assert!(merged.get("subagent").is_none());
1282 }
1283
1284 #[test]
1285 fn without_subagent_with_extensions_rejects_recursive_tool_name_collisions() {
1286 let mut other = ToolRegistry::empty();
1287 for name in [
1288 "subagent",
1289 "subagent_start",
1290 "subagent_status",
1291 "subagent_steer",
1292 "subagent_collect",
1293 "subagent_resume",
1294 "subagent_model_authorize",
1295 "subagent_models",
1296 "memory_context",
1297 ] {
1298 other.register(Arc::new(OwnedTool(name, Some("malicious-extension"))));
1299 }
1300
1301 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1302 for name in [
1303 "subagent",
1304 "subagent_start",
1305 "subagent_status",
1306 "subagent_steer",
1307 "subagent_collect",
1308 "subagent_resume",
1309 "subagent_model_authorize",
1310 "subagent_models",
1311 "memory_context",
1312 ] {
1313 assert!(merged.get(name).is_none(), "{name} must stay unavailable");
1314 }
1315 }
1316
1317 #[test]
1318 fn without_subagent_with_extensions_rejects_api_sanitized_recursive_collisions() {
1319 let mut other = ToolRegistry::empty();
1320 for runtime_name in [
1321 "subagent:start",
1322 "subagent:status",
1323 "subagent:steer",
1324 "subagent:collect",
1325 "subagent:resume",
1326 "subagent:model:authorize",
1327 "subagent:models",
1328 "memory:context",
1329 ] {
1330 other.register(Arc::new(OwnedTool(runtime_name, Some("subagent"))));
1331 }
1332
1333 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1334 for api_name in [
1335 "subagent_start",
1336 "subagent_status",
1337 "subagent_steer",
1338 "subagent_collect",
1339 "subagent_resume",
1340 "subagent_model_authorize",
1341 "subagent_models",
1342 "memory_context",
1343 ] {
1344 assert!(
1345 merged.get(api_name).is_none(),
1346 "API-safe recursive name {api_name} must stay unavailable"
1347 );
1348 }
1349 }
1350
1351 #[test]
1355 fn memory_context_present_in_primary_registry_absent_in_recursive_ones() {
1356 assert!(ToolRegistry::new().get("memory_context").is_some());
1357 assert!(ToolRegistry::without_subagent()
1358 .get("memory_context")
1359 .is_none());
1360
1361 let merged = ToolRegistry::without_subagent_with_extensions(&ToolRegistry::new());
1362 assert!(
1363 merged.get("memory_context").is_none(),
1364 "recursive registries must never expose memory_context"
1365 );
1366 }
1367
1368 #[test]
1369 fn without_subagent_with_extensions_does_not_overwrite_existing_builtin() {
1370 let mut other = ToolRegistry::empty();
1376 other.register(Arc::new(OwnedTool("ext:custom", Some("ext"))));
1377
1378 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1379 assert!(merged.get("ext:custom").is_some());
1380 assert!(merged.get("bash").is_some());
1381 assert!(merged.get("bash").unwrap().extension_id().is_none());
1382 }
1383
1384 struct McpTwin(&'static str);
1386
1387 #[async_trait::async_trait]
1388 impl Tool for McpTwin {
1389 fn name(&self) -> &str {
1390 self.0
1391 }
1392 fn description(&self) -> &str {
1393 "mcp twin"
1394 }
1395 fn parameters(&self) -> Value {
1396 json!({"type": "object"})
1397 }
1398 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1399 Ok("ok".to_string())
1400 }
1401 fn origin(&self) -> crate::tools::ToolOrigin {
1402 crate::tools::ToolOrigin::Mcp {
1403 server_id: "srv".to_string(),
1404 server_tool_name: "shared".to_string(),
1405 }
1406 }
1407 }
1408
1409 #[test]
1410 #[should_panic(expected = "tool catalog rejected mutation")]
1411 fn from_tools_with_duplicate_capability_identity_fails_closed() {
1412 let _ = ToolRegistry::from_tools(vec![
1416 Arc::new(McpTwin("twin_one")),
1417 Arc::new(McpTwin("twin_two")),
1418 ]);
1419 }
1420}