1use std::{collections::BTreeMap, fmt, sync::Arc};
2
3use futures_util::future::{Either, select};
4use jsonschema::Validator;
5use runifold_core::RunContext;
6use runifold_model::{ArtifactScope, ArtifactStore, ToolSpec};
7use serde_json::Value;
8
9use crate::{
10 Tool, ToolContext, ToolDescriptor, ToolError, ToolErrorKind, ToolFuture, ToolOutput,
11 ToolRegistrationError,
12};
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub struct ToolLimits {
17 pub max_input_bytes: usize,
19 pub max_output_bytes: usize,
21}
22
23impl Default for ToolLimits {
24 fn default() -> Self {
25 Self {
26 max_input_bytes: 1024 * 1024,
27 max_output_bytes: 8 * 1024 * 1024,
28 }
29 }
30}
31
32#[derive(Clone)]
33struct RegisteredTool {
34 tool: Arc<dyn Tool>,
35 input_validator: Validator,
36 output_validator: Validator,
37}
38
39#[derive(Clone, Default)]
41pub struct ToolRegistry {
42 tools: BTreeMap<String, RegisteredTool>,
43 limits: ToolLimits,
44 artifact_store: Option<Arc<dyn ArtifactStore>>,
45 artifact_scope: Option<ArtifactScope>,
46}
47
48impl ToolRegistry {
49 pub fn new() -> Self {
51 Self::default()
52 }
53
54 #[must_use]
56 pub const fn with_limits(mut self, limits: ToolLimits) -> Self {
57 self.limits = limits;
58 self
59 }
60
61 #[must_use]
63 pub fn with_artifact_store(
64 mut self,
65 scope: ArtifactScope,
66 store: Arc<dyn ArtifactStore>,
67 ) -> Self {
68 self.artifact_scope = Some(scope);
69 self.artifact_store = Some(store);
70 self
71 }
72
73 pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<(), ToolRegistrationError> {
79 let name = tool.descriptor().name.trim();
80 if name.is_empty() {
81 return Err(ToolRegistrationError::EmptyName);
82 }
83 if self.tools.contains_key(name) {
84 return Err(ToolRegistrationError::DuplicateName(name.into()));
85 }
86 let input_validator = compile_schema(name, "input", &tool.descriptor().input_schema)?;
87 let output_validator = compile_schema(name, "output", &tool.descriptor().output_schema)?;
88 self.tools.insert(
89 name.into(),
90 RegisteredTool {
91 tool,
92 input_validator,
93 output_validator,
94 },
95 );
96 Ok(())
97 }
98
99 pub fn model_specs(&self) -> Vec<ToolSpec> {
101 self.tools
102 .values()
103 .map(|registered| registered.tool.descriptor().model_spec())
104 .collect()
105 }
106
107 pub fn len(&self) -> usize {
109 self.tools.len()
110 }
111
112 pub fn is_empty(&self) -> bool {
114 self.tools.is_empty()
115 }
116
117 pub fn contains(&self, name: &str) -> bool {
119 self.tools.contains_key(name)
120 }
121
122 pub fn descriptor(&self, name: &str) -> Option<&ToolDescriptor> {
124 self.tools
125 .get(name)
126 .map(|registered| registered.tool.descriptor())
127 }
128
129 pub fn invoke<'a>(
132 &'a self,
133 name: &'a str,
134 input: Value,
135 run: &'a RunContext,
136 ) -> ToolFuture<'a, Result<ToolOutput, ToolError>> {
137 Box::pin(async move {
138 let registered = self.tools.get(name).ok_or_else(|| {
139 ToolError::local(
140 ToolErrorKind::NotFound,
141 format!("tool `{name}` is not registered"),
142 )
143 })?;
144 let descriptor = registered.tool.descriptor();
145 if !run.capabilities().contains(descriptor.id) {
146 return Err(ToolError::local(
147 ToolErrorKind::CapabilityDenied,
148 format!("run is not granted tool capability `{name}`"),
149 ));
150 }
151 let context = ToolContext::for_run(run)
152 .with_artifact_store(self.artifact_scope.clone(), self.artifact_store.clone());
153 validate_size(
154 "Tool input",
155 &input,
156 self.limits.max_input_bytes,
157 ToolErrorKind::InvalidInput,
158 )?;
159 registered
160 .input_validator
161 .validate(&input)
162 .map_err(|error| {
163 ToolError::local(
164 ToolErrorKind::InvalidInput,
165 format!(
166 "Tool input violates its declared schema at `{}`",
167 error.schema_path()
168 ),
169 )
170 })?;
171 if context
172 .remaining()
173 .is_some_and(|remaining| remaining.is_zero())
174 {
175 return Err(ToolError::local(
176 ToolErrorKind::DeadlineExceeded,
177 "tool invocation deadline already elapsed",
178 ));
179 }
180 let cancellation = context.cancellation().clone();
181 match select(
182 Box::pin(cancellation.cancelled()),
183 Box::pin(registered.tool.invoke(input, context)),
184 )
185 .await
186 {
187 Either::Left(_) => Err(ToolError::local(
188 ToolErrorKind::Cancelled,
189 "tool invocation was cancelled",
190 )),
191 Either::Right((result, _)) => {
192 let output = result?;
193 validate_output(&output, ®istered.output_validator, self.limits)?;
194 Ok(output)
195 }
196 }
197 })
198 }
199}
200
201fn compile_schema(
202 tool: &str,
203 direction: &'static str,
204 schema: &Value,
205) -> Result<Validator, ToolRegistrationError> {
206 jsonschema::validator_for(schema).map_err(|error| ToolRegistrationError::InvalidSchema {
207 tool: tool.into(),
208 direction,
209 message: error.to_string(),
210 })
211}
212
213fn validate_output(
214 output: &ToolOutput,
215 validator: &Validator,
216 limits: ToolLimits,
217) -> Result<(), ToolError> {
218 if output.content.is_empty() {
219 return Err(ToolError::local(
220 ToolErrorKind::InvalidOutput,
221 "Tool output content cannot be empty",
222 ));
223 }
224 validate_size(
225 "Tool output",
226 output,
227 limits.max_output_bytes,
228 ToolErrorKind::InvalidOutput,
229 )?;
230 if output.is_error {
231 return Ok(());
232 }
233 let instance = output
234 .structured_content
235 .clone()
236 .unwrap_or_else(|| serde_json::to_value(&output.content).unwrap_or(Value::Null));
237 validator.validate(&instance).map_err(|error| {
238 ToolError::local(
239 ToolErrorKind::InvalidOutput,
240 format!(
241 "Tool output violates its declared schema at `{}`",
242 error.schema_path()
243 ),
244 )
245 })
246}
247
248fn validate_size<T: serde::Serialize>(
249 label: &str,
250 value: &T,
251 limit: usize,
252 kind: ToolErrorKind,
253) -> Result<(), ToolError> {
254 let size = serde_json::to_vec(value)
255 .map_err(|error| {
256 ToolError::local(kind.clone(), format!("{label} cannot be encoded: {error}"))
257 })?
258 .len();
259 if size > limit {
260 return Err(ToolError::local(
261 kind,
262 format!("{label} is {size} bytes and exceeds the {limit}-byte limit"),
263 ));
264 }
265 Ok(())
266}
267
268impl fmt::Debug for ToolRegistry {
269 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
270 formatter
271 .debug_struct("ToolRegistry")
272 .field("tools", &self.tools.keys().collect::<Vec<_>>())
273 .field("limits", &self.limits)
274 .field("artifact_store", &self.artifact_store.is_some())
275 .field("artifact_scope", &self.artifact_scope)
276 .finish()
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use std::{collections::BTreeMap, sync::Arc};
283
284 use runifold_core::{
285 Budget, BudgetTracker, CapabilityId, CapabilitySet, EffectClass, RiskLevel, RunContext,
286 };
287 use serde_json::{Value, json};
288
289 use crate::{
290 Tool, ToolContext, ToolDescriptor, ToolError, ToolErrorKind, ToolFuture, ToolLimits,
291 ToolOutput, ToolRegistrationError,
292 };
293
294 use super::ToolRegistry;
295
296 #[derive(Debug)]
297 struct EchoTool {
298 descriptor: ToolDescriptor,
299 }
300
301 impl EchoTool {
302 fn new(name: &str) -> Self {
303 Self {
304 descriptor: ToolDescriptor {
305 id: CapabilityId::new(),
306 name: name.into(),
307 version: "1".into(),
308 description: "Echo structured input".into(),
309 input_schema: json!({"type": "object"}),
310 output_schema: json!({"type": "object"}),
311 effect: EffectClass::Pure,
312 risk: RiskLevel::Low,
313 metadata: BTreeMap::new(),
314 },
315 }
316 }
317 }
318
319 impl Tool for EchoTool {
320 fn descriptor(&self) -> &ToolDescriptor {
321 &self.descriptor
322 }
323
324 fn invoke(
325 &self,
326 input: Value,
327 _context: ToolContext,
328 ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
329 Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
330 }
331 }
332
333 #[test]
334 fn registry_requires_explicit_capability_grants() {
335 let tool = Arc::new(EchoTool::new("echo"));
336 let mut registry = ToolRegistry::new();
337 registry.register(tool).unwrap();
338 let run = RunContext::root(BudgetTracker::new(Budget::default()), CapabilitySet::new());
339
340 let error =
341 futures_executor::block_on(registry.invoke("echo", json!({"x": 1}), &run)).unwrap_err();
342
343 assert_eq!(error.kind, ToolErrorKind::CapabilityDenied);
344 }
345
346 #[test]
347 fn granted_tools_execute_through_the_object_safe_boundary() {
348 let tool = Arc::new(EchoTool::new("echo"));
349 let mut capabilities = CapabilitySet::new();
350 capabilities.grant(tool.descriptor().capability());
351 let run = RunContext::root(BudgetTracker::new(Budget::default()), capabilities);
352 let mut registry = ToolRegistry::new();
353 registry.register(tool).unwrap();
354
355 let output =
356 futures_executor::block_on(registry.invoke("echo", json!({"x": 1}), &run)).unwrap();
357
358 assert_eq!(output.structured_content, Some(json!({"x": 1})));
359 }
360
361 #[test]
362 fn duplicate_names_are_rejected_instead_of_replaced() {
363 let mut registry = ToolRegistry::new();
364 registry.register(Arc::new(EchoTool::new("echo"))).unwrap();
365
366 let error = registry
367 .register(Arc::new(EchoTool::new("echo")))
368 .unwrap_err();
369
370 assert_eq!(error, ToolRegistrationError::DuplicateName("echo".into()));
371 }
372
373 #[test]
374 fn invalid_schemas_fail_during_registration() {
375 let mut tool = EchoTool::new("invalid");
376 tool.descriptor.input_schema = json!({"type":"not-a-json-schema-type"});
377 let error = ToolRegistry::new().register(Arc::new(tool)).unwrap_err();
378 assert!(matches!(
379 error,
380 ToolRegistrationError::InvalidSchema {
381 direction: "input",
382 ..
383 }
384 ));
385 }
386
387 #[test]
388 fn output_contract_and_size_are_enforced_after_execution() {
389 let tool = Arc::new(EchoTool::new("bounded"));
390 let mut capabilities = CapabilitySet::new();
391 capabilities.grant(tool.descriptor().capability());
392 let run = RunContext::root(BudgetTracker::new(Budget::default()), capabilities);
393 let mut registry = ToolRegistry::new().with_limits(ToolLimits {
394 max_input_bytes: 1024,
395 max_output_bytes: 32,
396 });
397 registry.register(tool).unwrap();
398
399 let error = futures_executor::block_on(registry.invoke(
400 "bounded",
401 json!({"payload":"this output is intentionally larger than the limit"}),
402 &run,
403 ))
404 .unwrap_err();
405 assert_eq!(error.kind, ToolErrorKind::InvalidOutput);
406 }
407}