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