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]
70 pub fn with_human_approval(mut self, approval: Arc<dyn HumanApproval>) -> Self {
71 self.approval = Some(approval);
72 self
73 }
74
75 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 pub fn get(&self, name: &str) -> Option<Arc<dyn Callable>> {
98 self.tools.read().get(name).cloned()
99 }
100
101 pub fn list(&self) -> Vec<Definition> {
103 self.tools
104 .read()
105 .values()
106 .map(|t| t.definition().clone())
107 .collect()
108 }
109
110 pub fn names(&self) -> Vec<String> {
112 self.tools.read().keys().cloned().collect()
113 }
114
115 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 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 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 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 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 pub fn len(&self) -> usize {
329 self.tools.read().len()
330 }
331
332 pub fn is_empty(&self) -> bool {
334 self.tools.read().is_empty()
335 }
336
337 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}