1use std::sync::Arc;
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24
25use serde_json::{json, Value};
26use tokio::sync::mpsc;
27use tokio::time::timeout;
28use tokio_util::sync::CancellationToken;
29
30use crate::config::LoopConfig;
31use crate::error::{LoopError, ToolError};
32use crate::event::{AgentEvent, EventSink};
33use crate::plugin::{AfterToolCallContext, BeforeToolCallContext, EventObserver};
34use crate::tool::{detect_arg_parse_error, AgentTool, ExecutionMode, ToolCall, ToolResult};
35use crate::types::{AgentContext, AgentMessage, AssistantContent, ToolResultContent};
36
37const TOOL_UPDATE_DRAIN_GRACE: Duration = Duration::from_millis(50);
38const TOOL_UPDATE_EVENT_QUEUE_CAPACITY: usize = 256;
39
40fn spawn_tool_update_dispatcher(
41 event_sink: Arc<dyn EventSink>,
42 observers: Vec<Arc<dyn EventObserver>>,
43) -> mpsc::Sender<AgentEvent> {
44 let (tx, mut rx) = mpsc::channel::<AgentEvent>(TOOL_UPDATE_EVENT_QUEUE_CAPACITY);
45 tokio::spawn(async move {
46 while let Some(event) = rx.recv().await {
47 event_sink.emit(event.clone()).await;
48 for observer in observers.iter() {
49 observer.on_event(&event).await;
50 }
51 }
52 });
53 tx
54}
55
56fn enqueue_tool_update_event(tx: &mpsc::Sender<AgentEvent>, event: AgentEvent) {
57 match tx.try_send(event) {
58 Ok(()) => {}
59 Err(mpsc::error::TrySendError::Full(_)) => {
60 tracing::warn!("tool update event queue full; dropping partial update");
61 }
62 Err(mpsc::error::TrySendError::Closed(_)) => {}
63 }
64}
65
66pub(crate) struct ExecutedBatch {
68 pub messages: Vec<AgentMessage>,
70 pub terminate: bool,
73}
74
75pub(crate) async fn execute_tool_batch(
76 assistant: &AgentMessage,
77 tool_calls: Vec<ToolCall>,
78 context: &AgentContext,
79 config: &LoopConfig,
80 signal: &CancellationToken,
81 turn_allowlist: Option<&std::collections::HashSet<String>>,
82) -> Result<ExecutedBatch, LoopError> {
83 if tool_calls.is_empty() {
84 return Ok(ExecutedBatch {
85 messages: Vec::new(),
86 terminate: false,
87 });
88 }
89
90 let mut tool_calls = tool_calls;
95 config
96 .protocol
97 .normalize_tool_calls(&mut tool_calls, &config.tools);
98
99 let total_tool_calls = tool_calls.len();
100 let limit_counted_tool_calls = count_limit_counted_tool_calls(&tool_calls, &config.tools);
101 let (tool_calls, unexecuted_tool_calls, max_executed) =
102 split_tool_calls_for_execution(tool_calls, &config.tools, config.max_tool_calls_per_turn);
103
104 let assistant_content = match assistant {
105 AgentMessage::Assistant { content, .. } => content.clone(),
106 _ => AssistantContent { blocks: Vec::new() },
107 };
108
109 if tool_calls.is_empty() {
110 let messages = synthesize_unexecuted_tool_results(
111 assistant,
112 &assistant_content,
113 unexecuted_tool_calls,
114 total_tool_calls,
115 limit_counted_tool_calls,
116 max_executed.unwrap_or(0),
117 context,
118 config,
119 )
120 .await;
121 return Ok(ExecutedBatch {
122 messages,
123 terminate: false,
124 });
125 }
126
127 let any_exclusive = tool_calls.iter().any(|call| {
131 config
132 .tools
133 .get(&call.name)
134 .map(|t| t.requires_exclusive_sandbox())
135 .unwrap_or(false)
136 });
137
138 let effective_mode =
139 if any_exclusive || config.default_execution_mode == ExecutionMode::Sequential {
140 ExecutionMode::Sequential
141 } else {
142 ExecutionMode::Parallel
143 };
144
145 let mut batch = match effective_mode {
146 ExecutionMode::Sequential => {
147 execute_sequential(
148 assistant,
149 &assistant_content,
150 tool_calls,
151 context,
152 config,
153 signal,
154 turn_allowlist,
155 )
156 .await
157 }
158 ExecutionMode::Parallel => {
159 execute_parallel(
160 assistant,
161 &assistant_content,
162 tool_calls,
163 context,
164 config,
165 signal,
166 turn_allowlist,
167 )
168 .await
169 }
170 }?;
171
172 if !unexecuted_tool_calls.is_empty() {
173 batch.messages.extend(
174 synthesize_unexecuted_tool_results(
175 assistant,
176 &assistant_content,
177 unexecuted_tool_calls,
178 total_tool_calls,
179 limit_counted_tool_calls,
180 max_executed.unwrap_or(0),
181 context,
182 config,
183 )
184 .await,
185 );
186 batch.terminate = false;
187 }
188
189 Ok(batch)
190}
191
192fn split_tool_calls_for_execution(
193 tool_calls: Vec<ToolCall>,
194 tools: &crate::tool::ToolRegistry,
195 max_tool_calls: Option<usize>,
196) -> (Vec<ToolCall>, Vec<ToolCall>, Option<usize>) {
197 let Some(max_tool_calls) = max_tool_calls else {
198 return (tool_calls, Vec::new(), None);
199 };
200 let max_tool_calls = max_tool_calls.max(1);
201 if count_limit_counted_tool_calls(&tool_calls, tools) <= max_tool_calls {
202 return (tool_calls, Vec::new(), Some(max_tool_calls));
203 }
204
205 let mut executable = Vec::with_capacity(tool_calls.len());
206 let mut unexecuted = Vec::new();
207 let mut executed_counted = 0usize;
208 for call in tool_calls {
209 if !tool_counts_toward_call_limit(tools, &call.name) {
210 executable.push(call);
216 } else if executed_counted < max_tool_calls {
217 executed_counted += 1;
218 executable.push(call);
219 } else {
220 unexecuted.push(call);
221 }
222 }
223 (executable, unexecuted, Some(max_tool_calls))
224}
225
226fn count_limit_counted_tool_calls(
227 tool_calls: &[ToolCall],
228 tools: &crate::tool::ToolRegistry,
229) -> usize {
230 tool_calls
231 .iter()
232 .filter(|call| tool_counts_toward_call_limit(tools, &call.name))
233 .count()
234}
235
236fn tool_counts_toward_call_limit(tools: &crate::tool::ToolRegistry, name: &str) -> bool {
263 tools
264 .get(name)
265 .map(|tool| tool.counts_toward_tool_call_limit() && !tool.parallel_safe_per_turn())
266 .unwrap_or(false)
267}
268
269fn tool_counts_toward_termination_vote(tools: &crate::tool::ToolRegistry, name: &str) -> bool {
274 tools
275 .get(name)
276 .map(|tool| tool.counts_toward_termination_vote())
277 .unwrap_or(true)
278}
279
280fn compute_batch_terminate<'a, I>(tools: &crate::tool::ToolRegistry, votes: I) -> bool
296where
297 I: IntoIterator<Item = (&'a str, bool)>,
298{
299 let mut counted_total = 0usize;
300 let mut counted_terminate = 0usize;
301 let mut terminating: Vec<&'a str> = Vec::new();
302 let mut advisory_skipped: Vec<&'a str> = Vec::new();
303 for (name, terminate) in votes {
304 if !tool_counts_toward_termination_vote(tools, name) {
305 advisory_skipped.push(name);
306 continue;
307 }
308 counted_total += 1;
309 if terminate {
310 counted_terminate += 1;
311 terminating.push(name);
312 }
313 }
314 let terminated = counted_total > 0 && counted_terminate == counted_total;
315 if terminated && !advisory_skipped.is_empty() {
316 tracing::info!(
317 terminating_tools = ?terminating,
318 advisory_tools = ?advisory_skipped,
319 counted_total,
320 "advisory siblings excluded from unanimous termination vote"
321 );
322 }
323 terminated
324}
325
326#[allow(clippy::too_many_arguments)]
329async fn synthesize_unexecuted_tool_results(
330 assistant: &AgentMessage,
331 assistant_content: &AssistantContent,
332 tool_calls: Vec<ToolCall>,
333 total_tool_calls: usize,
334 limit_counted_tool_calls: usize,
335 max_executed: usize,
336 context: &AgentContext,
337 config: &LoopConfig,
338) -> Vec<AgentMessage> {
339 let mut messages = Vec::with_capacity(tool_calls.len());
340 for call in tool_calls {
341 let outcome = finalize(
342 assistant,
343 assistant_content,
344 &call,
345 &call.arguments,
346 ExecutedOutcome {
347 result: unexecuted_tool_call_result(
348 total_tool_calls,
349 limit_counted_tool_calls,
350 max_executed,
351 ),
352 is_error: true,
353 },
354 &context.messages,
355 &config.plugins.after_tool_call,
356 )
357 .await;
358 emit_tool_end(config, &call, &outcome).await;
359 messages.push(outcome_to_message(&call, outcome));
360 }
361 messages
362}
363
364fn unexecuted_tool_call_message(
365 total_tool_calls: usize,
366 limit_counted_tool_calls: usize,
367 max_executed: usize,
368) -> String {
369 let call_word = if total_tool_calls == 1 {
370 "tool call"
371 } else {
372 "tool calls"
373 };
374 let limited_call_word = if limit_counted_tool_calls == 1 {
375 "limit-counted tool call"
376 } else {
377 "limit-counted tool calls"
378 };
379 let executed_word = if max_executed == 1 { "call" } else { "calls" };
380 if limit_counted_tool_calls != total_tool_calls {
381 return format!(
382 "This tool call was not executed because the assistant turn emitted \
383 {limit_counted_tool_calls} {limited_call_word} ({total_tool_calls} \
384 {call_word} total, including progress-only calls), but only the \
385 first {max_executed} limit-counted {executed_word} can run in one \
386 turn. The earlier allowed calls already ran. Reissue this call in \
387 a later turn, one tool call at a time."
388 );
389 }
390 format!(
391 "This tool call was not executed because the assistant turn emitted \
392 {total_tool_calls} {call_word}, but only the first {max_executed} \
393 {executed_word} can run in one turn. The earlier {max_executed} \
394 {executed_word} already ran. Reissue this call in a later turn, \
395 one tool call at a time."
396 )
397}
398
399fn unexecuted_tool_call_result(
400 total_tool_calls: usize,
401 limit_counted_tool_calls: usize,
402 max_executed: usize,
403) -> ToolResult {
404 let mut result = ToolResult::error(unexecuted_tool_call_message(
405 total_tool_calls,
406 limit_counted_tool_calls,
407 max_executed,
408 ));
409 result.details = json!({
410 "kind": "tool_call_not_executed",
411 "reason": "max_tool_calls_per_turn",
412 "total_tool_calls": total_tool_calls,
413 "limit_counted_tool_calls": limit_counted_tool_calls,
414 "max_executed": max_executed,
415 });
416 result
417}
418
419#[allow(clippy::too_many_arguments)]
420async fn execute_sequential(
421 assistant: &AgentMessage,
422 assistant_content: &AssistantContent,
423 tool_calls: Vec<ToolCall>,
424 context: &AgentContext,
425 config: &LoopConfig,
426 signal: &CancellationToken,
427 turn_allowlist: Option<&std::collections::HashSet<String>>,
428) -> Result<ExecutedBatch, LoopError> {
429 let mut messages = Vec::with_capacity(tool_calls.len());
430 let mut votes: Vec<(String, bool)> = Vec::with_capacity(tool_calls.len());
431
432 let mut remaining_calls = tool_calls.into_iter();
433 while let Some(call) = remaining_calls.next() {
434 let outcome = run_one(
435 assistant,
436 assistant_content,
437 &call,
438 context,
439 config,
440 signal,
441 turn_allowlist,
442 )
443 .await?;
444 let abort_remaining = outcome.is_error
445 && config
446 .tools
447 .get(&call.name)
448 .map(|tool| tool.aborts_siblings_on_error())
449 .unwrap_or(false);
450 votes.push((call.name.clone(), outcome.terminate));
451 messages.push(outcome_to_message(&call, outcome));
452
453 if abort_remaining {
454 let skipped_calls: Vec<_> = remaining_calls.collect();
455 votes.extend(skipped_calls.iter().map(|call| (call.name.clone(), false)));
456 messages.extend(
457 synthesize_aborted_sibling_tool_results(
458 assistant,
459 assistant_content,
460 skipped_calls,
461 &call.name,
462 context,
463 config,
464 )
465 .await,
466 );
467 break;
468 }
469 }
470
471 let terminate =
472 compute_batch_terminate(&config.tools, votes.iter().map(|(n, t)| (n.as_str(), *t)));
473
474 Ok(ExecutedBatch {
475 messages,
476 terminate,
477 })
478}
479
480#[allow(clippy::too_many_arguments)]
484async fn synthesize_aborted_sibling_tool_results(
485 assistant: &AgentMessage,
486 assistant_content: &AssistantContent,
487 tool_calls: Vec<ToolCall>,
488 failed_tool_name: &str,
489 context: &AgentContext,
490 config: &LoopConfig,
491) -> Vec<AgentMessage> {
492 let mut messages = Vec::with_capacity(tool_calls.len());
493 for call in tool_calls {
494 let mut result = ToolResult::error(format!(
495 "This tool call was not executed because earlier sibling tool \
496 `{failed_tool_name}` failed and declared later work dependent on \
497 its success. Correct that error, then reissue this call in a later turn."
498 ));
499 result.details = json!({
500 "kind": "tool_call_not_executed",
501 "reason": "sibling_tool_error",
502 "failed_tool": failed_tool_name,
503 });
504 let outcome = finalize(
505 assistant,
506 assistant_content,
507 &call,
508 &call.arguments,
509 ExecutedOutcome {
510 result,
511 is_error: true,
512 },
513 &context.messages,
514 &config.plugins.after_tool_call,
515 )
516 .await;
517 emit_tool_end(config, &call, &outcome).await;
518 messages.push(outcome_to_message(&call, outcome));
519 }
520 messages
521}
522
523#[allow(clippy::too_many_arguments)]
524async fn execute_parallel(
525 assistant: &AgentMessage,
526 assistant_content: &AssistantContent,
527 tool_calls: Vec<ToolCall>,
528 context: &AgentContext,
529 config: &LoopConfig,
530 signal: &CancellationToken,
531 turn_allowlist: Option<&std::collections::HashSet<String>>,
532) -> Result<ExecutedBatch, LoopError> {
533 use futures::stream::{FuturesUnordered, StreamExt};
534
535 let batch_token = signal.child_token();
543
544 let mut prepared: Vec<(ToolCall, PreparedCall)> = Vec::with_capacity(tool_calls.len());
548 for call in tool_calls {
549 let prep = prepare_call(
550 assistant,
551 assistant_content,
552 &call,
553 context,
554 config,
555 turn_allowlist,
556 )
557 .await;
558 if matches!(prep, PreparedCall::Prepared { .. }) {
559 emit_tool_start(config, &call).await;
560 }
561 prepared.push((call, prep));
562 }
563
564 let mut futures = Vec::with_capacity(prepared.len());
565 let mut immediate: Vec<(usize, ToolCall, FinalizedOutcome)> = Vec::new();
566
567 for (idx, (call, prep)) in prepared.into_iter().enumerate() {
568 match prep {
569 PreparedCall::Immediate(executed) => {
570 let finalized = finalize(
578 assistant,
579 assistant_content,
580 &call,
581 &call.arguments,
582 executed,
583 &context.messages,
584 &config.plugins.after_tool_call,
585 )
586 .await;
587 immediate.push((idx, call, finalized));
588 }
589 PreparedCall::Prepared { tool, args } => {
590 let tool_signal = batch_token.child_token();
591 let run_signal = signal.clone();
592 let batch_token_clone = batch_token.clone();
593 let assistant_clone = assistant.clone();
594 let assistant_content_clone = assistant_content.clone();
595 let context_messages = context.messages.clone();
596 let after_hooks = config.plugins.after_tool_call.clone();
597 let event_sink = config.event_sink.clone();
598 let event_observers = config.plugins.event_observer.clone();
599 let call_clone = call.clone();
600 let fut = async move {
601 let id = call_clone.id.clone();
602 let name = call_clone.name.clone();
603 let name_for_message = name.clone();
604 let update_events = spawn_tool_update_dispatcher(event_sink, event_observers);
605 let executed_result = execute_prepared(
606 &tool,
607 &call_clone,
608 args.clone(),
609 tool_signal,
610 Box::new(move |update| {
611 let event = AgentEvent::ToolExecutionUpdate {
612 tool_call_id: id.clone(),
613 tool_name: name.clone(),
614 partial: update,
615 };
616 enqueue_tool_update_event(&update_events, event);
617 }),
618 )
619 .await;
620 let executed = match executed_result {
621 Ok(executed) => executed,
622 Err(LoopError::Aborted)
623 if batch_token_clone.is_cancelled() && !run_signal.is_cancelled() =>
624 {
625 ExecutedOutcome {
631 result: ToolResult::error(format!(
632 "aborted because a sibling tool in the \
633 parallel batch errored — re-run this \
634 {name_for_message} call after addressing the \
635 sibling failure"
636 )),
637 is_error: true,
638 }
639 }
640 Err(other) => return Err(other),
641 };
642 let finalized = finalize(
643 &assistant_clone,
644 &assistant_content_clone,
645 &call_clone,
646 &args,
647 executed,
648 &context_messages,
649 &after_hooks,
650 )
651 .await;
652 Ok::<_, LoopError>((idx, call_clone, finalized))
653 };
654 futures.push(fut);
655 }
656 }
657 }
658
659 let mut unordered: FuturesUnordered<_> = futures.into_iter().collect();
666 let mut completed: Vec<(usize, ToolCall, FinalizedOutcome)> =
667 Vec::with_capacity(unordered.len() + immediate.len());
668 while let Some(result) = unordered.next().await {
669 let entry = result?;
670 if entry.2.is_error {
671 let aborts = config
672 .tools
673 .get(&entry.1.name)
674 .map(|t| t.aborts_siblings_on_error())
675 .unwrap_or(false);
676 if aborts && !batch_token.is_cancelled() {
677 batch_token.cancel();
678 }
679 }
680 completed.push(entry);
681 }
682 completed.extend(immediate);
683 completed.sort_by_key(|(idx, _, _)| *idx);
684
685 let mut messages = Vec::with_capacity(completed.len());
686 let mut votes: Vec<(String, bool)> = Vec::with_capacity(completed.len());
687 for (_idx, call, outcome) in completed {
688 emit_tool_end(config, &call, &outcome).await;
689 votes.push((call.name.clone(), outcome.terminate));
690 messages.push(outcome_to_message(&call, outcome));
691 }
692
693 let terminate =
694 compute_batch_terminate(&config.tools, votes.iter().map(|(n, t)| (n.as_str(), *t)));
695
696 Ok(ExecutedBatch {
697 messages,
698 terminate,
699 })
700}
701
702#[allow(clippy::too_many_arguments)]
705async fn run_one(
706 assistant: &AgentMessage,
707 assistant_content: &AssistantContent,
708 call: &ToolCall,
709 context: &AgentContext,
710 config: &LoopConfig,
711 signal: &CancellationToken,
712 turn_allowlist: Option<&std::collections::HashSet<String>>,
713) -> Result<FinalizedOutcome, LoopError> {
714 let prep = prepare_call(
715 assistant,
716 assistant_content,
717 call,
718 context,
719 config,
720 turn_allowlist,
721 )
722 .await;
723 let outcome = match prep {
724 PreparedCall::Immediate(executed) => {
725 finalize(
726 assistant,
727 assistant_content,
728 call,
729 &call.arguments,
730 executed,
731 &context.messages,
732 &config.plugins.after_tool_call,
733 )
734 .await
735 }
736 PreparedCall::Prepared { tool, args } => {
737 emit_tool_start(config, call).await;
738 let event_sink = config.event_sink.clone();
739 let event_observers = config.plugins.event_observer.clone();
740 let id = call.id.clone();
741 let name = call.name.clone();
742 let update_events = spawn_tool_update_dispatcher(event_sink, event_observers);
743 let executed = execute_prepared(
744 &tool,
745 call,
746 args.clone(),
747 signal.clone(),
748 Box::new(move |update| {
749 let event = AgentEvent::ToolExecutionUpdate {
750 tool_call_id: id.clone(),
751 tool_name: name.clone(),
752 partial: update,
753 };
754 enqueue_tool_update_event(&update_events, event);
755 }),
756 )
757 .await?;
758 finalize(
759 assistant,
760 assistant_content,
761 call,
762 &args,
763 executed,
764 &context.messages,
765 &config.plugins.after_tool_call,
766 )
767 .await
768 }
769 };
770
771 emit_tool_end(config, call, &outcome).await;
772 Ok(outcome)
773}
774
775enum PreparedCall {
778 Immediate(ExecutedOutcome),
784 Prepared {
786 tool: Arc<dyn AgentTool>,
787 args: Value,
788 },
789}
790
791struct ExecutedOutcome {
792 result: ToolResult,
793 is_error: bool,
794}
795
796pub(crate) struct FinalizedOutcome {
797 pub result: ToolResult,
798 pub is_error: bool,
799 pub terminate: bool,
800}
801
802struct GateDenial {
808 reason: String,
809 gate: &'static str,
810}
811
812async fn gate_attributed_denial(
813 tool_name: &str,
814 config: &LoopConfig,
815 messages: &[AgentMessage],
816) -> Option<GateDenial> {
817 let available_tool_names: Vec<&str> = config.tools.iter().map(|t| t.name()).collect();
818 let iteration = messages
819 .iter()
820 .filter(|m| matches!(m, AgentMessage::Assistant { .. }))
821 .count();
822 for gate in &config.plugins.tool_gate {
823 let ctx = crate::plugin::ToolGateContext {
824 iteration,
825 messages,
826 conversation_id: config.conversation_id.as_deref(),
827 available_tool_names: &available_tool_names,
828 };
829 if let Some(reason) = gate.denial_reason(tool_name, ctx).await {
830 return Some(GateDenial {
831 reason,
832 gate: gate.name(),
833 });
834 }
835 }
836 None
837}
838
839async fn prepare_call(
840 assistant: &AgentMessage,
841 assistant_content: &AssistantContent,
842 call: &ToolCall,
843 context: &AgentContext,
844 config: &LoopConfig,
845 turn_allowlist: Option<&std::collections::HashSet<String>>,
846) -> PreparedCall {
847 let Some(tool) = config.tools.get(&call.name) else {
848 return PreparedCall::Immediate(ExecutedOutcome {
849 result: ToolResult::error(format!("Tool `{}` not found", call.name)),
850 is_error: true,
851 });
852 };
853
854 if let Some(allowlist) = turn_allowlist {
869 if !allowlist.contains(call.name.as_str()) {
870 let attributed = gate_attributed_denial(&call.name, config, &context.messages).await;
871 let (message, details) =
872 match attributed {
873 Some(denial) => {
874 let details = crate::protocol::generic_hidden_tool_details(
875 &call.name,
876 allowlist,
877 Some(denial.gate),
878 );
879 (denial.reason, details)
880 }
881 None => match config.protocol.hidden_tool_error(
882 crate::protocol::HiddenToolContext {
883 requested_tool: &call.name,
884 allowlist,
885 messages: &context.messages,
886 },
887 ) {
888 Some(err) => (err.message, err.details),
889 None => (
890 crate::protocol::generic_hidden_tool_message(&call.name, allowlist),
891 crate::protocol::generic_hidden_tool_details(
892 &call.name, allowlist, None,
893 ),
894 ),
895 },
896 };
897 let mut result = ToolResult::error(message);
898 result.details = details;
899 return PreparedCall::Immediate(ExecutedOutcome {
900 result,
901 is_error: true,
902 });
903 }
904 }
905
906 if let Some((parse_err, raw)) = detect_arg_parse_error(&call.arguments) {
913 return PreparedCall::Immediate(ExecutedOutcome {
914 result: ToolResult::argument_validation_error(
915 &call.name,
916 format_arg_parse_error(&call.name, parse_err, raw),
917 ),
918 is_error: true,
919 });
920 }
921
922 let prepared_args = tool.prepare_arguments(call.arguments.clone());
923
924 if let Err(err) = tool.validate(&prepared_args) {
925 return PreparedCall::Immediate(ExecutedOutcome {
926 result: ToolResult::argument_validation_error(&call.name, err.to_string()),
927 is_error: true,
928 });
929 }
930
931 let ctx = BeforeToolCallContext {
932 assistant_message: assistant,
933 assistant_content,
934 tool_call: call,
935 args: &prepared_args,
936 messages: &context.messages,
937 };
938 for hook in &config.plugins.before_tool_call {
939 let decision = hook
940 .on_before_tool_call(BeforeToolCallContext {
941 assistant_message: ctx.assistant_message,
942 assistant_content: ctx.assistant_content,
943 tool_call: ctx.tool_call,
944 args: ctx.args,
945 messages: ctx.messages,
946 })
947 .await;
948 if decision.block {
949 let reason = decision
950 .reason
951 .unwrap_or_else(|| format!("blocked by {}", hook.name()));
952 let mut result = ToolResult::error(reason);
953 if let Some(details) = decision.details {
954 result.details = details;
955 }
956 return PreparedCall::Immediate(ExecutedOutcome {
957 result,
958 is_error: true,
959 });
960 }
961 }
962
963 PreparedCall::Prepared {
964 tool,
965 args: prepared_args,
966 }
967}
968
969async fn execute_prepared(
970 tool: &Arc<dyn AgentTool>,
971 call: &ToolCall,
972 args: Value,
973 signal: CancellationToken,
974 on_update: Box<dyn Fn(ToolResult) + Send + Sync + 'static>,
975) -> Result<ExecutedOutcome, LoopError> {
976 let (tx, mut rx) = mpsc::unbounded_channel::<ToolResult>();
977
978 let mut drain_handle = tokio::spawn(async move {
980 while let Some(partial) = rx.recv().await {
981 on_update(partial);
982 }
983 });
984
985 let result = match tool.execute(&call.id, args, signal, tx).await {
986 Ok(result) => {
987 let is_error = result.is_error;
988 Ok(ExecutedOutcome { result, is_error })
989 }
990 Err(ToolError::Execution(reason)) => Ok(ExecutedOutcome {
991 result: ToolResult::error(ToolError::Execution(reason).to_string()),
992 is_error: true,
993 }),
994 Err(ToolError::Aborted) => Err(LoopError::Aborted),
995 Err(ToolError::Fatal(reason)) => Err(LoopError::ToolFatal {
996 tool: call.name.clone(),
997 reason,
998 }),
999 };
1000
1001 match timeout(TOOL_UPDATE_DRAIN_GRACE, &mut drain_handle).await {
1002 Ok(joined) => {
1003 if let Err(error) = joined {
1004 tracing::debug!(?error, "tool update dispatcher join failed");
1005 }
1006 }
1007 Err(_) => {
1008 drain_handle.abort();
1009 if let Err(error) = drain_handle.await {
1010 tracing::debug!(?error, "aborted tool update dispatcher");
1011 }
1012 }
1013 }
1014 result
1015}
1016
1017#[allow(clippy::too_many_arguments)]
1018async fn finalize(
1019 assistant: &AgentMessage,
1020 _assistant_content: &AssistantContent,
1021 call: &ToolCall,
1022 args: &Value,
1023 mut executed: ExecutedOutcome,
1024 messages: &[AgentMessage],
1025 after_hooks: &[Arc<dyn crate::plugin::AfterToolCall>],
1026) -> FinalizedOutcome {
1027 for hook in after_hooks {
1028 let ctx = AfterToolCallContext {
1029 assistant_message: assistant,
1030 tool_call: call,
1031 args,
1032 result: &executed.result,
1033 is_error: executed.is_error,
1034 messages,
1035 };
1036 let decision = hook.on_after_tool_call(ctx).await;
1037 if let Some(new_result) = decision.result {
1038 executed.is_error = new_result.is_error;
1039 executed.result = new_result;
1040 }
1041 if let Some(mark_error) = decision.mark_error {
1042 executed.is_error = mark_error;
1043 executed.result.is_error = mark_error;
1044 }
1045 if let Some(terminate) = decision.terminate {
1046 executed.result.terminate = terminate;
1047 }
1048 }
1049
1050 FinalizedOutcome {
1051 result: executed.result,
1052 is_error: executed.is_error,
1053 terminate: false,
1054 }
1055 .with_vote()
1058}
1059
1060impl FinalizedOutcome {
1061 fn with_vote(mut self) -> Self {
1062 self.terminate = self.result.terminate;
1063 self
1064 }
1065}
1066
1067fn outcome_to_message(call: &ToolCall, outcome: FinalizedOutcome) -> AgentMessage {
1068 let details = match outcome.result.details {
1069 serde_json::Value::Null => None,
1070 other => Some(other),
1071 };
1072 let message = AgentMessage::ToolResult {
1073 tool_call_id: call.id.clone(),
1074 tool_name: call.name.clone(),
1075 content: ToolResultContent {
1076 blocks: outcome.result.content,
1077 },
1078 is_error: outcome.is_error,
1079 narration: outcome.result.narration,
1085 details,
1090 timestamp: Some(now_ms()),
1091 };
1092 if let AgentMessage::ToolResult {
1100 content,
1101 is_error,
1102 tool_call_id,
1103 tool_name,
1104 ..
1105 } = &message
1106 {
1107 let plain = content.plain_text();
1108 let (head, tail) = head_tail_for_log(&plain);
1109 tracing::debug!(
1110 target: "clark_agent::exec::tool_result_built",
1111 tool_call_id = %tool_call_id,
1112 tool_name = %tool_name,
1113 is_error = *is_error,
1114 content_len = plain.len(),
1115 content_head = %head,
1116 content_tail = %tail,
1117 "outcome_to_message wrote ToolResult into messages"
1118 );
1119 }
1120 message
1121}
1122
1123const TOOL_RESULT_LOG_HEAD: usize = 200;
1124const TOOL_RESULT_LOG_TAIL: usize = 200;
1125
1126fn head_tail_for_log(text: &str) -> (String, String) {
1131 if text.len() <= TOOL_RESULT_LOG_HEAD + TOOL_RESULT_LOG_TAIL {
1132 return (text.to_string(), String::new());
1133 }
1134 let head_end = char_boundary_at_or_before(text, TOOL_RESULT_LOG_HEAD);
1135 let tail_start = char_boundary_at_or_after(text, text.len() - TOOL_RESULT_LOG_TAIL);
1136 (text[..head_end].to_string(), text[tail_start..].to_string())
1137}
1138
1139fn char_boundary_at_or_before(text: &str, mut idx: usize) -> usize {
1140 if idx >= text.len() {
1141 return text.len();
1142 }
1143 while idx > 0 && !text.is_char_boundary(idx) {
1144 idx -= 1;
1145 }
1146 idx
1147}
1148
1149fn char_boundary_at_or_after(text: &str, mut idx: usize) -> usize {
1150 if idx >= text.len() {
1151 return text.len();
1152 }
1153 while idx < text.len() && !text.is_char_boundary(idx) {
1154 idx += 1;
1155 }
1156 idx
1157}
1158
1159fn now_ms() -> u64 {
1160 SystemTime::now()
1161 .duration_since(UNIX_EPOCH)
1162 .map(|d| d.as_millis() as u64)
1163 .unwrap_or(0)
1164}
1165
1166async fn emit_tool_start(config: &LoopConfig, call: &ToolCall) {
1167 let event = AgentEvent::ToolExecutionStart {
1168 tool_call_id: call.id.clone(),
1169 tool_name: call.name.clone(),
1170 args: call.arguments.clone(),
1171 };
1172 config.event_sink.emit(event.clone()).await;
1173 for o in &config.plugins.event_observer {
1174 o.on_event(&event).await;
1175 }
1176}
1177
1178fn format_arg_parse_error(tool_name: &str, parse_err: &str, raw: &str) -> String {
1184 const RAW_MAX: usize = 1024;
1185 let raw_snippet = if raw.len() > RAW_MAX {
1186 format!(
1187 "{}…<{} bytes truncated>",
1188 &raw[..RAW_MAX],
1189 raw.len() - RAW_MAX
1190 )
1191 } else {
1192 raw.to_string()
1193 };
1194 format!(
1195 "Tool `{tool_name}` arguments were not valid JSON: {parse_err}. \
1196 You sent (raw): {raw_snippet}. \
1197 Re-emit the call with a JSON object matching the tool's schema; \
1198 this is a syntax error in your tool-call arguments, not a problem \
1199 with the file or the runtime."
1200 )
1201}
1202
1203async fn emit_tool_end(config: &LoopConfig, call: &ToolCall, outcome: &FinalizedOutcome) {
1204 let event = AgentEvent::ToolExecutionEnd {
1205 tool_call_id: call.id.clone(),
1206 tool_name: call.name.clone(),
1207 result: outcome.result.clone(),
1208 is_error: outcome.is_error,
1209 };
1210 config.event_sink.emit(event.clone()).await;
1211 for o in &config.plugins.event_observer {
1212 o.on_event(&event).await;
1213 }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219 use crate::ToolResultBlock;
1220 use std::sync::Arc;
1221
1222 struct LimitTool {
1223 name: &'static str,
1224 counts: bool,
1225 vote_counts: bool,
1226 parallel_safe: bool,
1227 }
1228
1229 #[async_trait::async_trait]
1230 impl AgentTool for LimitTool {
1231 fn name(&self) -> &str {
1232 self.name
1233 }
1234
1235 fn description(&self) -> &str {
1236 "test tool"
1237 }
1238
1239 fn parameters_schema(&self) -> Value {
1240 json!({"type": "object"})
1241 }
1242
1243 fn counts_toward_tool_call_limit(&self) -> bool {
1244 self.counts
1245 }
1246
1247 fn parallel_safe_per_turn(&self) -> bool {
1248 self.parallel_safe
1249 }
1250
1251 fn counts_toward_termination_vote(&self) -> bool {
1252 self.vote_counts
1253 }
1254
1255 async fn execute(
1256 &self,
1257 _call_id: &str,
1258 _args: Value,
1259 _signal: CancellationToken,
1260 _update: mpsc::UnboundedSender<ToolResult>,
1261 ) -> Result<ToolResult, ToolError> {
1262 unreachable!("split tests do not execute tools")
1263 }
1264 }
1265
1266 fn registry() -> crate::tool::ToolRegistry {
1267 crate::tool::ToolRegistry::new()
1271 .with(Arc::new(LimitTool {
1272 name: "message_info",
1273 counts: false,
1274 vote_counts: false,
1275 parallel_safe: false,
1276 }))
1277 .with(Arc::new(LimitTool {
1278 name: "browser_navigate",
1279 counts: true,
1280 vote_counts: true,
1281 parallel_safe: true,
1282 }))
1283 .with(Arc::new(LimitTool {
1284 name: "browser_capture",
1285 counts: true,
1286 vote_counts: true,
1287 parallel_safe: true,
1288 }))
1289 .with(Arc::new(LimitTool {
1290 name: "browser_inspect",
1291 counts: true,
1292 vote_counts: true,
1293 parallel_safe: true,
1294 }))
1295 .with(Arc::new(LimitTool {
1296 name: "shell",
1297 counts: true,
1298 vote_counts: true,
1299 parallel_safe: false,
1300 }))
1301 .with(Arc::new(LimitTool {
1302 name: "message_result",
1303 counts: true,
1304 vote_counts: true,
1305 parallel_safe: false,
1306 }))
1307 .with(Arc::new(LimitTool {
1308 name: "message_ask",
1309 counts: true,
1310 vote_counts: true,
1311 parallel_safe: false,
1312 }))
1313 .with(Arc::new(LimitTool {
1314 name: "web_search",
1315 counts: true,
1316 vote_counts: true,
1317 parallel_safe: true,
1318 }))
1319 .with(Arc::new(LimitTool {
1320 name: "file_read",
1321 counts: true,
1322 vote_counts: true,
1323 parallel_safe: true,
1324 }))
1325 }
1326
1327 fn call(name: &str) -> ToolCall {
1328 ToolCall {
1329 id: format!("tc-{name}"),
1330 name: name.to_string(),
1331 arguments: Value::Null,
1332 }
1333 }
1334
1335 fn names(calls: &[ToolCall]) -> Vec<&str> {
1336 calls.iter().map(|call| call.name.as_str()).collect()
1337 }
1338
1339 #[test]
1340 fn progress_only_tools_do_not_starve_first_work_tool() {
1341 let registry = registry();
1342 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1343 vec![call("message_info"), call("browser_navigate")],
1344 ®istry,
1345 Some(1),
1346 );
1347
1348 assert_eq!(max, Some(1));
1349 assert_eq!(names(&executable), vec!["message_info", "browser_navigate"]);
1350 assert!(unexecuted.is_empty());
1351 }
1352
1353 #[test]
1354 fn extra_limit_counted_tools_still_get_synthetic_errors() {
1355 let registry = registry();
1356 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1357 vec![call("message_info"), call("shell"), call("message_result")],
1358 ®istry,
1359 Some(1),
1360 );
1361
1362 assert_eq!(max, Some(1));
1363 assert_eq!(names(&executable), vec!["message_info", "shell"]);
1364 assert_eq!(names(&unexecuted), vec!["message_result"]);
1365 }
1366
1367 #[test]
1368 fn parallel_safe_reads_do_not_burn_the_per_turn_cap() {
1369 let registry = registry();
1374 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1375 vec![
1376 call("web_search"),
1377 call("web_search"),
1378 call("browser_navigate"),
1379 ],
1380 ®istry,
1381 Some(1),
1382 );
1383
1384 assert_eq!(max, Some(1));
1385 assert_eq!(
1386 names(&executable),
1387 vec!["web_search", "web_search", "browser_navigate"]
1388 );
1389 assert!(
1390 unexecuted.is_empty(),
1391 "unexecuted: {:?}",
1392 names(&unexecuted)
1393 );
1394 }
1395
1396 #[test]
1397 fn parallel_safe_reads_do_not_compete_with_a_write_for_the_cap() {
1398 let registry = registry();
1401 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1402 vec![
1403 call("file_read"),
1404 call("file_read"),
1405 call("shell"),
1406 call("shell"),
1407 ],
1408 ®istry,
1409 Some(1),
1410 );
1411
1412 assert_eq!(max, Some(1));
1413 assert_eq!(names(&executable), vec!["file_read", "file_read", "shell"]);
1414 assert_eq!(names(&unexecuted), vec!["shell"]);
1415 }
1416
1417 #[test]
1418 fn browser_tools_do_not_burn_the_per_turn_cap() {
1419 let registry = registry();
1426 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1427 vec![
1428 call("browser_navigate"),
1429 call("browser_navigate"),
1430 call("browser_capture"),
1431 call("browser_inspect"),
1432 call("shell"),
1433 ],
1434 ®istry,
1435 Some(1),
1436 );
1437
1438 assert_eq!(max, Some(1));
1439 assert_eq!(
1440 names(&executable),
1441 vec![
1442 "browser_navigate",
1443 "browser_navigate",
1444 "browser_capture",
1445 "browser_inspect",
1446 "shell",
1447 ]
1448 );
1449 assert!(
1450 unexecuted.is_empty(),
1451 "unexecuted: {:?}",
1452 names(&unexecuted)
1453 );
1454 }
1455
1456 #[test]
1457 fn malformed_calls_do_not_burn_the_cap_or_preempt_real_work() {
1458 let registry = registry();
1466
1467 let (executable, unexecuted, _) = split_tool_calls_for_execution(
1469 vec![call("missing"), call("shell")],
1470 ®istry,
1471 Some(1),
1472 );
1473 assert_eq!(names(&executable), vec!["missing", "shell"]);
1474 assert!(
1475 unexecuted.is_empty(),
1476 "real work must not be preempted by an unknown name: {:?}",
1477 names(&unexecuted)
1478 );
1479
1480 let (executable, unexecuted, _) =
1482 split_tool_calls_for_execution(vec![call(""), call("shell")], ®istry, Some(1));
1483 assert_eq!(names(&executable), vec!["", "shell"]);
1484 assert!(
1485 unexecuted.is_empty(),
1486 "empty-name glitch must not preempt real work: {:?}",
1487 names(&unexecuted)
1488 );
1489
1490 let (executable, unexecuted, _) =
1492 split_tool_calls_for_execution(vec![call("shell"), call("shell")], ®istry, Some(1));
1493 assert_eq!(names(&executable), vec!["shell"]);
1494 assert_eq!(names(&unexecuted), vec!["shell"]);
1495 }
1496
1497 #[test]
1498 fn compute_batch_terminate_passes_when_only_advisory_siblings_dont_vote() {
1499 let registry = registry();
1507 let votes = [("message_result", true), ("message_info", false)];
1508 assert!(compute_batch_terminate(
1509 ®istry,
1510 votes.iter().map(|(n, t)| (*n, *t))
1511 ));
1512 }
1513
1514 #[test]
1515 fn compute_batch_terminate_fails_when_any_counted_tool_did_not_vote_terminate() {
1516 let registry = registry();
1517 let votes = [("message_result", true), ("shell", false)];
1520 assert!(!compute_batch_terminate(
1521 ®istry,
1522 votes.iter().map(|(n, t)| (*n, *t))
1523 ));
1524 }
1525
1526 #[test]
1527 fn compute_batch_terminate_returns_false_for_all_advisory_batches() {
1528 let registry = registry();
1532 let votes = [("message_info", false), ("message_info", false)];
1533 assert!(!compute_batch_terminate(
1534 ®istry,
1535 votes.iter().map(|(n, t)| (*n, *t))
1536 ));
1537 }
1538
1539 #[test]
1540 fn compute_batch_terminate_returns_false_for_empty_batch() {
1541 let registry = registry();
1542 let votes: Vec<(&str, bool)> = Vec::new();
1543 assert!(!compute_batch_terminate(®istry, votes.into_iter()));
1544 }
1545
1546 #[test]
1547 fn compute_batch_terminate_treats_unknown_tools_as_counted() {
1548 let registry = registry();
1552 let votes = [("message_result", true), ("ghost_tool", false)];
1555 assert!(!compute_batch_terminate(
1556 ®istry,
1557 votes.iter().map(|(n, t)| (*n, *t))
1558 ));
1559
1560 let votes = [("message_result", true), ("ghost_tool", true)];
1564 assert!(compute_batch_terminate(
1565 ®istry,
1566 votes.iter().map(|(n, t)| (*n, *t))
1567 ));
1568 }
1569
1570 #[test]
1571 fn compute_batch_terminate_passes_when_message_ask_is_only_counted_terminator() {
1572 let registry = registry();
1575 let votes = [("message_ask", true), ("message_info", false)];
1576 assert!(compute_batch_terminate(
1577 ®istry,
1578 votes.iter().map(|(n, t)| (*n, *t))
1579 ));
1580 }
1581
1582 #[test]
1583 fn head_tail_for_log_returns_full_text_when_short() {
1584 let (head, tail) = head_tail_for_log("hello");
1589 assert_eq!(head, "hello");
1590 assert_eq!(tail, "");
1591 }
1592
1593 #[test]
1594 fn head_tail_for_log_truncates_long_text_with_head_and_tail() {
1595 let payload: String = "abc".repeat(500);
1596 assert!(payload.len() > TOOL_RESULT_LOG_HEAD + TOOL_RESULT_LOG_TAIL);
1597 let (head, tail) = head_tail_for_log(&payload);
1598 assert_eq!(head.len(), TOOL_RESULT_LOG_HEAD);
1599 assert_eq!(tail.len(), TOOL_RESULT_LOG_TAIL);
1600 assert!(payload.starts_with(&head));
1604 assert!(payload.ends_with(&tail));
1605 }
1606
1607 #[test]
1608 fn head_tail_for_log_respects_utf8_char_boundaries() {
1609 let mid = "πλάκα".repeat(50); let prefix: String = "x".repeat(150);
1615 let suffix: String = "y".repeat(150);
1616 let payload = format!("{prefix}{mid}{suffix}");
1617 let (head, tail) = head_tail_for_log(&payload);
1618 assert!(payload.starts_with(&head));
1623 assert!(payload.ends_with(&tail));
1624 assert!(head.len() <= TOOL_RESULT_LOG_HEAD);
1626 assert!(tail.len() <= TOOL_RESULT_LOG_TAIL + 1); }
1628
1629 #[test]
1630 fn unexecuted_message_mentions_progress_only_calls_when_present() {
1631 let result = unexecuted_tool_call_result(3, 2, 1);
1632 let text = match result.content.first() {
1633 Some(ToolResultBlock::Text(text)) => text.text.as_str(),
1634 _ => panic!("expected text result"),
1635 };
1636
1637 assert!(text.contains("2 limit-counted tool calls"));
1638 assert!(text.contains("3 tool calls total, including progress-only calls"));
1639 assert_eq!(
1640 result
1641 .details
1642 .get("limit_counted_tool_calls")
1643 .and_then(Value::as_u64),
1644 Some(2)
1645 );
1646 }
1647}