1use std::sync::Arc;
9use std::time::Instant;
10
11use behest_tool::Tool;
12use futures_util::future::join_all;
13use serde_json::Value;
14use tokio::sync::Semaphore;
15use tokio::time::timeout;
16use tracing::{debug, warn};
17use uuid::Uuid;
18
19use super::error::{RuntimeError, RuntimeResult};
20use super::policy::RuntimePolicy;
21use super::tool_output::{self, ToolOutputConfig};
22use super::tool_scope::ScopedToolRegistry;
23use behest_core::error::ToolError;
24use behest_provider::{Message, ToolCall};
25use behest_store::{ExecutionStore, ToolExecution};
26use behest_tool::{ToolOutput, ToolRegistry};
27
28#[derive(Debug)]
33pub struct ToolExecutionOutcome {
34 pub call: ToolCall,
36 pub output: Result<ToolOutput, ToolError>,
38 pub message: Message,
40}
41
42pub struct ToolRuntime {
44 registry: Arc<ScopedToolRegistry>,
45 policy: RuntimePolicy,
46 semaphore: Arc<Semaphore>,
47}
48
49impl ToolRuntime {
50 #[must_use]
52 pub fn new(registry: ToolRegistry, policy: RuntimePolicy) -> Self {
53 let mut policy = policy;
54 policy.max_tool_concurrency = policy.max_tool_concurrency.max(1);
55 let semaphore = Arc::new(Semaphore::new(policy.max_tool_concurrency));
56 Self {
57 registry: Arc::new(ScopedToolRegistry::new(registry)),
58 policy,
59 semaphore,
60 }
61 }
62
63 #[must_use]
65 pub fn registry(&self) -> &Arc<ScopedToolRegistry> {
66 &self.registry
67 }
68
69 pub fn registry_mut(&mut self) -> &mut Arc<ScopedToolRegistry> {
71 &mut self.registry
72 }
73
74 #[must_use]
76 pub fn policy(&self) -> &RuntimePolicy {
77 &self.policy
78 }
79
80 pub fn register_tool(&self, tool: Arc<dyn Tool>) -> Option<Arc<dyn Tool>> {
84 self.registry.base().register_arc(tool)
85 }
86
87 #[must_use]
92 pub fn unregister_tool(&self, name: &str) -> Option<Arc<dyn Tool>> {
93 self.registry.unregister_from_base(name)
94 }
95
96 pub async fn execute_batch(
116 &self,
117 calls: Vec<ToolCall>,
118 session_id: Uuid,
119 message_id: Uuid,
120 execution_store: Option<&dyn ExecutionStore>,
121 ) -> RuntimeResult<Vec<ToolExecutionOutcome>> {
122 if calls.is_empty() {
123 return Ok(Vec::new());
124 }
125
126 let call_count = calls.len();
127 let indexed_calls: Vec<(usize, ToolCall)> = calls.into_iter().enumerate().collect();
128
129 let (concurrent_group, exclusive_group): (Vec<_>, Vec<_>) =
130 indexed_calls.into_iter().partition(|(_, call)| {
131 self.registry
132 .get(&call.name)
133 .is_some_and(|tool| tool.is_concurrency_safe())
134 });
135
136 let mut results: Vec<Option<ToolExecutionOutcome>> =
137 (0..call_count).map(|_| None).collect();
138
139 if !concurrent_group.is_empty() {
140 let concurrent_results = {
141 let futures: Vec<_> = concurrent_group
142 .into_iter()
143 .map(|(idx, call)| {
144 let sem = Arc::clone(&self.semaphore);
145 let registry = self.registry.clone();
146 let tool_timeout = self.policy.tool_timeout;
147 let continue_on_failure = self.policy.continue_on_tool_failure;
148 let truncation_config = self.policy.tool_output.clone();
149
150 async move {
151 let _permit =
152 sem.acquire().await.map_err(|_| RuntimeError::ToolTimeout {
153 tool: call.name.clone(),
154 })?;
155
156 let outcome = Self::execute_single(
157 ®istry,
158 &call,
159 tool_timeout,
160 continue_on_failure,
161 &truncation_config,
162 )
163 .await?;
164 Ok::<(usize, ToolExecutionOutcome), RuntimeError>((idx, outcome))
165 }
166 })
167 .collect();
168
169 join_all(futures).await
170 };
171
172 for res in concurrent_results {
173 let (idx, outcome) = res?;
174 if let Some(store) = execution_store {
175 Self::record_execution(store, session_id, message_id, &outcome.call, &outcome)
176 .await;
177 }
178 results[idx] = Some(outcome);
179 }
180 }
181
182 for (idx, call) in exclusive_group {
183 let _permit =
184 self.semaphore
185 .acquire()
186 .await
187 .map_err(|_| RuntimeError::ToolTimeout {
188 tool: call.name.clone(),
189 })?;
190
191 let outcome = Self::execute_single(
192 &self.registry,
193 &call,
194 self.policy.tool_timeout,
195 self.policy.continue_on_tool_failure,
196 &self.policy.tool_output,
197 )
198 .await?;
199
200 if let Some(store) = execution_store {
201 Self::record_execution(store, session_id, message_id, &call, &outcome).await;
202 }
203
204 results[idx] = Some(outcome);
205 }
206
207 Ok(results
208 .into_iter()
209 .map(|r| r.unwrap_or_else(|| unreachable!("all indices populated")))
210 .collect())
211 }
212
213 async fn execute_single(
214 registry: &ScopedToolRegistry,
215 call: &ToolCall,
216 tool_timeout: std::time::Duration,
217 continue_on_failure: bool,
218 truncation_config: &ToolOutputConfig,
219 ) -> RuntimeResult<ToolExecutionOutcome> {
220 let Some(tool) = registry.get(&call.name) else {
221 let error_msg = format!("tool not found: {}", call.name);
222 warn!(tool = %call.name, "tool not found");
223 let error = ToolError::NotFound {
224 name: call.name.clone(),
225 };
226 if !continue_on_failure {
227 return Err(error.into());
228 }
229
230 return Ok(ToolExecutionOutcome {
231 call: call.clone(),
232 output: Err(error),
233 message: Message::tool_text(
234 call.id.clone(),
235 call.name.clone(),
236 format!("{{\"error\":\"{error_msg}\"}}"),
237 ),
238 });
239 };
240
241 if let Err(validation_error) =
242 Self::validate_arguments(&tool.parameters_schema(), &call.arguments)
243 {
244 debug!(tool = %call.name, error = %validation_error, "schema validation failed");
245 let err = ToolError::InvalidArguments {
246 name: call.name.clone(),
247 message: validation_error.clone(),
248 };
249 if !continue_on_failure {
250 return Err(err.into());
251 }
252 return Ok(ToolExecutionOutcome {
253 call: call.clone(),
254 output: Err(err),
255 message: Message::tool_text(
256 call.id.clone(),
257 call.name.clone(),
258 format!("{{\"error\":\"{validation_error}\"}}"),
259 ),
260 });
261 }
262
263 let start = Instant::now();
264 match timeout(tool_timeout, tool.execute(call.arguments.clone())).await {
265 Ok(Ok(output)) => {
266 let duration = start.elapsed();
267 debug!(tool = %call.name, ?duration, "tool executed successfully");
268 let raw_text = output.value.to_string();
269 let truncated =
270 tool_output::truncate_output(&raw_text, truncation_config, Some(&call.name));
271 let msg = Message::tool_text(call.id.clone(), call.name.clone(), truncated.text);
272 Ok(ToolExecutionOutcome {
273 call: call.clone(),
274 output: Ok(output),
275 message: msg,
276 })
277 }
278 Ok(Err(tool_error)) => {
279 let duration = start.elapsed();
280 let error_msg = tool_error.to_string();
281 warn!(tool = %call.name, ?duration, error = %error_msg, "tool execution failed");
282 if !continue_on_failure {
283 return Err(tool_error.into());
284 }
285
286 Ok(ToolExecutionOutcome {
287 call: call.clone(),
288 output: Err(tool_error),
289 message: Message::tool_text(
290 call.id.clone(),
291 call.name.clone(),
292 format!("{{\"error\":\"{error_msg}\"}}"),
293 ),
294 })
295 }
296 Err(_) => {
297 let error_msg = format!("tool execution timeout after {tool_timeout:?}");
298 warn!(tool = %call.name, "tool execution timed out");
299 if !continue_on_failure {
300 return Err(RuntimeError::ToolTimeout {
301 tool: call.name.clone(),
302 });
303 }
304
305 Ok(ToolExecutionOutcome {
306 call: call.clone(),
307 output: Err(ToolError::Execution {
308 name: call.name.clone(),
309 message: error_msg.clone(),
310 }),
311 message: Message::tool_text(
312 call.id.clone(),
313 call.name.clone(),
314 format!("{{\"error\":\"{error_msg}\"}}"),
315 ),
316 })
317 }
318 }
319 }
320
321 fn validate_arguments(schema: &Value, arguments: &Value) -> Result<(), String> {
322 if schema.is_null() || schema.as_object().is_none() {
323 return Ok(());
324 }
325
326 let evaluation = jsonschema::evaluate(schema, arguments);
327 let flag = evaluation.flag();
328
329 if flag.valid {
330 Ok(())
331 } else {
332 let errors: Vec<String> = evaluation
333 .iter_errors()
334 .map(|e| e.error.to_string())
335 .collect();
336 Err(format!("validation errors: {}", errors.join("; ")))
337 }
338 }
339
340 async fn record_execution(
341 store: &dyn ExecutionStore,
342 session_id: Uuid,
343 message_id: Uuid,
344 call: &ToolCall,
345 outcome: &ToolExecutionOutcome,
346 ) {
347 let mut execution = ToolExecution::new(
348 session_id,
349 message_id,
350 &call.id,
351 &call.name,
352 call.arguments.clone(),
353 );
354
355 match &outcome.output {
356 Ok(output) => {
357 execution = execution.with_success(output.value.clone(), std::time::Duration::ZERO);
358 }
359 Err(error) => {
360 execution = execution.with_failure(error.to_string(), std::time::Duration::ZERO);
361 }
362 }
363
364 if let Err(e) = store.record_execution(execution).await {
365 warn!(error = %e, "failed to record tool execution");
366 }
367 }
368}
369
370#[cfg(test)]
371#[allow(clippy::unwrap_used, clippy::type_complexity, clippy::expect_used)]
372mod tests {
373 use super::*;
374 use behest_store::memory::MemoryExecutionStore;
375 use behest_tool::FunctionTool;
376 use serde_json::json;
377
378 fn echo_tool() -> FunctionTool {
379 FunctionTool::new(
380 "echo",
381 "Echoes input",
382 json!({
383 "type": "object",
384 "properties": {
385 "message": { "type": "string" }
386 },
387 "required": ["message"]
388 }),
389 |args: Value| -> std::pin::Pin<
390 Box<dyn std::future::Future<Output = behest_tool::ToolResult<Value>> + Send>,
391 > {
392 Box::pin(async move { Ok(args.get("message").cloned().unwrap_or(Value::Null)) })
393 },
394 )
395 }
396
397 fn failing_tool() -> FunctionTool {
398 FunctionTool::new(
399 "fail",
400 "Always fails",
401 json!({"type": "object"}),
402 |_args: Value| -> std::pin::Pin<
403 Box<dyn std::future::Future<Output = behest_tool::ToolResult<Value>> + Send>,
404 > {
405 Box::pin(async move {
406 Err(ToolError::Execution {
407 name: "fail".to_owned(),
408 message: "intentional failure".to_owned(),
409 })
410 })
411 },
412 )
413 }
414
415 #[tokio::test]
416 async fn execute_batch_should_run_tools_in_parallel() {
417 let registry = ToolRegistry::new();
418 registry.register(echo_tool());
419 let policy = RuntimePolicy::new().with_max_tool_concurrency(2);
420 let runtime = ToolRuntime::new(registry, policy);
421
422 let calls = vec![
423 ToolCall::new("call_1", "echo", json!({"message": "hello"})),
424 ToolCall::new("call_2", "echo", json!({"message": "world"})),
425 ];
426
427 let session_id = Uuid::now_v7();
428 let message_id = Uuid::now_v7();
429 let outcomes = runtime
430 .execute_batch(calls, session_id, message_id, None)
431 .await
432 .unwrap();
433
434 assert_eq!(outcomes.len(), 2);
435 assert!(outcomes[0].output.is_ok());
436 assert!(outcomes[1].output.is_ok());
437 }
438
439 #[tokio::test]
440 async fn execute_batch_should_handle_unknown_tool() {
441 let registry = ToolRegistry::new();
442 let policy = RuntimePolicy::new();
443 let runtime = ToolRuntime::new(registry, policy);
444
445 let calls = vec![ToolCall::new("call_1", "unknown", json!({}))];
446 let session_id = Uuid::now_v7();
447 let message_id = Uuid::now_v7();
448 let outcomes = runtime
449 .execute_batch(calls, session_id, message_id, None)
450 .await
451 .unwrap();
452
453 assert_eq!(outcomes.len(), 1);
454 assert!(outcomes[0].output.is_err());
455 }
456
457 #[tokio::test]
458 async fn execute_batch_should_validate_schema() {
459 let registry = ToolRegistry::new();
460 registry.register(echo_tool());
461 let policy = RuntimePolicy::new();
462 let runtime = ToolRuntime::new(registry, policy);
463
464 let calls = vec![ToolCall::new("call_1", "echo", json!({"message": 123}))];
465 let session_id = Uuid::now_v7();
466 let message_id = Uuid::now_v7();
467 let outcomes = runtime
468 .execute_batch(calls, session_id, message_id, None)
469 .await
470 .unwrap();
471
472 assert_eq!(outcomes.len(), 1);
473 assert!(outcomes[0].output.is_err());
474 let err = outcomes[0].output.as_ref().unwrap_err();
475 assert!(err.to_string().contains("validation"));
476 }
477
478 #[tokio::test]
479 async fn execute_batch_should_record_to_execution_store() {
480 let registry = ToolRegistry::new();
481 registry.register(echo_tool());
482 let policy = RuntimePolicy::new();
483 let runtime = ToolRuntime::new(registry, policy);
484
485 let store = MemoryExecutionStore::new();
486 let calls = vec![ToolCall::new("call_1", "echo", json!({"message": "test"}))];
487 let session_id = Uuid::now_v7();
488 let message_id = Uuid::now_v7();
489
490 runtime
491 .execute_batch(calls, session_id, message_id, Some(&store))
492 .await
493 .unwrap();
494
495 let executions = store.list_executions(&session_id).await.unwrap();
496 assert_eq!(executions.len(), 1);
497 assert_eq!(executions[0].tool_name, "echo");
498 }
499
500 #[tokio::test]
501 async fn execute_batch_should_handle_tool_failure() {
502 let registry = ToolRegistry::new();
503 registry.register(failing_tool());
504 let policy = RuntimePolicy::new().with_continue_on_tool_failure(true);
505 let runtime = ToolRuntime::new(registry, policy);
506
507 let calls = vec![ToolCall::new("call_1", "fail", json!({}))];
508 let session_id = Uuid::now_v7();
509 let message_id = Uuid::now_v7();
510 let outcomes = runtime
511 .execute_batch(calls, session_id, message_id, None)
512 .await
513 .unwrap();
514
515 assert_eq!(outcomes.len(), 1);
516 assert!(outcomes[0].output.is_err());
517 }
518
519 #[tokio::test]
520 async fn execute_batch_empty_returns_empty() {
521 let registry = ToolRegistry::new();
522 let policy = RuntimePolicy::new();
523 let runtime = ToolRuntime::new(registry, policy);
524
525 let session_id = Uuid::now_v7();
526 let message_id = Uuid::now_v7();
527 let outcomes = runtime
528 .execute_batch(Vec::new(), session_id, message_id, None)
529 .await
530 .unwrap();
531
532 assert!(outcomes.is_empty());
533 }
534
535 #[tokio::test]
536 async fn execute_batch_partitions_concurrent_and_exclusive() {
537 use std::sync::Arc;
538 use std::sync::atomic::{AtomicBool, Ordering};
539
540 let concurrent_flag = Arc::new(AtomicBool::new(false));
541 let exclusive_flag = Arc::new(AtomicBool::new(false));
542
543 let con_flag = Arc::clone(&concurrent_flag);
544 let concurrent_tool = FunctionTool::new(
545 "concurrent",
546 "Safe for concurrency",
547 json!({"type": "object"}),
548 move |_args: Value| -> std::pin::Pin<
549 Box<dyn std::future::Future<Output = behest_tool::ToolResult<Value>> + Send>,
550 > {
551 let flag = Arc::clone(&con_flag);
552 Box::pin(async move {
553 flag.store(true, Ordering::SeqCst);
554 Ok(Value::Null)
555 })
556 },
557 )
558 .concurrency_safe();
559
560 let exc_flag = Arc::clone(&exclusive_flag);
561 let exclusive_tool = FunctionTool::new(
562 "exclusive",
563 "Not safe for concurrency",
564 json!({"type": "object"}),
565 move |_args: Value| -> std::pin::Pin<
566 Box<dyn std::future::Future<Output = behest_tool::ToolResult<Value>> + Send>,
567 > {
568 let flag = Arc::clone(&exc_flag);
569 Box::pin(async move {
570 flag.store(true, Ordering::SeqCst);
571 Ok(Value::Null)
572 })
573 },
574 );
575 let registry = ToolRegistry::new();
578 registry.register(concurrent_tool);
579 registry.register(exclusive_tool);
580
581 let policy = RuntimePolicy::new().with_max_tool_concurrency(4);
582 let runtime = ToolRuntime::new(registry, policy);
583
584 let calls = vec![
585 ToolCall::new("call_1", "concurrent", json!({})),
586 ToolCall::new("call_2", "exclusive", json!({})),
587 ];
588
589 let session_id = Uuid::now_v7();
590 let message_id = Uuid::now_v7();
591 let outcomes = runtime
592 .execute_batch(calls, session_id, message_id, None)
593 .await
594 .unwrap();
595
596 assert_eq!(outcomes.len(), 2);
597 assert!(outcomes[0].output.is_ok());
598 assert!(outcomes[1].output.is_ok());
599 assert!(concurrent_flag.load(Ordering::SeqCst));
600 assert!(exclusive_flag.load(Ordering::SeqCst));
601 }
602
603 #[tokio::test]
604 async fn execute_batch_preserves_result_order() {
605 let tool_a = FunctionTool::new(
606 "a",
607 "Tool A",
608 json!({"type": "object"}),
609 |_args: Value| -> std::pin::Pin<
610 Box<dyn std::future::Future<Output = behest_tool::ToolResult<Value>> + Send>,
611 > { Box::pin(async move { Ok(Value::String("A".into())) }) },
612 )
613 .concurrency_safe();
614
615 let tool_b = FunctionTool::new(
616 "b",
617 "Tool B",
618 json!({"type": "object"}),
619 |_args: Value| -> std::pin::Pin<
620 Box<dyn std::future::Future<Output = behest_tool::ToolResult<Value>> + Send>,
621 > { Box::pin(async move { Ok(Value::String("B".into())) }) },
622 );
623 let registry = ToolRegistry::new();
626 registry.register(tool_a);
627 registry.register(tool_b);
628
629 let policy = RuntimePolicy::new().with_max_tool_concurrency(4);
630 let runtime = ToolRuntime::new(registry, policy);
631
632 let calls = vec![
633 ToolCall::new("call_1", "a", json!({})),
634 ToolCall::new("call_2", "b", json!({})),
635 ];
636
637 let session_id = Uuid::now_v7();
638 let message_id = Uuid::now_v7();
639 let outcomes = runtime
640 .execute_batch(calls, session_id, message_id, None)
641 .await
642 .unwrap();
643
644 assert_eq!(outcomes.len(), 2);
645 assert_eq!(outcomes[0].call.name, "a");
647 assert_eq!(outcomes[1].call.name, "b");
648 }
649
650 #[tokio::test]
651 async fn execute_batch_should_propagate_tool_failure_when_continue_disabled() {
652 let registry = ToolRegistry::new();
653 registry.register(failing_tool());
654 let policy = RuntimePolicy::new().with_continue_on_tool_failure(false);
655 let runtime = ToolRuntime::new(registry, policy);
656
657 let calls = vec![ToolCall::new("call_1", "fail", json!({}))];
658 let session_id = Uuid::now_v7();
659 let message_id = Uuid::now_v7();
660 let result = runtime
661 .execute_batch(calls, session_id, message_id, None)
662 .await;
663
664 assert!(matches!(result, Err(RuntimeError::Tool(_))));
665 }
666
667 #[tokio::test]
668 async fn execute_batch_should_propagate_invalid_arguments_when_continue_disabled() {
669 let registry = ToolRegistry::new();
670 registry.register(echo_tool());
671 let policy = RuntimePolicy::new().with_continue_on_tool_failure(false);
672 let runtime = ToolRuntime::new(registry, policy);
673
674 let calls = vec![ToolCall::new("call_1", "echo", json!({"message": 123}))];
675 let session_id = Uuid::now_v7();
676 let message_id = Uuid::now_v7();
677 let result = runtime
678 .execute_batch(calls, session_id, message_id, None)
679 .await;
680
681 assert!(matches!(result, Err(RuntimeError::Tool(_))));
682 }
683
684 #[tokio::test]
685 async fn execute_batch_with_zero_concurrency_should_not_hang() {
686 let registry = ToolRegistry::new();
687 registry.register(echo_tool());
688 let policy = RuntimePolicy::new().with_max_tool_concurrency(0);
689 let runtime = ToolRuntime::new(registry, policy);
690
691 let calls = vec![ToolCall::new("call_1", "echo", json!({"message": "hello"}))];
692 let session_id = Uuid::now_v7();
693 let message_id = Uuid::now_v7();
694 let result = tokio::time::timeout(
695 std::time::Duration::from_millis(100),
696 runtime.execute_batch(calls, session_id, message_id, None),
697 )
698 .await;
699
700 let outcomes = result.expect("tool runtime should not hang").unwrap();
701 assert_eq!(outcomes.len(), 1);
702 assert!(outcomes[0].output.is_ok());
703 }
704}