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 #[allow(unused_mut)]
42 let mut tools: Vec<Arc<dyn Tool>> = vec![
43 #[cfg(all(windows, feature = "command-exec"))]
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::discovery::SearchToolsTool),
69 Arc::new(crate::tools::discovery::ActivateToolsTool),
70 Arc::new(crate::tools::memory::MemorySearchTool),
74 Arc::new(crate::tools::memory::MemoryFetchTool),
75 Arc::new(crate::tools::memory::MemoryStoreTool),
76 Arc::new(crate::tools::memory::MemoryForgetTool),
77 Arc::new(crate::tools::forum::ForumPostTool),
80 Arc::new(crate::tools::forum::ForumReadTool),
81 Arc::new(crate::tools::forum::ForumForgetTool),
82 ];
83 #[cfg(feature = "command-exec")]
87 {
88 tools.push(Arc::new(crate::tools::bash::BashTool));
89 tools.push(Arc::new(crate::tools::shell::ShellStartTool));
90 tools.push(Arc::new(crate::tools::shell::ShellSendTool));
91 tools.push(Arc::new(crate::tools::shell::ShellEndTool));
92 }
93 Self::from_tools(tools)
94 }
95
96 pub fn empty() -> Self {
99 Self::from_tools(Vec::new())
100 }
101
102 pub fn disable(&mut self, names: &[String]) {
108 self.try_disable(names).expect(CATALOG_REJECTED_MSG);
109 }
110
111 pub fn try_disable(&mut self, names: &[String]) -> Result<(), CatalogError> {
124 if names.is_empty() {
125 return Ok(());
126 }
127 let remaining: Vec<Arc<dyn Tool>> = self
128 .tools
129 .values()
130 .filter(|t| !names.iter().any(|n| n == t.name()))
131 .cloned()
132 .collect();
133 if remaining.len() == self.tools.len() {
134 return Ok(());
135 }
136 let prior = self.catalog.generation();
137 let mut candidate = Self::try_from_tools(remaining)?;
138 candidate.catalog.rebase_past(prior)?;
139 *self = candidate;
140 Ok(())
141 }
142
143 pub fn without_subagent() -> Self {
145 #[allow(unused_mut)]
146 let mut tools: Vec<Arc<dyn Tool>> = vec![
147 #[cfg(all(windows, feature = "command-exec"))]
148 Arc::new(crate::tools::powershell::PowerShellTool),
149 Arc::new(crate::tools::read::ReadTool),
150 Arc::new(crate::tools::write::WriteTool),
151 Arc::new(crate::tools::edit::EditTool),
152 Arc::new(crate::tools::grep::GrepTool),
153 Arc::new(crate::tools::find::FindTool),
154 Arc::new(crate::tools::ls::LsTool),
155 Arc::new(crate::tools::forum::ForumPostTool),
156 Arc::new(crate::tools::forum::ForumReadTool),
157 Arc::new(crate::tools::forum::ForumForgetTool),
158 ];
159 #[cfg(feature = "command-exec")]
160 {
161 tools.push(Arc::new(crate::tools::bash::BashTool));
162 tools.push(Arc::new(crate::tools::shell::ShellStartTool));
163 tools.push(Arc::new(crate::tools::shell::ShellSendTool));
164 tools.push(Arc::new(crate::tools::shell::ShellEndTool));
165 }
166 Self::from_tools(tools)
167 }
168
169 pub fn without_subagent_with_extensions(extension_tools: &ToolRegistry) -> Self {
177 let mut combined = Self::without_subagent();
178 for tool in extension_tools.tools.values() {
179 if tool.extension_id().is_some() && !is_recursive_subagent_tool_name(tool.name()) {
180 combined
181 .insert_tool_with_catalog(tool.clone())
182 .expect(CATALOG_REJECTED_MSG);
183 }
184 }
185 combined.rebuild_schema();
186 combined
187 }
188
189 fn from_tools(tool_list: Vec<Arc<dyn Tool>>) -> Self {
195 Self::try_from_tools(tool_list).expect(CATALOG_REJECTED_MSG)
196 }
197
198 #[doc(hidden)]
202 pub fn from_tools_for_tests(tool_list: Vec<Arc<dyn Tool>>) -> Self {
203 Self::from_tools(tool_list)
204 }
205
206 fn try_from_tools(tool_list: Vec<Arc<dyn Tool>>) -> Result<Self, CatalogError> {
207 let mut registry = ToolRegistry {
208 tools: HashMap::new(),
209 cached_schema: Arc::new(Vec::new()),
210 api_to_runtime_names: HashMap::new(),
211 input_name_maps: HashMap::new(),
212 catalog: ToolCatalog::empty(),
213 };
214 for tool in tool_list {
218 let name = tool.name().to_string();
219 registry.tools.insert(name, tool);
220 }
221 let sorted: Vec<Arc<dyn Tool>> =
227 registry.iter_tools_sorted().into_iter().cloned().collect();
228 for tool in sorted {
229 let record = CapabilityRecord::for_registered_tool(&tool);
230 registry.catalog.upsert(None, record)?;
231 }
232 registry.rebuild_schema();
233 Ok(registry)
234 }
235
236 fn api_safe_name(name: &str, used: &HashSet<String>) -> String {
237 Self::api_safe_identifier(name, used, 128, false)
238 }
239
240 pub(crate) fn unclaimed_wire_name(runtime_name: &str) -> String {
243 Self::api_safe_name(runtime_name, &HashSet::new())
244 }
245
246 fn api_safe_property_name(name: &str, used: &HashSet<String>) -> String {
247 Self::api_safe_identifier(name, used, 64, true)
248 }
249
250 fn api_safe_identifier(
251 name: &str,
252 used: &HashSet<String>,
253 max_len: usize,
254 allow_dot: bool,
255 ) -> String {
256 let mut sanitized = String::with_capacity(name.len());
257 for ch in name.chars() {
258 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || (allow_dot && ch == '.') {
259 sanitized.push(ch);
260 } else {
261 sanitized.push('_');
262 }
263 }
264 if sanitized.is_empty() {
265 sanitized.push_str("field");
266 }
267 if sanitized.len() > max_len {
268 sanitized.truncate(max_len);
269 }
270
271 let base = sanitized.clone();
272 let mut suffix = 2;
273 while used.contains(&sanitized) {
274 let suffix_str = format!("_{suffix}");
275 let keep = max_len.saturating_sub(suffix_str.len());
276 sanitized = format!("{}{}", &base[..base.len().min(keep)], suffix_str);
277 suffix += 1;
278 }
279 sanitized
280 }
281
282 fn sanitize_schema(mut schema: Value) -> (Value, SchemaNameMap) {
283 let mut map = SchemaNameMap::default();
284 let Some(obj) = schema.as_object_mut() else {
285 return (schema, map);
286 };
287
288 let mut required_name_map = HashMap::new();
289 if let Some(props_value) = obj.get_mut("properties") {
290 if let Some(props) = props_value.as_object_mut() {
291 let original = std::mem::take(props);
292 let mut used = HashSet::new();
293 for (runtime_name, child_schema) in original {
294 let api_name = Self::api_safe_property_name(&runtime_name, &used);
295 used.insert(api_name.clone());
296 required_name_map.insert(runtime_name.clone(), api_name.clone());
297 map.api_to_runtime.insert(api_name.clone(), runtime_name);
298
299 let (sanitized_child, child_map) = Self::sanitize_schema(child_schema);
300 if !child_map.api_to_runtime.is_empty() || !child_map.children.is_empty() {
301 map.children.insert(api_name.clone(), child_map);
302 }
303 props.insert(api_name, sanitized_child);
304 }
305 }
306 }
307
308 if let Some(required) = obj.get_mut("required").and_then(Value::as_array_mut) {
309 for item in required.iter_mut() {
310 if let Some(name) = item.as_str() {
311 if let Some(api_name) = required_name_map.get(name) {
312 *item = Value::String(api_name.clone());
313 }
314 }
315 }
316 }
317
318 if let Some(items) = obj.get_mut("items") {
321 let (sanitized_items, items_map) = Self::sanitize_schema(std::mem::take(items));
322 if !items_map.api_to_runtime.is_empty()
323 || !items_map.children.is_empty()
324 || items_map.items.is_some()
325 {
326 map.items = Some(Box::new(items_map));
327 }
328 *items = sanitized_items;
329 }
330
331 (schema, map)
332 }
333
334 fn translate_input_names(input: Value, map: &SchemaNameMap) -> Value {
335 match input {
336 Value::Object(obj) => {
337 let mut out = serde_json::Map::new();
338 for (api_name, value) in obj {
339 let runtime_name = map
340 .api_to_runtime
341 .get(&api_name)
342 .cloned()
343 .unwrap_or_else(|| api_name.clone());
344 let value = if let Some(child) = map.children.get(&api_name) {
345 Self::translate_input_names(value, child)
346 } else {
347 value
348 };
349 out.insert(runtime_name, value);
350 }
351 Value::Object(out)
352 }
353 Value::Array(arr) => {
354 if let Some(items_map) = &map.items {
356 Value::Array(
357 arr.into_iter()
358 .map(|v| Self::translate_input_names(v, items_map))
359 .collect(),
360 )
361 } else {
362 Value::Array(arr)
363 }
364 }
365 other => other,
366 }
367 }
368
369 fn rebuild_schema(&mut self) {
370 let mut used = HashSet::new();
371 let mut api_to_runtime_names = HashMap::new();
372 let mut input_name_maps = HashMap::new();
373 let mut schema = Vec::with_capacity(self.tools.len());
374
375 let mut sorted_tools: Vec<_> = self.tools.values().collect();
390 sorted_tools.sort_by_key(|t| {
391 let builtin_first = match t.origin() {
392 crate::tools::ToolOrigin::Builtin => 0u8,
393 _ => 1u8,
394 };
395 (builtin_first, t.name().to_string())
396 });
397
398 for tool in sorted_tools {
399 let runtime_name = tool.name();
400 let api_name = Self::api_safe_name(runtime_name, &used);
401 used.insert(api_name.clone());
402 api_to_runtime_names.insert(api_name.clone(), runtime_name.to_string());
403 let (input_schema, input_map) = Self::sanitize_schema(tool.parameters());
404 input_name_maps.insert(api_name.clone(), input_map);
405 schema.push(serde_json::json!({
406 "name": api_name,
407 "description": tool.description(),
408 "input_schema": input_schema
409 }));
410 }
411
412 self.api_to_runtime_names = api_to_runtime_names;
413 self.input_name_maps = input_name_maps;
414 self.cached_schema = Arc::new(schema);
415 }
416
417 pub fn register(&mut self, tool: Arc<dyn Tool>) {
427 self.try_register(tool).expect(CATALOG_REJECTED_MSG);
428 }
429
430 pub fn try_register(&mut self, tool: Arc<dyn Tool>) -> Result<(), CatalogError> {
436 self.insert_tool_with_catalog(tool)?;
437 self.rebuild_schema();
438 Ok(())
439 }
440
441 pub fn try_register_batch(
455 &mut self,
456 tool_list: Vec<Arc<dyn Tool>>,
457 ) -> Result<usize, CatalogError> {
458 if tool_list.is_empty() {
459 return Ok(0);
460 }
461 let mut batch_names: HashSet<String> = HashSet::new();
462 let mut batch_ids: HashSet<crate::tools::catalog::ToolId> = HashSet::new();
463 for tool in &tool_list {
464 let name = tool.name();
465 if self.tools.contains_key(name) || !batch_names.insert(name.to_string()) {
466 return Err(CatalogError::DuplicateRuntimeName(
467 agent_core::BoundedText::new(name, 128).text,
468 ));
469 }
470 let id = tool_id_for(tool.as_ref());
471 if self.catalog.get(&id).is_some() || !batch_ids.insert(id.clone()) {
472 return Err(CatalogError::DuplicateToolId(id));
473 }
474 }
475 let count = tool_list.len();
476 let mut candidate = self.clone();
477 for tool in tool_list {
478 candidate.insert_tool_with_catalog(tool)?;
479 }
480 candidate.rebuild_schema();
481 *self = candidate;
482 Ok(count)
483 }
484
485 fn insert_tool_with_catalog(&mut self, tool: Arc<dyn Tool>) -> Result<(), CatalogError> {
494 let record = CapabilityRecord::for_registered_tool(&tool);
495 let replaced = self
496 .tools
497 .get(tool.name())
498 .map(|old| tool_id_for(old.as_ref()));
499 self.catalog.upsert(replaced.as_ref(), record)?;
500 self.tools.insert(tool.name().to_string(), tool);
501 Ok(())
502 }
503
504 pub fn catalog(&self) -> &ToolCatalog {
507 &self.catalog
508 }
509
510 #[doc(hidden)]
518 pub fn resume_catalog_generation_for_tests(
519 &mut self,
520 generation: crate::tools::catalog::CatalogGeneration,
521 ) {
522 self.catalog.set_generation_for_tests(generation);
523 }
524
525 #[doc(hidden)]
531 pub fn upsert_catalog_record_for_tests(
532 &mut self,
533 replaced: Option<&crate::tools::catalog::ToolId>,
534 record: crate::tools::catalog::CapabilityRecord,
535 ) {
536 self.catalog
537 .upsert(replaced, record)
538 .expect("test upsert must be structurally valid");
539 }
540
541 pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
542 let runtime_name = self
543 .api_to_runtime_names
544 .get(name)
545 .map(String::as_str)
546 .unwrap_or(name);
547 self.tools.get(runtime_name)
548 }
549
550 pub fn runtime_name_for_api<'a>(&'a self, name: &'a str) -> &'a str {
551 self.api_to_runtime_names
552 .get(name)
553 .map(String::as_str)
554 .unwrap_or(name)
555 }
556
557 pub fn resolve_wire_call(
567 &self,
568 wire_name: &str,
569 ) -> Option<crate::tools::activation::ResolvedToolCall> {
570 let runtime_name = self
571 .api_to_runtime_names
572 .get(wire_name)
573 .map(String::as_str)
574 .unwrap_or(wire_name);
575 let tool = self.tools.get(runtime_name)?;
576 Some(crate::tools::activation::ResolvedToolCall::new(
577 wire_name,
578 runtime_name,
579 tool_id_for(tool.as_ref()),
580 ))
581 }
582
583 pub fn translate_input_for_api_tool(&self, tool_name: &str, input: Value) -> Value {
584 if let Some(map) = self.input_name_maps.get(tool_name) {
585 Self::translate_input_names(input, map)
586 } else {
587 input
588 }
589 }
590
591 pub fn tools_schema(&self) -> Arc<Vec<Value>> {
592 Arc::clone(&self.cached_schema)
593 }
594
595 pub fn deferred_tool_split(
631 &self,
632 session: &crate::tools::activation::SessionToolSet,
633 referenced_ids: &HashSet<String>,
634 ) -> DeferredToolSplit {
635 let projection = self.session_tools_schema(session).schema;
636 let projected: HashSet<&str> = projection
637 .iter()
638 .filter_map(|entry| entry["name"].as_str())
639 .collect();
640 let mut split = DeferredToolSplit::default();
641 for entry in self.cached_schema.iter() {
642 let Some(api_name) = entry["name"].as_str() else {
643 continue;
644 };
645 let Some(tool) = self.tools.get(self.runtime_name_for_api(api_name)) else {
646 continue;
647 };
648 let id = tool_id_for(tool.as_ref());
649 if projected.contains(api_name) {
650 if session.is_core(&id) || !referenced_ids.contains(id.as_str()) {
651 split.loaded.push(entry.clone());
652 } else {
653 split.deferred.push(deferred_entry(entry));
654 }
655 } else if self
656 .catalog
657 .get(&id)
658 .is_some_and(crate::tools::activation::is_activatable_record)
659 {
660 split.deferred.push(deferred_entry(entry));
661 } else {
662 continue;
663 }
664 split
665 .id_to_api
666 .insert(id.as_str().to_string(), api_name.to_string());
667 }
668 split
669 }
670
671 pub fn session_tools_schema(
672 &self,
673 session: &crate::tools::activation::SessionToolSet,
674 ) -> SessionSchemaProjection {
675 let mut projected = Vec::new();
676 let mut dropped = Vec::new();
677 for entry in self.cached_schema.iter() {
678 let Some(api_name) = entry["name"].as_str() else {
679 continue;
680 };
681 let runtime_name = self.runtime_name_for_api(api_name);
682 let Some(tool) = self.tools.get(runtime_name) else {
683 continue;
684 };
685 let id = tool_id_for(tool.as_ref());
686 let pinned = session
687 .core_pin(&id)
688 .map(|pin| (pin.schema_digest(), pin.provenance()))
689 .or_else(|| {
690 session
691 .activation(&id)
692 .map(|a| (a.schema_digest(), a.provenance()))
693 });
694 let Some((pinned_digest, pinned_provenance)) = pinned else {
695 continue; };
697 let Some(record) = self.catalog.get(&id) else {
698 tracing::warn!(
699 tool = %id,
700 "session schema projection drops uncataloged session member \
701 (removed since the session pinned it)"
702 );
703 dropped.push((id, DroppedSessionMember::Uncataloged));
704 continue;
705 };
706 if pinned_digest != record.schema_digest() {
707 tracing::warn!(
708 tool = %id,
709 "session schema projection drops drifted session member \
710 (schema digest changed since the session pinned it)"
711 );
712 dropped.push((id, DroppedSessionMember::SchemaDigestMismatch));
713 continue;
714 }
715 if pinned_provenance != record.provenance() {
716 tracing::warn!(
717 tool = %id,
718 "session schema projection drops drifted session member \
719 (trust provenance changed since the session pinned it)"
720 );
721 dropped.push((id, DroppedSessionMember::ProvenanceMismatch));
722 continue;
723 }
724 projected.push(entry.clone());
725 }
726 let seen: HashSet<&crate::tools::catalog::ToolId> =
733 dropped.iter().map(|(id, _)| id).collect();
734 let vanished: Vec<crate::tools::catalog::ToolId> = session
735 .core_ids()
736 .chain(session.activated().map(|a| a.grant().tool_id()))
737 .filter(|id| !seen.contains(id) && self.catalog.get(id).is_none())
738 .cloned()
739 .collect();
740 for id in vanished {
741 tracing::warn!(
742 tool = %id,
743 "session schema projection drops uncataloged session member (removed from the registry since the session pinned it)"
744 );
745 dropped.push((id, DroppedSessionMember::Uncataloged));
746 }
747 SessionSchemaProjection {
748 schema: projected,
749 dropped,
750 }
751 }
752
753 pub fn tool_names_for_extension(&self, extension_id: &str) -> Vec<String> {
756 let mut names: Vec<String> = self
757 .tools
758 .values()
759 .filter(|t| t.extension_id() == Some(extension_id))
760 .map(|t| t.name().to_string())
761 .collect();
762 names.sort();
763 names
764 }
765
766 pub fn iter_tools_sorted(&self) -> Vec<&Arc<dyn Tool>> {
769 let mut tools: Vec<_> = self.tools.values().collect();
770 tools.sort_by_key(|t| t.name());
771 tools
772 }
773}
774
775#[derive(Clone, Debug, PartialEq, Eq)]
781pub struct SessionSchemaProjection {
782 pub schema: Vec<Value>,
784 pub dropped: Vec<(crate::tools::catalog::ToolId, DroppedSessionMember)>,
788}
789
790#[derive(Clone, Copy, Debug, PartialEq, Eq)]
792pub enum DroppedSessionMember {
793 Uncataloged,
795 SchemaDigestMismatch,
797 ProvenanceMismatch,
800}
801
802const CATALOG_REJECTED_MSG: &str =
808 "tool catalog rejected mutation: refusing to expose a tool the catalog could not record";
809
810fn is_recursive_subagent_tool_name(name: &str) -> bool {
811 let api_name = ToolRegistry::api_safe_name(name, &HashSet::new());
812 matches!(
813 api_name.as_str(),
814 "subagent"
815 | "subagent_start"
816 | "subagent_status"
817 | "subagent_steer"
818 | "subagent_collect"
819 | "subagent_resume"
820 | "subagent_model_authorize"
821 | "subagent_models"
822 | "memory_context"
826 )
827}
828#[derive(Debug, Clone, Default)]
830pub struct DeferredToolSplit {
831 pub loaded: Vec<Value>,
833 pub deferred: Vec<Value>,
835 pub id_to_api: std::collections::HashMap<String, String>,
837}
838
839fn deferred_entry(entry: &Value) -> Value {
840 let mut entry = entry.clone();
841 if let Some(map) = entry.as_object_mut() {
842 map.remove("cache_control");
843 map.insert("defer_loading".to_string(), Value::Bool(true));
844 }
845 entry
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851 use crate::{Result, ToolContext};
852 use serde_json::json;
853
854 struct NamedTool(&'static str);
855
856 #[async_trait::async_trait]
857 impl Tool for NamedTool {
858 fn name(&self) -> &str {
859 self.0
860 }
861 fn description(&self) -> &str {
862 "test tool"
863 }
864 fn parameters(&self) -> Value {
865 json!({"type": "object"})
866 }
867 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
868 Ok("ok".to_string())
869 }
870 }
871
872 struct SchemaTool;
873
874 #[async_trait::async_trait]
875 impl Tool for SchemaTool {
876 fn name(&self) -> &str {
877 "schema_tool"
878 }
879 fn description(&self) -> &str {
880 "schema tool"
881 }
882 fn parameters(&self) -> Value {
883 json!({
884 "type": "object",
885 "properties": {
886 "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going": {"type": "string"},
887 "nested:obj": {
888 "type": "object",
889 "properties": {"inner/key": {"type": "string"}},
890 "required": ["inner/key"]
891 }
892 },
893 "required": [
894 "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going",
895 "nested:obj"
896 ]
897 })
898 }
899 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
900 Ok("ok".to_string())
901 }
902 }
903
904 #[test]
905 fn tool_schema_uses_api_safe_names_and_maps_back() {
906 let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool("plugin:skill.tool"))]);
907
908 assert_eq!(registry.tools_schema()[0]["name"], "plugin_skill_tool");
909 assert!(registry.get("plugin:skill.tool").is_some());
910 assert!(registry.get("plugin_skill_tool").is_some());
911 assert_eq!(
912 registry.runtime_name_for_api("plugin_skill_tool"),
913 "plugin:skill.tool"
914 );
915 }
916
917 #[test]
927 fn extension_leaf_name_never_shadows_builtin_memory_fetch_api_name() {
928 struct ExtMemoryTool {
929 runtime: &'static str,
930 ext: &'static str,
931 }
932 #[async_trait::async_trait]
933 impl Tool for ExtMemoryTool {
934 fn origin(&self) -> crate::tools::ToolOrigin {
935 crate::tools::ToolOrigin::Extension {
936 extension_id: self.ext.to_string(),
937 }
938 }
939 fn extension_id(&self) -> Option<&str> {
940 Some(self.ext)
941 }
942 fn name(&self) -> &str {
943 self.runtime
944 }
945 fn description(&self) -> &str {
946 "extension memory tool (single-id schema)"
947 }
948 fn parameters(&self) -> Value {
949 json!({
950 "type": "object",
951 "properties": {"id": {"type": "string"}},
952 "required": ["id"]
953 })
954 }
955 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
956 Ok("ok".to_string())
957 }
958 }
959
960 let registry = ToolRegistry::from_tools(vec![
961 Arc::new(crate::tools::memory::MemoryFetchTool),
962 Arc::new(ExtMemoryTool {
964 runtime: "memory:fetch",
965 ext: "memory",
966 }),
967 Arc::new(ExtMemoryTool {
969 runtime: "axel-memory-manager:memory_fetch",
970 ext: "axel-memory-manager",
971 }),
972 ]);
973
974 assert_eq!(
976 registry.runtime_name_for_api("memory_fetch"),
977 "memory_fetch",
978 "wire name `memory_fetch` must resolve to the built-in"
979 );
980 let schema = registry.tools_schema();
981 let builtin = schema
982 .iter()
983 .find(|e| e["name"] == "memory_fetch")
984 .expect("built-in memory_fetch exposed");
985 assert_eq!(
986 builtin["input_schema"]["required"],
987 json!(["ids"]),
988 "built-in memory_fetch schema must remain {{ids: [...]}}"
989 );
990
991 let axel = schema
993 .iter()
994 .find(|e| e["name"] == "axel-memory-manager_memory_fetch")
995 .expect("extension tool exposed under namespaced API name");
996 assert_eq!(axel["input_schema"]["required"], json!(["id"]));
997 assert_eq!(
1000 registry.runtime_name_for_api("memory_fetch_2"),
1001 "memory:fetch"
1002 );
1003 let resolved = registry.resolve_wire_call("memory_fetch").unwrap();
1006 assert_eq!(resolved.runtime_name(), "memory_fetch");
1007 }
1008
1009 #[test]
1010 fn tool_schema_disambiguates_sanitized_name_collisions() {
1011 let registry =
1012 ToolRegistry::from_tools(vec![Arc::new(NamedTool("a:b")), Arc::new(NamedTool("a.b"))]);
1013 let names: HashSet<String> = registry
1014 .tools_schema()
1015 .iter()
1016 .filter_map(|s| s["name"].as_str().map(str::to_string))
1017 .collect();
1018
1019 assert_eq!(names.len(), 2);
1020 assert!(names.contains("a_b"));
1021 assert!(names.contains("a_b_2"));
1022 assert!(registry.get("a_b").is_some());
1023 assert!(registry.get("a_b_2").is_some());
1024 }
1025
1026 #[test]
1027 fn tool_schema_truncates_long_names_to_anthropic_limit() {
1028 let long = "x".repeat(140);
1029 let leaked: &'static str = Box::leak(long.into_boxed_str());
1030 let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool(leaked))]);
1031 let schema = registry.tools_schema();
1032 let name = schema[0]["name"].as_str().unwrap();
1033
1034 assert_eq!(name.len(), 128);
1035 assert!(name
1036 .chars()
1037 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
1038 assert!(registry.get(name).is_some());
1039 }
1040
1041 #[test]
1042 fn tool_schema_sanitizes_input_property_names_and_translates_inputs_back() {
1043 let registry = ToolRegistry::from_tools(vec![Arc::new(SchemaTool)]);
1044 let schema = registry.tools_schema();
1045 let input_schema = &schema[0]["input_schema"];
1046 let props = input_schema["properties"].as_object().unwrap();
1047
1048 assert!(props.keys().all(|k| k.len() <= 64
1049 && k.chars()
1050 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')));
1051 assert_eq!(
1052 input_schema["required"].as_array().unwrap()[0]
1053 .as_str()
1054 .unwrap()
1055 .len(),
1056 64
1057 );
1058 assert_eq!(input_schema["required"][1], "nested_obj");
1059 assert!(props["nested_obj"]["properties"]
1060 .as_object()
1061 .unwrap()
1062 .contains_key("inner_key"));
1063 assert_eq!(props["nested_obj"]["required"][0], "inner_key");
1064
1065 let first_required = input_schema["required"][0].as_str().unwrap();
1066 let translated = registry.translate_input_for_api_tool(
1067 "schema_tool",
1068 json!({
1069 first_required: "value",
1070 "nested_obj": {"inner_key": "nested"}
1071 }),
1072 );
1073
1074 assert_eq!(
1075 translated["bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going"],
1076 "value"
1077 );
1078 assert_eq!(translated["nested:obj"]["inner/key"], "nested");
1079 }
1080
1081 #[cfg(feature = "command-exec")]
1082 #[test]
1083 fn test_tool_registry_new() {
1084 let registry = ToolRegistry::new();
1085
1086 assert_eq!(
1090 registry.tools_schema().len(),
1091 28 + usize::from(cfg!(windows))
1092 );
1093 for name in ["forum_post", "forum_read", "forum_forget"] {
1094 assert!(registry.get(name).is_some(), "missing {name}");
1095 }
1096
1097 assert!(registry.get("bash").is_some());
1099
1100 assert!(registry.get("nonexistent").is_none());
1102
1103 assert!(registry.get("bash").is_some());
1105 assert!(registry.get("read").is_some());
1106 assert!(registry.get("write").is_some());
1107 assert!(registry.get("edit").is_some());
1108 assert!(registry.get("grep").is_some());
1109 assert!(registry.get("find").is_some());
1110 assert!(registry.get("ls").is_some());
1111 assert!(registry.get("subagent").is_some());
1112 assert!(registry.get("subagent_model_authorize").is_some());
1113 assert!(registry.get("subagent_models").is_some());
1114 }
1115
1116 #[cfg(feature = "command-exec")]
1117 #[test]
1118 fn test_disable_removes_tools_from_registry_and_schema() {
1119 let mut registry = ToolRegistry::new();
1120 let before = registry.tools_schema().len();
1121
1122 registry.disable(&["bash".to_string(), "ls".to_string()]);
1123
1124 assert!(registry.get("bash").is_none());
1126 assert!(registry.get("ls").is_none());
1127 assert!(registry.get("read").is_some());
1129 assert!(registry.get("subagent").is_some());
1130 assert_eq!(registry.tools_schema().len(), before - 2);
1132 }
1133
1134 #[cfg(feature = "command-exec")]
1135 #[test]
1136 fn test_disable_empty_and_unknown_are_noops() {
1137 let mut registry = ToolRegistry::new();
1138 let n = registry.tools_schema().len();
1139 registry.disable(&[]); assert_eq!(registry.tools_schema().len(), n);
1141 registry.disable(&["not_a_real_tool".to_string()]); assert_eq!(registry.tools_schema().len(), n);
1143 assert!(registry.get("bash").is_some());
1144 }
1145
1146 #[cfg(feature = "command-exec")]
1147 #[test]
1148 fn test_tool_registry_without_subagent() {
1149 let registry = ToolRegistry::without_subagent();
1150
1151 assert_eq!(
1153 registry.tools_schema().len(),
1154 13 + usize::from(cfg!(windows))
1155 );
1156 for name in ["forum_post", "forum_read", "forum_forget"] {
1157 assert!(registry.get(name).is_some(), "missing {name}");
1158 }
1159 assert!(registry.get("subagent").is_none());
1161
1162 assert!(registry.get("bash").is_some());
1164
1165 assert!(registry.get("bash").is_some());
1167 assert!(registry.get("read").is_some());
1168 assert!(registry.get("write").is_some());
1169 assert!(registry.get("edit").is_some());
1170 assert!(registry.get("grep").is_some());
1171 assert!(registry.get("find").is_some());
1172 assert!(registry.get("ls").is_some());
1173 }
1174
1175 #[cfg(not(feature = "command-exec"))]
1180 #[test]
1181 fn hardened_build_excludes_command_exec_tools() {
1182 let registry = ToolRegistry::new();
1183 assert!(registry.get("bash").is_none(), "bash must be absent in a hardened build");
1184 assert!(registry.get("shell_start").is_none());
1185 assert!(registry.get("shell_send").is_none());
1186 assert!(registry.get("shell_end").is_none());
1187 assert!(registry.get("read").is_some());
1189 assert!(registry.get("ls").is_some());
1190 assert!(registry.get("subagent").is_some());
1191
1192 let without_sub = ToolRegistry::without_subagent();
1193 assert!(without_sub.get("bash").is_none());
1194 assert!(without_sub.get("shell_start").is_none());
1195 assert!(without_sub.get("read").is_some());
1196 }
1197
1198 #[test]
1199 fn test_tool_registry_register() {
1200 let mut registry = ToolRegistry::without_subagent();
1201 let initial_count = registry.tools_schema().len();
1202
1203 struct TestTool;
1205 #[async_trait::async_trait]
1206 impl Tool for TestTool {
1207 fn name(&self) -> &str {
1208 "test_tool"
1209 }
1210 fn description(&self) -> &str {
1211 "A test tool"
1212 }
1213 fn parameters(&self) -> Value {
1214 json!({"type": "object"})
1215 }
1216 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1217 Ok("test result".to_string())
1218 }
1219 }
1220
1221 registry.register(Arc::new(TestTool));
1222
1223 assert_eq!(registry.tools_schema().len(), initial_count + 1);
1225
1226 assert!(registry.get("test_tool").is_some());
1228 }
1229
1230 #[test]
1231 fn tool_names_for_extension_filters_by_owner_and_sorts() {
1232 struct OwnedTool(&'static str, Option<&'static str>);
1233 #[async_trait::async_trait]
1234 impl Tool for OwnedTool {
1235 fn name(&self) -> &str {
1236 self.0
1237 }
1238 fn description(&self) -> &str {
1239 "owned"
1240 }
1241 fn parameters(&self) -> Value {
1242 json!({"type": "object"})
1243 }
1244 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1245 Ok("ok".to_string())
1246 }
1247 fn extension_id(&self) -> Option<&str> {
1248 self.1
1249 }
1250 }
1251
1252 let mut registry = ToolRegistry::without_subagent();
1253 registry.register(Arc::new(OwnedTool("alpha:zed", Some("alpha"))));
1254 registry.register(Arc::new(OwnedTool("alpha:bar", Some("alpha"))));
1255 registry.register(Arc::new(OwnedTool("beta:thing", Some("beta"))));
1256
1257 assert_eq!(
1258 registry.tool_names_for_extension("alpha"),
1259 vec!["alpha:bar".to_string(), "alpha:zed".to_string()]
1260 );
1261 assert_eq!(
1262 registry.tool_names_for_extension("beta"),
1263 vec!["beta:thing".to_string()]
1264 );
1265 assert!(registry.tool_names_for_extension("ghost").is_empty());
1266 assert!(registry.tool_names_for_extension("bash").is_empty());
1268 }
1269
1270 struct OwnedTool(&'static str, Option<&'static str>);
1271 #[async_trait::async_trait]
1272 impl Tool for OwnedTool {
1273 fn name(&self) -> &str {
1274 self.0
1275 }
1276 fn description(&self) -> &str {
1277 "owned"
1278 }
1279 fn parameters(&self) -> Value {
1280 json!({"type": "object"})
1281 }
1282 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1283 Ok("ok".to_string())
1284 }
1285 fn extension_id(&self) -> Option<&str> {
1286 self.1
1287 }
1288 }
1289
1290 #[test]
1291 fn without_subagent_excludes_subagent_tools() {
1292 let registry = ToolRegistry::without_subagent();
1293 assert!(registry.get("subagent").is_none());
1294 assert!(registry.get("subagent_start").is_none());
1295 assert!(registry.get("subagent_status").is_none());
1296 assert!(registry.get("subagent_steer").is_none());
1297 assert!(registry.get("subagent_collect").is_none());
1298 assert!(registry.get("subagent_resume").is_none());
1299 assert!(registry.get("ls").is_some());
1301 assert!(registry.get("read").is_some());
1302 }
1303
1304 #[test]
1305 fn without_subagent_with_extensions_includes_extension_tools() {
1306 let mut other = ToolRegistry::empty();
1307 other.register(Arc::new(OwnedTool("alpha:do_thing", Some("alpha"))));
1308
1309 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1310
1311 assert!(merged.get("alpha:do_thing").is_some());
1313 assert!(merged.get("read").is_some());
1315 assert!(merged.get("subagent_start").is_none());
1317 }
1318
1319 #[cfg(feature = "command-exec")]
1320 #[test]
1321 fn forum_membership_preserves_worker_bounds_and_foreground_disclosure() {
1322 use crate::tools::activation::{SessionId, SessionToolSet};
1323 use crate::tools::catalog::{ToolEffect, ToolId};
1324 let main = ToolRegistry::new();
1325 let mut extensions = ToolRegistry::empty();
1326 extensions.register(Arc::new(OwnedTool("peer:probe", Some("peer"))));
1327 let merged = ToolRegistry::without_subagent_with_extensions(&extensions);
1328 assert_eq!(merged.tools_schema().len(), 14 + usize::from(cfg!(windows)));
1329 assert!(merged.get("peer:probe").is_some());
1330 for registry in [&main, &ToolRegistry::without_subagent(), &merged] {
1331 for (name, effect) in [
1332 ("forum_post", ToolEffect::NonIdempotent),
1333 ("forum_read", ToolEffect::ReadOnly),
1334 ("forum_forget", ToolEffect::NonIdempotent),
1335 ] {
1336 let tool = registry.get(name).unwrap();
1337 assert_eq!(tool.origin(), crate::tools::ToolOrigin::Builtin);
1338 assert_eq!(tool.effect(), effect);
1339 assert!(registry.catalog().get(&ToolId::builtin(name)).is_some());
1340 assert_eq!(
1341 registry
1342 .tools_schema()
1343 .iter()
1344 .filter(|schema| schema["name"] == name)
1345 .count(),
1346 1
1347 );
1348 }
1349 }
1350 for registry in [ToolRegistry::without_subagent(), merged] {
1351 for name in [
1352 "subagent",
1353 "subagent_start",
1354 "subagent_status",
1355 "subagent_steer",
1356 "subagent_collect",
1357 "subagent_resume",
1358 "subagent_model_authorize",
1359 "subagent_models",
1360 "search_tools",
1361 "activate_tools",
1362 "memory_context",
1363 "memory_store",
1364 "memory_search",
1365 "memory_fetch",
1366 "memory_forget",
1367 ] {
1368 assert!(registry.get(name).is_none(), "worker must not gain {name}");
1369 }
1370 for name in ["bash", "write", "edit"] {
1371 assert!(registry.get(name).is_some(), "worker must retain {name}");
1372 }
1373 }
1374 let session = SessionToolSet::progressive_core_for_catalog(
1375 SessionId::parse("forum-foreground-test").unwrap(),
1376 main.catalog(),
1377 );
1378 let projection = main.session_tools_schema(&session);
1379 for name in ["forum_post", "forum_read", "forum_forget"] {
1380 assert!(!session.is_core(&ToolId::builtin(name)));
1381 assert!(!projection
1382 .schema
1383 .iter()
1384 .any(|schema| schema["name"] == name));
1385 }
1386 }
1387
1388 #[test]
1389 fn without_subagent_with_extensions_excludes_built_ins_from_other_registry() {
1390 let other = ToolRegistry::new();
1395
1396 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1397
1398 let read = merged.get("read").expect("read present");
1400 assert!(read.extension_id().is_none());
1401 assert!(merged.get("subagent_start").is_none());
1403 assert!(merged.get("subagent").is_none());
1404 }
1405
1406 #[test]
1407 fn without_subagent_with_extensions_rejects_recursive_tool_name_collisions() {
1408 let mut other = ToolRegistry::empty();
1409 for name in [
1410 "subagent",
1411 "subagent_start",
1412 "subagent_status",
1413 "subagent_steer",
1414 "subagent_collect",
1415 "subagent_resume",
1416 "subagent_model_authorize",
1417 "subagent_models",
1418 "memory_context",
1419 ] {
1420 other.register(Arc::new(OwnedTool(name, Some("malicious-extension"))));
1421 }
1422
1423 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1424 for name in [
1425 "subagent",
1426 "subagent_start",
1427 "subagent_status",
1428 "subagent_steer",
1429 "subagent_collect",
1430 "subagent_resume",
1431 "subagent_model_authorize",
1432 "subagent_models",
1433 "memory_context",
1434 ] {
1435 assert!(merged.get(name).is_none(), "{name} must stay unavailable");
1436 }
1437 }
1438
1439 #[test]
1440 fn without_subagent_with_extensions_rejects_api_sanitized_recursive_collisions() {
1441 let mut other = ToolRegistry::empty();
1442 for runtime_name in [
1443 "subagent:start",
1444 "subagent:status",
1445 "subagent:steer",
1446 "subagent:collect",
1447 "subagent:resume",
1448 "subagent:model:authorize",
1449 "subagent:models",
1450 "memory:context",
1451 ] {
1452 other.register(Arc::new(OwnedTool(runtime_name, Some("subagent"))));
1453 }
1454
1455 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1456 for api_name in [
1457 "subagent_start",
1458 "subagent_status",
1459 "subagent_steer",
1460 "subagent_collect",
1461 "subagent_resume",
1462 "subagent_model_authorize",
1463 "subagent_models",
1464 "memory_context",
1465 ] {
1466 assert!(
1467 merged.get(api_name).is_none(),
1468 "API-safe recursive name {api_name} must stay unavailable"
1469 );
1470 }
1471 }
1472
1473 #[test]
1477 fn memory_context_present_in_primary_registry_absent_in_recursive_ones() {
1478 assert!(ToolRegistry::new().get("memory_context").is_some());
1479 assert!(ToolRegistry::without_subagent()
1480 .get("memory_context")
1481 .is_none());
1482
1483 let merged = ToolRegistry::without_subagent_with_extensions(&ToolRegistry::new());
1484 assert!(
1485 merged.get("memory_context").is_none(),
1486 "recursive registries must never expose memory_context"
1487 );
1488 }
1489
1490 #[test]
1491 fn without_subagent_with_extensions_does_not_overwrite_existing_builtin() {
1492 let mut other = ToolRegistry::empty();
1498 other.register(Arc::new(OwnedTool("ext:custom", Some("ext"))));
1499
1500 let merged = ToolRegistry::without_subagent_with_extensions(&other);
1501 assert!(merged.get("ext:custom").is_some());
1502 assert!(merged.get("read").is_some());
1503 assert!(merged.get("read").unwrap().extension_id().is_none());
1504 }
1505
1506 struct McpTwin(&'static str);
1508
1509 #[async_trait::async_trait]
1510 impl Tool for McpTwin {
1511 fn name(&self) -> &str {
1512 self.0
1513 }
1514 fn description(&self) -> &str {
1515 "mcp twin"
1516 }
1517 fn parameters(&self) -> Value {
1518 json!({"type": "object"})
1519 }
1520 async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1521 Ok("ok".to_string())
1522 }
1523 fn origin(&self) -> crate::tools::ToolOrigin {
1524 crate::tools::ToolOrigin::Mcp {
1525 server_id: "srv".to_string(),
1526 server_tool_name: "shared".to_string(),
1527 }
1528 }
1529 }
1530
1531 #[test]
1532 #[should_panic(expected = "tool catalog rejected mutation")]
1533 fn from_tools_with_duplicate_capability_identity_fails_closed() {
1534 let _ = ToolRegistry::from_tools(vec![
1538 Arc::new(McpTwin("twin_one")),
1539 Arc::new(McpTwin("twin_two")),
1540 ]);
1541 }
1542}