Skip to main content

rskit_tool/
registry.rs

1//! Concurrent-safe tool registry.
2
3use parking_lot::RwLock;
4use rskit_ai::semconv;
5use rskit_component::{Component, Health};
6use rskit_errors::{AppError, AppResult, ErrorCode};
7use rskit_observability::set_span_attribute;
8use std::collections::HashMap;
9use std::sync::Arc;
10use tracing::Instrument;
11
12use crate::callable::Callable;
13use crate::context::Context;
14use crate::definition::{Definition, ExecutionHint};
15use crate::hitl::{Decision, HumanApproval, SensitivityEvaluator, ToolCall, denied_error};
16use crate::io::ToolInput;
17use crate::result::ToolResult;
18
19/// Options for passive batch tool execution.
20#[derive(Debug, Clone, Copy)]
21pub struct BatchOptions {
22    /// Maximum number of concurrent calls. Values below 1 are treated as 1.
23    pub concurrency: usize,
24    /// Stop scheduling calls after the first error.
25    pub fail_fast: bool,
26}
27
28impl Default for BatchOptions {
29    fn default() -> Self {
30        Self {
31            concurrency: 1,
32            fail_fast: true,
33        }
34    }
35}
36
37/// Thread-safe registry of callable tools.
38///
39/// Optionally wires the HITL stages (per locked decision D10):
40/// `sensitivity → (if RequireApproval) human approval → invoke`.
41/// The authorization stage is owned by the boundary (`rskit-mcp`, etc.) and is not enforced here;
42/// this preserves module layering.
43pub struct Registry {
44    tools: RwLock<HashMap<String, Arc<dyn Callable>>>,
45    sensitivity: Option<Arc<dyn SensitivityEvaluator>>,
46    approval: Option<Arc<dyn HumanApproval>>,
47}
48
49impl Registry {
50    /// Create a new empty registry.
51    #[must_use]
52    pub fn new() -> Self {
53        Self {
54            tools: RwLock::new(HashMap::new()),
55            sensitivity: None,
56            approval: None,
57        }
58    }
59
60    /// Inject a sensitivity evaluator. When unset, no sensitivity checks run.
61    #[must_use]
62    pub fn with_sensitivity_evaluator(mut self, evaluator: Arc<dyn SensitivityEvaluator>) -> Self {
63        self.sensitivity = Some(evaluator);
64        self
65    }
66
67    /// Inject a human-approval gate. When unset, `RequireApproval` decisions are treated as denials.
68    #[must_use]
69    pub fn with_human_approval(mut self, approval: Arc<dyn HumanApproval>) -> Self {
70        self.approval = Some(approval);
71        self
72    }
73
74    /// Register a tool. Returns error on empty name or duplicate.
75    pub fn register(&self, tool: Box<dyn Callable>) -> AppResult<()> {
76        let name = tool.definition().name.clone();
77        if name.trim().is_empty() {
78            return Err(AppError::new(
79                ErrorCode::InvalidInput,
80                "tool name must not be empty",
81            ));
82        }
83        let mut tools = self.tools.write();
84        if tools.contains_key(&name) {
85            return Err(AppError::new(
86                ErrorCode::AlreadyExists,
87                format!("tool already registered: {name:?}"),
88            ));
89        }
90        tools.insert(name, Arc::from(tool));
91        drop(tools);
92        Ok(())
93    }
94
95    /// Get a tool by name.
96    pub fn get(&self, name: &str) -> Option<Arc<dyn Callable>> {
97        self.tools.read().get(name).cloned()
98    }
99
100    /// List all registered tool definitions.
101    pub fn list(&self) -> Vec<Definition> {
102        self.tools
103            .read()
104            .values()
105            .map(|t| t.definition().clone())
106            .collect()
107    }
108
109    /// List all registered tool names.
110    pub fn names(&self) -> Vec<String> {
111        self.tools.read().keys().cloned().collect()
112    }
113
114    /// Call a tool by name with a context.
115    /// Runs the HITL stages (sensitivity → human approval) if configured before invoking the tool.
116    pub async fn call(&self, name: &str, ctx: &Context, input: ToolInput) -> AppResult<ToolResult> {
117        self.call_inner(name, ctx, input, true).await
118    }
119
120    /// Call a tool after the caller has already validated the input schema.
121    ///
122    /// This still runs HITL stages before invoking the tool,
123    /// but skips the schema validation pass performed by [`Registry::call`].
124    pub async fn call_validated(
125        &self,
126        name: &str,
127        ctx: &Context,
128        input: ToolInput,
129    ) -> AppResult<ToolResult> {
130        self.call_inner(name, ctx, input, false).await
131    }
132
133    async fn call_inner(
134        &self,
135        name: &str,
136        ctx: &Context,
137        input: ToolInput,
138        validate_input: bool,
139    ) -> AppResult<ToolResult> {
140        let span = tracing::info_span!(
141            "tool.call",
142            "gen_ai.operation.name" = semconv::Operation::ToolCall.as_str(),
143            "gen_ai.tool.name" = name,
144            "tool.use_id" = %ctx.tool_use_id,
145        );
146        set_span_attribute(
147            &span,
148            semconv::OPERATION_NAME,
149            semconv::Operation::ToolCall.as_str(),
150        );
151        set_span_attribute(&span, semconv::TOOL_NAME, name);
152        async {
153            let tool = self.get(name).ok_or_else(|| {
154                AppError::new(ErrorCode::NotFound, format!("tool not found: {name:?}"))
155            })?;
156            if validate_input {
157                validate_tool_input(tool.as_ref(), &input)?;
158            }
159            self.run_hitl(tool.as_ref(), ctx, &input).await?;
160            tool.call(ctx, input).await
161        }
162        .instrument(span)
163        .await
164    }
165
166    async fn run_hitl(
167        &self,
168        tool: &dyn Callable,
169        ctx: &Context,
170        input: &ToolInput,
171    ) -> AppResult<()> {
172        let evaluator = match &self.sensitivity {
173            Some(e) => e.clone(),
174            None => return Ok(()),
175        };
176        let definition = tool.definition();
177        let call = ToolCall {
178            name: definition.name.clone(),
179            input: input.clone(),
180        };
181        let decision = evaluator.evaluate(ctx, &call, &definition.envelope).await?;
182        match decision {
183            Decision::Allow => Ok(()),
184            Decision::Deny(reason) => Err(denied_error(reason)),
185            Decision::RequireApproval(reason) => match &self.approval {
186                Some(approver) => {
187                    if approver.approve(ctx, &call, &reason).await? {
188                        Ok(())
189                    } else {
190                        Err(denied_error(format!("human approval rejected: {reason}")))
191                    }
192                }
193                None => Err(denied_error(format!(
194                    "approval required but no approver configured: {reason}"
195                ))),
196            },
197        }
198    }
199
200    /// Search for tools whose name or description contains the query (case-insensitive).
201    pub fn search(&self, query: &str) -> Vec<Definition> {
202        let q = query.to_lowercase();
203        self.tools
204            .read()
205            .values()
206            .filter_map(|t| {
207                let def = t.definition();
208                if def.name.to_lowercase().contains(&q)
209                    || def.description.to_lowercase().contains(&q)
210                {
211                    Some(def.clone())
212                } else {
213                    None
214                }
215            })
216            .collect()
217    }
218
219    /// Filter tools by `execution_hint` annotation.
220    pub fn filter_by_execution_hint(&self, hint: ExecutionHint) -> Vec<Definition> {
221        self.tools
222            .read()
223            .values()
224            .filter_map(|t| {
225                let def = t.definition();
226                (def.annotations.execution_hint.effective() == hint.effective())
227                    .then(|| def.clone())
228            })
229            .collect()
230    }
231
232    /// Call multiple tools with caller-supplied concurrency policy.
233    /// Each call goes through the same HITL stages as [`Registry::call`].
234    pub async fn call_batch(
235        &self,
236        calls: Vec<(&str, ToolInput)>,
237        ctx: &Context,
238        options: BatchOptions,
239    ) -> Vec<AppResult<ToolResult>> {
240        let concurrency = options.concurrency.max(1);
241        let mut results = Vec::with_capacity(calls.len());
242
243        for chunk in calls.chunks(concurrency) {
244            let mut handles = Vec::with_capacity(chunk.len());
245            for (name, input) in chunk {
246                let name = (*name).to_string();
247                let input = input.clone();
248                let ctx = ctx.clone();
249                let tool = self.get(&name);
250                let sensitivity = self.sensitivity.clone();
251                let approval = self.approval.clone();
252                let span = tracing::info_span!(
253                    "tool.call",
254                    "gen_ai.operation.name" = semconv::Operation::ToolCall.as_str(),
255                    "gen_ai.tool.name" = name.as_str(),
256                    "tool.use_id" = %ctx.tool_use_id,
257                );
258                set_span_attribute(
259                    &span,
260                    semconv::OPERATION_NAME,
261                    semconv::Operation::ToolCall.as_str(),
262                );
263                set_span_attribute(&span, semconv::TOOL_NAME, name.as_str());
264                handles.push(tokio::spawn(
265                    async move {
266                        let Some(tool) = tool else {
267                            return Err(AppError::new(
268                                ErrorCode::NotFound,
269                                format!("tool not found: {name:?}"),
270                            ));
271                        };
272                        validate_tool_input(tool.as_ref(), &input)?;
273                        if let Some(evaluator) = sensitivity {
274                            let definition = tool.definition();
275                            let call = ToolCall {
276                                name: definition.name.clone(),
277                                input: input.clone(),
278                            };
279                            let decision = evaluator
280                                .evaluate(&ctx, &call, &definition.envelope)
281                                .await?;
282                            match decision {
283                                Decision::Allow => {}
284                                Decision::Deny(reason) => return Err(denied_error(reason)),
285                                Decision::RequireApproval(reason) => match approval {
286                                    Some(approver) => {
287                                        if !approver.approve(&ctx, &call, &reason).await? {
288                                            return Err(denied_error(format!(
289                                                "human approval rejected: {reason}"
290                                            )));
291                                        }
292                                    }
293                                    None => {
294                                        return Err(denied_error(format!(
295                                            "approval required but no approver configured: {reason}"
296                                        )));
297                                    }
298                                },
299                            }
300                        }
301                        tool.call(&ctx, input).await
302                    }
303                    .instrument(span),
304                ));
305            }
306
307            for handle in handles {
308                let result = match handle.await {
309                    Ok(result) => result,
310                    Err(error) => Err(AppError::new(
311                        ErrorCode::Internal,
312                        format!("task join error: {error}"),
313                    )),
314                };
315                let failed = result.is_err();
316                results.push(result);
317                if failed && options.fail_fast {
318                    return results;
319                }
320            }
321        }
322
323        results
324    }
325
326    /// Number of registered tools.
327    pub fn len(&self) -> usize {
328        self.tools.read().len()
329    }
330
331    /// Whether the registry is empty.
332    pub fn is_empty(&self) -> bool {
333        self.tools.read().is_empty()
334    }
335
336    /// Check if a tool is registered.
337    pub fn contains(&self, name: &str) -> bool {
338        self.tools.read().contains_key(name)
339    }
340}
341
342fn validate_tool_input(tool: &dyn Callable, input: &ToolInput) -> AppResult<()> {
343    let validation = tool.validate(input);
344    if validation.valid {
345        return Ok(());
346    }
347    let mut details = validation
348        .errors
349        .iter()
350        .map(ToString::to_string)
351        .collect::<Vec<_>>()
352        .join("; ");
353    if details.is_empty() {
354        details = String::from("schema validation failed");
355    }
356    Err(AppError::new(
357        ErrorCode::InvalidInput,
358        format!(
359            "invalid tool input for {}: {details}",
360            tool.definition().name
361        ),
362    ))
363}
364
365#[async_trait::async_trait]
366impl Component for Registry {
367    fn name(&self) -> &'static str {
368        "rskit-tool.registry"
369    }
370
371    async fn start(&self) -> AppResult<()> {
372        Ok(())
373    }
374
375    async fn stop(&self) -> AppResult<()> {
376        Ok(())
377    }
378
379    fn health(&self) -> Health {
380        Health::healthy(self.name())
381    }
382}
383
384impl Default for Registry {
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::definition::Definition;
394    use crate::envelope::{Envelope, SensitiveMatcher, SensitivePredicate};
395    use crate::hitl::{DenyHumanApproval, DenyOnSensitive};
396    use crate::result::ToolResult;
397    use serde_json::json;
398
399    struct StubTool {
400        def: Definition,
401        valid: bool,
402    }
403
404    #[async_trait::async_trait]
405    impl Callable for StubTool {
406        fn definition(&self) -> &Definition {
407            &self.def
408        }
409        fn validate(&self, _input: &ToolInput) -> rskit_schema::ValidationResult {
410            rskit_schema::ValidationResult {
411                valid: self.valid,
412                errors: Vec::new(),
413            }
414        }
415        async fn call(&self, _ctx: &Context, input: ToolInput) -> AppResult<ToolResult> {
416            Ok(ToolResult {
417                output: Some(crate::ToolOutput::from(input.into_json())),
418                content: "ok".to_owned(),
419                is_error: false,
420                metadata: crate::ToolMetadata::new(),
421            })
422        }
423    }
424
425    fn stub(name: &str, env: Envelope) -> Box<dyn Callable> {
426        Box::new(StubTool {
427            def: Definition {
428                name: name.to_owned(),
429                description: "stub".to_owned(),
430                input_schema: crate::ToolSchema::new(json!({"type": "object"})).unwrap(),
431                output_schema: None,
432                annotations: crate::Annotations::default(),
433                envelope: env,
434            },
435            valid: true,
436        })
437    }
438
439    fn invalid_stub(name: &str) -> Box<dyn Callable> {
440        Box::new(StubTool {
441            def: Definition {
442                name: name.to_owned(),
443                description: "stub".to_owned(),
444                input_schema: crate::ToolSchema::new(json!({"type": "object"})).unwrap(),
445                output_schema: None,
446                annotations: crate::Annotations::default(),
447                envelope: Envelope::default(),
448            },
449            valid: false,
450        })
451    }
452
453    #[tokio::test]
454    async fn register_rejects_empty_name() {
455        let registry = Registry::new();
456        let err = registry
457            .register(stub("", Envelope::default()))
458            .expect_err("empty name rejected");
459        assert_eq!(err.code(), ErrorCode::InvalidInput);
460    }
461
462    #[tokio::test]
463    async fn deny_on_sensitive_blocks_dispatch() {
464        let env = Envelope {
465            sensitive_invocations: vec![SensitivePredicate {
466                jsonpath: "$.msg".to_owned(),
467                matcher: SensitiveMatcher::Exists,
468            }],
469            ..Envelope::default()
470        };
471        let registry = Registry::new().with_sensitivity_evaluator(Arc::new(DenyOnSensitive));
472        registry.register(stub("danger", env)).unwrap();
473
474        let ctx = Context::new();
475        let err = registry
476            .call(
477                "danger",
478                &ctx,
479                ToolInput::new(json!({"msg": "hi"})).unwrap(),
480            )
481            .await
482            .expect_err("sensitive call denied");
483        assert_eq!(err.code(), ErrorCode::Forbidden);
484    }
485
486    #[tokio::test]
487    async fn allow_when_no_sensitive_predicate_matches() {
488        let registry = Registry::new().with_sensitivity_evaluator(Arc::new(DenyOnSensitive));
489        registry
490            .register(stub("safe", Envelope::default()))
491            .unwrap();
492
493        let ctx = Context::new();
494        let result = registry
495            .call("safe", &ctx, ToolInput::new(json!({"msg": "hi"})).unwrap())
496            .await
497            .unwrap();
498        assert!(!result.is_error);
499    }
500
501    #[tokio::test]
502    async fn require_approval_with_deny_human_rejects() {
503        struct AlwaysApprove;
504        #[async_trait::async_trait]
505        impl SensitivityEvaluator for AlwaysApprove {
506            async fn evaluate(
507                &self,
508                _ctx: &Context,
509                _call: &ToolCall,
510                _envelope: &Envelope,
511            ) -> AppResult<Decision> {
512                Ok(Decision::RequireApproval("policy".into()))
513            }
514        }
515        let registry = Registry::new()
516            .with_sensitivity_evaluator(Arc::new(AlwaysApprove))
517            .with_human_approval(Arc::new(DenyHumanApproval));
518        registry.register(stub("any", Envelope::default())).unwrap();
519
520        let ctx = Context::new();
521        let err = registry
522            .call("any", &ctx, ToolInput::new(json!({"msg": "x"})).unwrap())
523            .await
524            .expect_err("denied by human approval default");
525        assert_eq!(err.code(), ErrorCode::Forbidden);
526    }
527
528    #[tokio::test]
529    async fn invalid_input_without_details_uses_fallback_message() {
530        let registry = Registry::new();
531        registry
532            .register(invalid_stub("broken"))
533            .expect("register invalid stub");
534
535        let err = registry
536            .call("broken", &Context::new(), ToolInput::empty())
537            .await
538            .expect_err("invalid input rejected");
539
540        assert_eq!(
541            err.message(),
542            "invalid tool input for broken: schema validation failed"
543        );
544    }
545
546    #[tokio::test]
547    async fn call_validated_skips_schema_validation() {
548        let registry = Registry::new();
549        registry
550            .register(invalid_stub("prechecked"))
551            .expect("register invalid stub");
552
553        let result = registry
554            .call_validated("prechecked", &Context::new(), ToolInput::empty())
555            .await
556            .expect("prevalidated call skips schema validation");
557
558        assert!(!result.is_error);
559    }
560
561    #[tokio::test]
562    async fn call_batch_clamps_zero_concurrency_and_continues_when_not_fail_fast() {
563        let registry = Registry::new();
564        registry.register(stub("ok", Envelope::default())).unwrap();
565        registry.register(invalid_stub("invalid")).unwrap();
566
567        let results = registry
568            .call_batch(
569                vec![
570                    ("missing", ToolInput::empty()),
571                    ("ok", ToolInput::new(json!({"id": 1})).unwrap()),
572                    ("invalid", ToolInput::empty()),
573                ],
574                &Context::new(),
575                BatchOptions {
576                    concurrency: 0,
577                    fail_fast: false,
578                },
579            )
580            .await;
581
582        assert_eq!(results.len(), 3);
583        assert_eq!(results[0].as_ref().unwrap_err().code(), ErrorCode::NotFound);
584        assert_eq!(
585            results[1].as_ref().unwrap().output.as_ref().unwrap()["id"],
586            1
587        );
588        assert_eq!(
589            results[2].as_ref().unwrap_err().code(),
590            ErrorCode::InvalidInput
591        );
592    }
593
594    #[tokio::test]
595    async fn call_batch_stops_after_first_failure_when_fail_fast() {
596        let registry = Registry::new();
597        registry.register(stub("ok", Envelope::default())).unwrap();
598
599        let results = registry
600            .call_batch(
601                vec![
602                    ("missing", ToolInput::empty()),
603                    ("ok", ToolInput::new(json!({"id": 1})).unwrap()),
604                ],
605                &Context::new(),
606                BatchOptions {
607                    concurrency: 1,
608                    fail_fast: true,
609                },
610            )
611            .await;
612
613        assert_eq!(results.len(), 1);
614        assert_eq!(results[0].as_ref().unwrap_err().code(), ErrorCode::NotFound);
615    }
616
617    #[tokio::test]
618    async fn call_requires_approval_denies_without_approver_and_allows_with_approval() {
619        struct NeedsApproval;
620
621        #[async_trait::async_trait]
622        impl SensitivityEvaluator for NeedsApproval {
623            async fn evaluate(
624                &self,
625                _ctx: &Context,
626                _call: &ToolCall,
627                _envelope: &Envelope,
628            ) -> AppResult<Decision> {
629                Ok(Decision::RequireApproval("sensitive".to_owned()))
630            }
631        }
632
633        struct AllowApproval;
634
635        #[async_trait::async_trait]
636        impl HumanApproval for AllowApproval {
637            async fn approve(
638                &self,
639                _ctx: &Context,
640                _call: &ToolCall,
641                reason: &str,
642            ) -> AppResult<bool> {
643                Ok(reason == "sensitive")
644            }
645        }
646
647        let denied = Registry::new().with_sensitivity_evaluator(Arc::new(NeedsApproval));
648        denied.register(stub("tool", Envelope::default())).unwrap();
649        let err = denied
650            .call("tool", &Context::new(), ToolInput::empty())
651            .await
652            .unwrap_err();
653        assert_eq!(err.code(), ErrorCode::Forbidden);
654        assert!(
655            err.message()
656                .contains("approval required but no approver configured")
657        );
658
659        let allowed = Registry::new()
660            .with_sensitivity_evaluator(Arc::new(NeedsApproval))
661            .with_human_approval(Arc::new(AllowApproval));
662        allowed.register(stub("tool", Envelope::default())).unwrap();
663        let result = allowed
664            .call("tool", &Context::new(), ToolInput::empty())
665            .await
666            .unwrap();
667        assert!(!result.is_error);
668    }
669
670    #[tokio::test]
671    async fn batch_hitl_covers_deny_and_approval_paths() {
672        struct NeedsApproval;
673
674        #[async_trait::async_trait]
675        impl SensitivityEvaluator for NeedsApproval {
676            async fn evaluate(
677                &self,
678                _ctx: &Context,
679                _call: &ToolCall,
680                _envelope: &Envelope,
681            ) -> AppResult<Decision> {
682                Ok(Decision::RequireApproval("batch".to_owned()))
683            }
684        }
685
686        struct AllowApproval;
687
688        #[async_trait::async_trait]
689        impl HumanApproval for AllowApproval {
690            async fn approve(
691                &self,
692                _ctx: &Context,
693                _call: &ToolCall,
694                reason: &str,
695            ) -> AppResult<bool> {
696                Ok(reason == "batch")
697            }
698        }
699
700        let denied = Registry::new().with_sensitivity_evaluator(Arc::new(DenyOnSensitive));
701        denied
702            .register(stub(
703                "danger",
704                Envelope {
705                    sensitive_invocations: vec![SensitivePredicate {
706                        jsonpath: "$.secret".to_owned(),
707                        matcher: SensitiveMatcher::Exists,
708                    }],
709                    ..Envelope::default()
710                },
711            ))
712            .unwrap();
713        let denied_results = denied
714            .call_batch(
715                vec![("danger", ToolInput::new(json!({"secret": true})).unwrap())],
716                &Context::new(),
717                BatchOptions::default(),
718            )
719            .await;
720        assert_eq!(
721            denied_results[0].as_ref().unwrap_err().code(),
722            ErrorCode::Forbidden
723        );
724
725        let missing_approval = Registry::new().with_sensitivity_evaluator(Arc::new(NeedsApproval));
726        missing_approval
727            .register(stub("tool", Envelope::default()))
728            .unwrap();
729        let missing_results = missing_approval
730            .call_batch(
731                vec![("tool", ToolInput::empty())],
732                &Context::new(),
733                BatchOptions::default(),
734            )
735            .await;
736        assert_eq!(
737            missing_results[0].as_ref().unwrap_err().code(),
738            ErrorCode::Forbidden
739        );
740
741        let allowed = Registry::new()
742            .with_sensitivity_evaluator(Arc::new(NeedsApproval))
743            .with_human_approval(Arc::new(AllowApproval));
744        allowed.register(stub("tool", Envelope::default())).unwrap();
745        let allowed_results = allowed
746            .call_batch(
747                vec![("tool", ToolInput::empty())],
748                &Context::new(),
749                BatchOptions::default(),
750            )
751            .await;
752        assert!(!allowed_results[0].as_ref().unwrap().is_error);
753    }
754
755    #[tokio::test]
756    async fn default_registry_component_lifecycle_is_healthy() {
757        let registry = Registry::default();
758
759        assert_eq!(registry.name(), "rskit-tool.registry");
760        registry.start().await.expect("start succeeds");
761        assert_eq!(registry.health().name, "rskit-tool.registry");
762        registry.stop().await.expect("stop succeeds");
763    }
764}