1use 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#[derive(Debug, Clone, Copy)]
21pub struct BatchOptions {
22 pub concurrency: usize,
24 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
37pub 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 #[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 #[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 #[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 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 pub fn get(&self, name: &str) -> Option<Arc<dyn Callable>> {
97 self.tools.read().get(name).cloned()
98 }
99
100 pub fn list(&self) -> Vec<Definition> {
102 self.tools
103 .read()
104 .values()
105 .map(|t| t.definition().clone())
106 .collect()
107 }
108
109 pub fn names(&self) -> Vec<String> {
111 self.tools.read().keys().cloned().collect()
112 }
113
114 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 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 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 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 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 pub fn len(&self) -> usize {
328 self.tools.read().len()
329 }
330
331 pub fn is_empty(&self) -> bool {
333 self.tools.read().is_empty()
334 }
335
336 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}