runifold_tool/
registry.rs1use std::{collections::BTreeMap, fmt, sync::Arc};
2
3use futures_util::future::{Either, select};
4use runifold_core::RunContext;
5use runifold_model::ToolSpec;
6use serde_json::Value;
7
8use crate::{
9 Tool, ToolContext, ToolDescriptor, ToolError, ToolErrorKind, ToolFuture, ToolOutput,
10 ToolRegistrationError,
11};
12
13#[derive(Clone, Default)]
15pub struct ToolRegistry {
16 tools: BTreeMap<String, Arc<dyn Tool>>,
17}
18
19impl ToolRegistry {
20 pub fn new() -> Self {
22 Self::default()
23 }
24
25 pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<(), ToolRegistrationError> {
31 let name = tool.descriptor().name.trim();
32 if name.is_empty() {
33 return Err(ToolRegistrationError::EmptyName);
34 }
35 if self.tools.contains_key(name) {
36 return Err(ToolRegistrationError::DuplicateName(name.into()));
37 }
38 self.tools.insert(name.into(), tool);
39 Ok(())
40 }
41
42 pub fn model_specs(&self) -> Vec<ToolSpec> {
44 self.tools
45 .values()
46 .map(|tool| tool.descriptor().model_spec())
47 .collect()
48 }
49
50 pub fn len(&self) -> usize {
52 self.tools.len()
53 }
54
55 pub fn is_empty(&self) -> bool {
57 self.tools.is_empty()
58 }
59
60 pub fn contains(&self, name: &str) -> bool {
62 self.tools.contains_key(name)
63 }
64
65 pub fn descriptor(&self, name: &str) -> Option<&ToolDescriptor> {
67 self.tools.get(name).map(|tool| tool.descriptor())
68 }
69
70 pub fn invoke<'a>(
73 &'a self,
74 name: &'a str,
75 input: Value,
76 run: &'a RunContext,
77 ) -> ToolFuture<'a, Result<ToolOutput, ToolError>> {
78 Box::pin(async move {
79 let tool = self.tools.get(name).ok_or_else(|| {
80 ToolError::local(
81 ToolErrorKind::NotFound,
82 format!("tool `{name}` is not registered"),
83 )
84 })?;
85 let descriptor = tool.descriptor();
86 if !run.capabilities().contains(descriptor.id) {
87 return Err(ToolError::local(
88 ToolErrorKind::CapabilityDenied,
89 format!("run is not granted tool capability `{name}`"),
90 ));
91 }
92 let context = ToolContext::for_run(run);
93 if context
94 .remaining()
95 .is_some_and(|remaining| remaining.is_zero())
96 {
97 return Err(ToolError::local(
98 ToolErrorKind::DeadlineExceeded,
99 "tool invocation deadline already elapsed",
100 ));
101 }
102 let cancellation = context.cancellation().clone();
103 match select(
104 Box::pin(cancellation.cancelled()),
105 Box::pin(tool.invoke(input, context)),
106 )
107 .await
108 {
109 Either::Left(_) => Err(ToolError::local(
110 ToolErrorKind::Cancelled,
111 "tool invocation was cancelled",
112 )),
113 Either::Right((result, _)) => result,
114 }
115 })
116 }
117}
118
119impl fmt::Debug for ToolRegistry {
120 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
121 formatter
122 .debug_struct("ToolRegistry")
123 .field("tools", &self.tools.keys().collect::<Vec<_>>())
124 .finish()
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use std::{collections::BTreeMap, sync::Arc};
131
132 use runifold_core::{
133 Budget, BudgetTracker, CapabilityId, CapabilitySet, EffectClass, RiskLevel, RunContext,
134 };
135 use serde_json::{Value, json};
136
137 use crate::{
138 Tool, ToolContext, ToolDescriptor, ToolError, ToolErrorKind, ToolFuture, ToolOutput,
139 ToolRegistrationError,
140 };
141
142 use super::ToolRegistry;
143
144 #[derive(Debug)]
145 struct EchoTool {
146 descriptor: ToolDescriptor,
147 }
148
149 impl EchoTool {
150 fn new(name: &str) -> Self {
151 Self {
152 descriptor: ToolDescriptor {
153 id: CapabilityId::new(),
154 name: name.into(),
155 version: "1".into(),
156 description: "Echo structured input".into(),
157 input_schema: json!({"type": "object"}),
158 output_schema: json!({"type": "object"}),
159 effect: EffectClass::Pure,
160 risk: RiskLevel::Low,
161 metadata: BTreeMap::new(),
162 },
163 }
164 }
165 }
166
167 impl Tool for EchoTool {
168 fn descriptor(&self) -> &ToolDescriptor {
169 &self.descriptor
170 }
171
172 fn invoke(
173 &self,
174 input: Value,
175 _context: ToolContext,
176 ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
177 Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
178 }
179 }
180
181 #[test]
182 fn registry_requires_explicit_capability_grants() {
183 let tool = Arc::new(EchoTool::new("echo"));
184 let mut registry = ToolRegistry::new();
185 registry.register(tool).unwrap();
186 let run = RunContext::root(BudgetTracker::new(Budget::default()), CapabilitySet::new());
187
188 let error =
189 futures_executor::block_on(registry.invoke("echo", json!({"x": 1}), &run)).unwrap_err();
190
191 assert_eq!(error.kind, ToolErrorKind::CapabilityDenied);
192 }
193
194 #[test]
195 fn granted_tools_execute_through_the_object_safe_boundary() {
196 let tool = Arc::new(EchoTool::new("echo"));
197 let mut capabilities = CapabilitySet::new();
198 capabilities.grant(tool.descriptor().capability());
199 let run = RunContext::root(BudgetTracker::new(Budget::default()), capabilities);
200 let mut registry = ToolRegistry::new();
201 registry.register(tool).unwrap();
202
203 let output =
204 futures_executor::block_on(registry.invoke("echo", json!({"x": 1}), &run)).unwrap();
205
206 assert_eq!(output.value, json!({"x": 1}));
207 }
208
209 #[test]
210 fn duplicate_names_are_rejected_instead_of_replaced() {
211 let mut registry = ToolRegistry::new();
212 registry.register(Arc::new(EchoTool::new("echo"))).unwrap();
213
214 let error = registry
215 .register(Arc::new(EchoTool::new("echo")))
216 .unwrap_err();
217
218 assert_eq!(error, ToolRegistrationError::DuplicateName("echo".into()));
219 }
220}