Skip to main content

runifold_tool/
registry.rs

1use 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/// Immutable-name registry and capability gate for tools.
14#[derive(Clone, Default)]
15pub struct ToolRegistry {
16    tools: BTreeMap<String, Arc<dyn Tool>>,
17}
18
19impl ToolRegistry {
20    /// Creates an empty registry.
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Registers a tool without replacing an existing name.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`ToolRegistrationError`] for blank or duplicate names.
30    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    /// Returns model-facing specifications in deterministic name order.
43    pub fn model_specs(&self) -> Vec<ToolSpec> {
44        self.tools
45            .values()
46            .map(|tool| tool.descriptor().model_spec())
47            .collect()
48    }
49
50    /// Returns the number of registered tools.
51    pub fn len(&self) -> usize {
52        self.tools.len()
53    }
54
55    /// Returns whether no tools are registered.
56    pub fn is_empty(&self) -> bool {
57        self.tools.is_empty()
58    }
59
60    /// Returns whether a tool is registered under `name`.
61    pub fn contains(&self, name: &str) -> bool {
62        self.tools.contains_key(name)
63    }
64
65    /// Returns the immutable descriptor registered under `name`.
66    pub fn descriptor(&self, name: &str) -> Option<&ToolDescriptor> {
67        self.tools.get(name).map(|tool| tool.descriptor())
68    }
69
70    /// Invokes a registered tool after checking the owning run's explicit
71    /// capability grant.
72    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}