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 for call in tool_calls {
433 let outcome = run_one(
434 assistant,
435 assistant_content,
436 &call,
437 context,
438 config,
439 signal,
440 turn_allowlist,
441 )
442 .await?;
443 votes.push((call.name.clone(), outcome.terminate));
444 messages.push(outcome_to_message(&call, outcome));
445 }
446
447 let terminate =
448 compute_batch_terminate(&config.tools, votes.iter().map(|(n, t)| (n.as_str(), *t)));
449
450 Ok(ExecutedBatch {
451 messages,
452 terminate,
453 })
454}
455
456#[allow(clippy::too_many_arguments)]
457async fn execute_parallel(
458 assistant: &AgentMessage,
459 assistant_content: &AssistantContent,
460 tool_calls: Vec<ToolCall>,
461 context: &AgentContext,
462 config: &LoopConfig,
463 signal: &CancellationToken,
464 turn_allowlist: Option<&std::collections::HashSet<String>>,
465) -> Result<ExecutedBatch, LoopError> {
466 use futures::stream::{FuturesUnordered, StreamExt};
467
468 let batch_token = signal.child_token();
476
477 let mut prepared: Vec<(ToolCall, PreparedCall)> = Vec::with_capacity(tool_calls.len());
481 for call in tool_calls {
482 let prep = prepare_call(
483 assistant,
484 assistant_content,
485 &call,
486 context,
487 config,
488 turn_allowlist,
489 )
490 .await;
491 if matches!(prep, PreparedCall::Prepared { .. }) {
492 emit_tool_start(config, &call).await;
493 }
494 prepared.push((call, prep));
495 }
496
497 let mut futures = Vec::with_capacity(prepared.len());
498 let mut immediate: Vec<(usize, ToolCall, FinalizedOutcome)> = Vec::new();
499
500 for (idx, (call, prep)) in prepared.into_iter().enumerate() {
501 match prep {
502 PreparedCall::Immediate(executed) => {
503 let finalized = finalize(
511 assistant,
512 assistant_content,
513 &call,
514 &call.arguments,
515 executed,
516 &context.messages,
517 &config.plugins.after_tool_call,
518 )
519 .await;
520 immediate.push((idx, call, finalized));
521 }
522 PreparedCall::Prepared { tool, args } => {
523 let tool_signal = batch_token.child_token();
524 let run_signal = signal.clone();
525 let batch_token_clone = batch_token.clone();
526 let assistant_clone = assistant.clone();
527 let assistant_content_clone = assistant_content.clone();
528 let context_messages = context.messages.clone();
529 let after_hooks = config.plugins.after_tool_call.clone();
530 let event_sink = config.event_sink.clone();
531 let event_observers = config.plugins.event_observer.clone();
532 let call_clone = call.clone();
533 let fut = async move {
534 let id = call_clone.id.clone();
535 let name = call_clone.name.clone();
536 let name_for_message = name.clone();
537 let update_events = spawn_tool_update_dispatcher(event_sink, event_observers);
538 let executed_result = execute_prepared(
539 &tool,
540 &call_clone,
541 args.clone(),
542 tool_signal,
543 Box::new(move |update| {
544 let event = AgentEvent::ToolExecutionUpdate {
545 tool_call_id: id.clone(),
546 tool_name: name.clone(),
547 partial: update,
548 };
549 enqueue_tool_update_event(&update_events, event);
550 }),
551 )
552 .await;
553 let executed = match executed_result {
554 Ok(executed) => executed,
555 Err(LoopError::Aborted)
556 if batch_token_clone.is_cancelled() && !run_signal.is_cancelled() =>
557 {
558 ExecutedOutcome {
564 result: ToolResult::error(format!(
565 "aborted because a sibling tool in the \
566 parallel batch errored — re-run this \
567 {name_for_message} call after addressing the \
568 sibling failure"
569 )),
570 is_error: true,
571 }
572 }
573 Err(other) => return Err(other),
574 };
575 let finalized = finalize(
576 &assistant_clone,
577 &assistant_content_clone,
578 &call_clone,
579 &args,
580 executed,
581 &context_messages,
582 &after_hooks,
583 )
584 .await;
585 Ok::<_, LoopError>((idx, call_clone, finalized))
586 };
587 futures.push(fut);
588 }
589 }
590 }
591
592 let mut unordered: FuturesUnordered<_> = futures.into_iter().collect();
599 let mut completed: Vec<(usize, ToolCall, FinalizedOutcome)> =
600 Vec::with_capacity(unordered.len() + immediate.len());
601 while let Some(result) = unordered.next().await {
602 let entry = result?;
603 if entry.2.is_error {
604 let aborts = config
605 .tools
606 .get(&entry.1.name)
607 .map(|t| t.aborts_siblings_on_error())
608 .unwrap_or(false);
609 if aborts && !batch_token.is_cancelled() {
610 batch_token.cancel();
611 }
612 }
613 completed.push(entry);
614 }
615 completed.extend(immediate);
616 completed.sort_by_key(|(idx, _, _)| *idx);
617
618 let mut messages = Vec::with_capacity(completed.len());
619 let mut votes: Vec<(String, bool)> = Vec::with_capacity(completed.len());
620 for (_idx, call, outcome) in completed {
621 emit_tool_end(config, &call, &outcome).await;
622 votes.push((call.name.clone(), outcome.terminate));
623 messages.push(outcome_to_message(&call, outcome));
624 }
625
626 let terminate =
627 compute_batch_terminate(&config.tools, votes.iter().map(|(n, t)| (n.as_str(), *t)));
628
629 Ok(ExecutedBatch {
630 messages,
631 terminate,
632 })
633}
634
635#[allow(clippy::too_many_arguments)]
638async fn run_one(
639 assistant: &AgentMessage,
640 assistant_content: &AssistantContent,
641 call: &ToolCall,
642 context: &AgentContext,
643 config: &LoopConfig,
644 signal: &CancellationToken,
645 turn_allowlist: Option<&std::collections::HashSet<String>>,
646) -> Result<FinalizedOutcome, LoopError> {
647 let prep = prepare_call(
648 assistant,
649 assistant_content,
650 call,
651 context,
652 config,
653 turn_allowlist,
654 )
655 .await;
656 let outcome = match prep {
657 PreparedCall::Immediate(executed) => {
658 finalize(
659 assistant,
660 assistant_content,
661 call,
662 &call.arguments,
663 executed,
664 &context.messages,
665 &config.plugins.after_tool_call,
666 )
667 .await
668 }
669 PreparedCall::Prepared { tool, args } => {
670 emit_tool_start(config, call).await;
671 let event_sink = config.event_sink.clone();
672 let event_observers = config.plugins.event_observer.clone();
673 let id = call.id.clone();
674 let name = call.name.clone();
675 let update_events = spawn_tool_update_dispatcher(event_sink, event_observers);
676 let executed = execute_prepared(
677 &tool,
678 call,
679 args.clone(),
680 signal.clone(),
681 Box::new(move |update| {
682 let event = AgentEvent::ToolExecutionUpdate {
683 tool_call_id: id.clone(),
684 tool_name: name.clone(),
685 partial: update,
686 };
687 enqueue_tool_update_event(&update_events, event);
688 }),
689 )
690 .await?;
691 finalize(
692 assistant,
693 assistant_content,
694 call,
695 &args,
696 executed,
697 &context.messages,
698 &config.plugins.after_tool_call,
699 )
700 .await
701 }
702 };
703
704 emit_tool_end(config, call, &outcome).await;
705 Ok(outcome)
706}
707
708enum PreparedCall {
711 Immediate(ExecutedOutcome),
717 Prepared {
719 tool: Arc<dyn AgentTool>,
720 args: Value,
721 },
722}
723
724struct ExecutedOutcome {
725 result: ToolResult,
726 is_error: bool,
727}
728
729pub(crate) struct FinalizedOutcome {
730 pub result: ToolResult,
731 pub is_error: bool,
732 pub terminate: bool,
733}
734
735struct GateDenial {
741 reason: String,
742 gate: &'static str,
743}
744
745async fn gate_attributed_denial(
746 tool_name: &str,
747 config: &LoopConfig,
748 messages: &[AgentMessage],
749) -> Option<GateDenial> {
750 let available_tool_names: Vec<&str> = config.tools.iter().map(|t| t.name()).collect();
751 let iteration = messages
752 .iter()
753 .filter(|m| matches!(m, AgentMessage::Assistant { .. }))
754 .count();
755 for gate in &config.plugins.tool_gate {
756 let ctx = crate::plugin::ToolGateContext {
757 iteration,
758 messages,
759 conversation_id: config.conversation_id.as_deref(),
760 available_tool_names: &available_tool_names,
761 };
762 if let Some(reason) = gate.denial_reason(tool_name, ctx).await {
763 return Some(GateDenial {
764 reason,
765 gate: gate.name(),
766 });
767 }
768 }
769 None
770}
771
772async fn prepare_call(
773 assistant: &AgentMessage,
774 assistant_content: &AssistantContent,
775 call: &ToolCall,
776 context: &AgentContext,
777 config: &LoopConfig,
778 turn_allowlist: Option<&std::collections::HashSet<String>>,
779) -> PreparedCall {
780 let Some(tool) = config.tools.get(&call.name) else {
781 return PreparedCall::Immediate(ExecutedOutcome {
782 result: ToolResult::error(format!("Tool `{}` not found", call.name)),
783 is_error: true,
784 });
785 };
786
787 if let Some(allowlist) = turn_allowlist {
802 if !allowlist.contains(call.name.as_str()) {
803 let attributed = gate_attributed_denial(&call.name, config, &context.messages).await;
804 let (message, details) =
805 match attributed {
806 Some(denial) => {
807 let details = crate::protocol::generic_hidden_tool_details(
808 &call.name,
809 allowlist,
810 Some(denial.gate),
811 );
812 (denial.reason, details)
813 }
814 None => match config.protocol.hidden_tool_error(
815 crate::protocol::HiddenToolContext {
816 requested_tool: &call.name,
817 allowlist,
818 messages: &context.messages,
819 },
820 ) {
821 Some(err) => (err.message, err.details),
822 None => (
823 crate::protocol::generic_hidden_tool_message(&call.name, allowlist),
824 crate::protocol::generic_hidden_tool_details(
825 &call.name, allowlist, None,
826 ),
827 ),
828 },
829 };
830 let mut result = ToolResult::error(message);
831 result.details = details;
832 return PreparedCall::Immediate(ExecutedOutcome {
833 result,
834 is_error: true,
835 });
836 }
837 }
838
839 if let Some((parse_err, raw)) = detect_arg_parse_error(&call.arguments) {
846 return PreparedCall::Immediate(ExecutedOutcome {
847 result: ToolResult::error(format_arg_parse_error(&call.name, parse_err, raw)),
848 is_error: true,
849 });
850 }
851
852 let prepared_args = tool.prepare_arguments(call.arguments.clone());
853
854 if let Err(err) = tool.validate(&prepared_args) {
855 return PreparedCall::Immediate(ExecutedOutcome {
856 result: ToolResult::error(err.to_string()),
857 is_error: true,
858 });
859 }
860
861 let ctx = BeforeToolCallContext {
862 assistant_message: assistant,
863 assistant_content,
864 tool_call: call,
865 args: &prepared_args,
866 messages: &context.messages,
867 };
868 for hook in &config.plugins.before_tool_call {
869 let decision = hook
870 .on_before_tool_call(BeforeToolCallContext {
871 assistant_message: ctx.assistant_message,
872 assistant_content: ctx.assistant_content,
873 tool_call: ctx.tool_call,
874 args: ctx.args,
875 messages: ctx.messages,
876 })
877 .await;
878 if decision.block {
879 let reason = decision
880 .reason
881 .unwrap_or_else(|| format!("blocked by {}", hook.name()));
882 let mut result = ToolResult::error(reason);
883 if let Some(details) = decision.details {
884 result.details = details;
885 }
886 return PreparedCall::Immediate(ExecutedOutcome {
887 result,
888 is_error: true,
889 });
890 }
891 }
892
893 PreparedCall::Prepared {
894 tool,
895 args: prepared_args,
896 }
897}
898
899async fn execute_prepared(
900 tool: &Arc<dyn AgentTool>,
901 call: &ToolCall,
902 args: Value,
903 signal: CancellationToken,
904 on_update: Box<dyn Fn(ToolResult) + Send + Sync + 'static>,
905) -> Result<ExecutedOutcome, LoopError> {
906 let (tx, mut rx) = mpsc::unbounded_channel::<ToolResult>();
907
908 let mut drain_handle = tokio::spawn(async move {
910 while let Some(partial) = rx.recv().await {
911 on_update(partial);
912 }
913 });
914
915 let result = match tool.execute(&call.id, args, signal, tx).await {
916 Ok(result) => {
917 let is_error = result.is_error;
918 Ok(ExecutedOutcome { result, is_error })
919 }
920 Err(ToolError::Execution(reason)) => Ok(ExecutedOutcome {
921 result: ToolResult::error(ToolError::Execution(reason).to_string()),
922 is_error: true,
923 }),
924 Err(ToolError::Aborted) => Err(LoopError::Aborted),
925 Err(ToolError::Fatal(reason)) => Err(LoopError::ToolFatal {
926 tool: call.name.clone(),
927 reason,
928 }),
929 };
930
931 match timeout(TOOL_UPDATE_DRAIN_GRACE, &mut drain_handle).await {
932 Ok(joined) => {
933 if let Err(error) = joined {
934 tracing::debug!(?error, "tool update dispatcher join failed");
935 }
936 }
937 Err(_) => {
938 drain_handle.abort();
939 if let Err(error) = drain_handle.await {
940 tracing::debug!(?error, "aborted tool update dispatcher");
941 }
942 }
943 }
944 result
945}
946
947#[allow(clippy::too_many_arguments)]
948async fn finalize(
949 assistant: &AgentMessage,
950 _assistant_content: &AssistantContent,
951 call: &ToolCall,
952 args: &Value,
953 mut executed: ExecutedOutcome,
954 messages: &[AgentMessage],
955 after_hooks: &[Arc<dyn crate::plugin::AfterToolCall>],
956) -> FinalizedOutcome {
957 for hook in after_hooks {
958 let ctx = AfterToolCallContext {
959 assistant_message: assistant,
960 tool_call: call,
961 args,
962 result: &executed.result,
963 is_error: executed.is_error,
964 messages,
965 };
966 let decision = hook.on_after_tool_call(ctx).await;
967 if let Some(new_result) = decision.result {
968 executed.is_error = new_result.is_error;
969 executed.result = new_result;
970 }
971 if let Some(mark_error) = decision.mark_error {
972 executed.is_error = mark_error;
973 executed.result.is_error = mark_error;
974 }
975 if let Some(terminate) = decision.terminate {
976 executed.result.terminate = terminate;
977 }
978 }
979
980 FinalizedOutcome {
981 result: executed.result,
982 is_error: executed.is_error,
983 terminate: false,
984 }
985 .with_vote()
988}
989
990impl FinalizedOutcome {
991 fn with_vote(mut self) -> Self {
992 self.terminate = self.result.terminate;
993 self
994 }
995}
996
997fn outcome_to_message(call: &ToolCall, outcome: FinalizedOutcome) -> AgentMessage {
998 let details = match outcome.result.details {
999 serde_json::Value::Null => None,
1000 other => Some(other),
1001 };
1002 let message = AgentMessage::ToolResult {
1003 tool_call_id: call.id.clone(),
1004 tool_name: call.name.clone(),
1005 content: ToolResultContent {
1006 blocks: outcome.result.content,
1007 },
1008 is_error: outcome.is_error,
1009 narration: outcome.result.narration,
1015 details,
1020 timestamp: Some(now_ms()),
1021 };
1022 if let AgentMessage::ToolResult {
1030 content,
1031 is_error,
1032 tool_call_id,
1033 tool_name,
1034 ..
1035 } = &message
1036 {
1037 let plain = content.plain_text();
1038 let (head, tail) = head_tail_for_log(&plain);
1039 tracing::debug!(
1040 target: "clark_agent::exec::tool_result_built",
1041 tool_call_id = %tool_call_id,
1042 tool_name = %tool_name,
1043 is_error = *is_error,
1044 content_len = plain.len(),
1045 content_head = %head,
1046 content_tail = %tail,
1047 "outcome_to_message wrote ToolResult into messages"
1048 );
1049 }
1050 message
1051}
1052
1053const TOOL_RESULT_LOG_HEAD: usize = 200;
1054const TOOL_RESULT_LOG_TAIL: usize = 200;
1055
1056fn head_tail_for_log(text: &str) -> (String, String) {
1061 if text.len() <= TOOL_RESULT_LOG_HEAD + TOOL_RESULT_LOG_TAIL {
1062 return (text.to_string(), String::new());
1063 }
1064 let head_end = char_boundary_at_or_before(text, TOOL_RESULT_LOG_HEAD);
1065 let tail_start = char_boundary_at_or_after(text, text.len() - TOOL_RESULT_LOG_TAIL);
1066 (text[..head_end].to_string(), text[tail_start..].to_string())
1067}
1068
1069fn char_boundary_at_or_before(text: &str, mut idx: usize) -> usize {
1070 if idx >= text.len() {
1071 return text.len();
1072 }
1073 while idx > 0 && !text.is_char_boundary(idx) {
1074 idx -= 1;
1075 }
1076 idx
1077}
1078
1079fn char_boundary_at_or_after(text: &str, mut idx: usize) -> usize {
1080 if idx >= text.len() {
1081 return text.len();
1082 }
1083 while idx < text.len() && !text.is_char_boundary(idx) {
1084 idx += 1;
1085 }
1086 idx
1087}
1088
1089fn now_ms() -> u64 {
1090 SystemTime::now()
1091 .duration_since(UNIX_EPOCH)
1092 .map(|d| d.as_millis() as u64)
1093 .unwrap_or(0)
1094}
1095
1096async fn emit_tool_start(config: &LoopConfig, call: &ToolCall) {
1097 let event = AgentEvent::ToolExecutionStart {
1098 tool_call_id: call.id.clone(),
1099 tool_name: call.name.clone(),
1100 args: call.arguments.clone(),
1101 };
1102 config.event_sink.emit(event.clone()).await;
1103 for o in &config.plugins.event_observer {
1104 o.on_event(&event).await;
1105 }
1106}
1107
1108fn format_arg_parse_error(tool_name: &str, parse_err: &str, raw: &str) -> String {
1114 const RAW_MAX: usize = 1024;
1115 let raw_snippet = if raw.len() > RAW_MAX {
1116 format!(
1117 "{}…<{} bytes truncated>",
1118 &raw[..RAW_MAX],
1119 raw.len() - RAW_MAX
1120 )
1121 } else {
1122 raw.to_string()
1123 };
1124 format!(
1125 "Tool `{tool_name}` arguments were not valid JSON: {parse_err}. \
1126 You sent (raw): {raw_snippet}. \
1127 Re-emit the call with a JSON object matching the tool's schema; \
1128 this is a syntax error in your tool-call arguments, not a problem \
1129 with the file or the runtime."
1130 )
1131}
1132
1133async fn emit_tool_end(config: &LoopConfig, call: &ToolCall, outcome: &FinalizedOutcome) {
1134 let event = AgentEvent::ToolExecutionEnd {
1135 tool_call_id: call.id.clone(),
1136 tool_name: call.name.clone(),
1137 result: outcome.result.clone(),
1138 is_error: outcome.is_error,
1139 };
1140 config.event_sink.emit(event.clone()).await;
1141 for o in &config.plugins.event_observer {
1142 o.on_event(&event).await;
1143 }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149 use crate::ToolResultBlock;
1150 use std::sync::Arc;
1151
1152 struct LimitTool {
1153 name: &'static str,
1154 counts: bool,
1155 vote_counts: bool,
1156 parallel_safe: bool,
1157 }
1158
1159 #[async_trait::async_trait]
1160 impl AgentTool for LimitTool {
1161 fn name(&self) -> &str {
1162 self.name
1163 }
1164
1165 fn description(&self) -> &str {
1166 "test tool"
1167 }
1168
1169 fn parameters_schema(&self) -> Value {
1170 json!({"type": "object"})
1171 }
1172
1173 fn counts_toward_tool_call_limit(&self) -> bool {
1174 self.counts
1175 }
1176
1177 fn parallel_safe_per_turn(&self) -> bool {
1178 self.parallel_safe
1179 }
1180
1181 fn counts_toward_termination_vote(&self) -> bool {
1182 self.vote_counts
1183 }
1184
1185 async fn execute(
1186 &self,
1187 _call_id: &str,
1188 _args: Value,
1189 _signal: CancellationToken,
1190 _update: mpsc::UnboundedSender<ToolResult>,
1191 ) -> Result<ToolResult, ToolError> {
1192 unreachable!("split tests do not execute tools")
1193 }
1194 }
1195
1196 fn registry() -> crate::tool::ToolRegistry {
1197 crate::tool::ToolRegistry::new()
1201 .with(Arc::new(LimitTool {
1202 name: "message_info",
1203 counts: false,
1204 vote_counts: false,
1205 parallel_safe: false,
1206 }))
1207 .with(Arc::new(LimitTool {
1208 name: "browser_navigate",
1209 counts: true,
1210 vote_counts: true,
1211 parallel_safe: true,
1212 }))
1213 .with(Arc::new(LimitTool {
1214 name: "browser_capture",
1215 counts: true,
1216 vote_counts: true,
1217 parallel_safe: true,
1218 }))
1219 .with(Arc::new(LimitTool {
1220 name: "browser_inspect",
1221 counts: true,
1222 vote_counts: true,
1223 parallel_safe: true,
1224 }))
1225 .with(Arc::new(LimitTool {
1226 name: "shell",
1227 counts: true,
1228 vote_counts: true,
1229 parallel_safe: false,
1230 }))
1231 .with(Arc::new(LimitTool {
1232 name: "message_result",
1233 counts: true,
1234 vote_counts: true,
1235 parallel_safe: false,
1236 }))
1237 .with(Arc::new(LimitTool {
1238 name: "message_ask",
1239 counts: true,
1240 vote_counts: true,
1241 parallel_safe: false,
1242 }))
1243 .with(Arc::new(LimitTool {
1244 name: "web_search",
1245 counts: true,
1246 vote_counts: true,
1247 parallel_safe: true,
1248 }))
1249 .with(Arc::new(LimitTool {
1250 name: "file_read",
1251 counts: true,
1252 vote_counts: true,
1253 parallel_safe: true,
1254 }))
1255 }
1256
1257 fn call(name: &str) -> ToolCall {
1258 ToolCall {
1259 id: format!("tc-{name}"),
1260 name: name.to_string(),
1261 arguments: Value::Null,
1262 }
1263 }
1264
1265 fn names(calls: &[ToolCall]) -> Vec<&str> {
1266 calls.iter().map(|call| call.name.as_str()).collect()
1267 }
1268
1269 #[test]
1270 fn progress_only_tools_do_not_starve_first_work_tool() {
1271 let registry = registry();
1272 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1273 vec![call("message_info"), call("browser_navigate")],
1274 ®istry,
1275 Some(1),
1276 );
1277
1278 assert_eq!(max, Some(1));
1279 assert_eq!(names(&executable), vec!["message_info", "browser_navigate"]);
1280 assert!(unexecuted.is_empty());
1281 }
1282
1283 #[test]
1284 fn extra_limit_counted_tools_still_get_synthetic_errors() {
1285 let registry = registry();
1286 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1287 vec![call("message_info"), call("shell"), call("message_result")],
1288 ®istry,
1289 Some(1),
1290 );
1291
1292 assert_eq!(max, Some(1));
1293 assert_eq!(names(&executable), vec!["message_info", "shell"]);
1294 assert_eq!(names(&unexecuted), vec!["message_result"]);
1295 }
1296
1297 #[test]
1298 fn parallel_safe_reads_do_not_burn_the_per_turn_cap() {
1299 let registry = registry();
1304 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1305 vec![
1306 call("web_search"),
1307 call("web_search"),
1308 call("browser_navigate"),
1309 ],
1310 ®istry,
1311 Some(1),
1312 );
1313
1314 assert_eq!(max, Some(1));
1315 assert_eq!(
1316 names(&executable),
1317 vec!["web_search", "web_search", "browser_navigate"]
1318 );
1319 assert!(
1320 unexecuted.is_empty(),
1321 "unexecuted: {:?}",
1322 names(&unexecuted)
1323 );
1324 }
1325
1326 #[test]
1327 fn parallel_safe_reads_do_not_compete_with_a_write_for_the_cap() {
1328 let registry = registry();
1331 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1332 vec![
1333 call("file_read"),
1334 call("file_read"),
1335 call("shell"),
1336 call("shell"),
1337 ],
1338 ®istry,
1339 Some(1),
1340 );
1341
1342 assert_eq!(max, Some(1));
1343 assert_eq!(names(&executable), vec!["file_read", "file_read", "shell"]);
1344 assert_eq!(names(&unexecuted), vec!["shell"]);
1345 }
1346
1347 #[test]
1348 fn browser_tools_do_not_burn_the_per_turn_cap() {
1349 let registry = registry();
1356 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1357 vec![
1358 call("browser_navigate"),
1359 call("browser_navigate"),
1360 call("browser_capture"),
1361 call("browser_inspect"),
1362 call("shell"),
1363 ],
1364 ®istry,
1365 Some(1),
1366 );
1367
1368 assert_eq!(max, Some(1));
1369 assert_eq!(
1370 names(&executable),
1371 vec![
1372 "browser_navigate",
1373 "browser_navigate",
1374 "browser_capture",
1375 "browser_inspect",
1376 "shell",
1377 ]
1378 );
1379 assert!(
1380 unexecuted.is_empty(),
1381 "unexecuted: {:?}",
1382 names(&unexecuted)
1383 );
1384 }
1385
1386 #[test]
1387 fn malformed_calls_do_not_burn_the_cap_or_preempt_real_work() {
1388 let registry = registry();
1396
1397 let (executable, unexecuted, _) = split_tool_calls_for_execution(
1399 vec![call("missing"), call("shell")],
1400 ®istry,
1401 Some(1),
1402 );
1403 assert_eq!(names(&executable), vec!["missing", "shell"]);
1404 assert!(
1405 unexecuted.is_empty(),
1406 "real work must not be preempted by an unknown name: {:?}",
1407 names(&unexecuted)
1408 );
1409
1410 let (executable, unexecuted, _) =
1412 split_tool_calls_for_execution(vec![call(""), call("shell")], ®istry, Some(1));
1413 assert_eq!(names(&executable), vec!["", "shell"]);
1414 assert!(
1415 unexecuted.is_empty(),
1416 "empty-name glitch must not preempt real work: {:?}",
1417 names(&unexecuted)
1418 );
1419
1420 let (executable, unexecuted, _) =
1422 split_tool_calls_for_execution(vec![call("shell"), call("shell")], ®istry, Some(1));
1423 assert_eq!(names(&executable), vec!["shell"]);
1424 assert_eq!(names(&unexecuted), vec!["shell"]);
1425 }
1426
1427 #[test]
1428 fn compute_batch_terminate_passes_when_only_advisory_siblings_dont_vote() {
1429 let registry = registry();
1437 let votes = [("message_result", true), ("message_info", false)];
1438 assert!(compute_batch_terminate(
1439 ®istry,
1440 votes.iter().map(|(n, t)| (*n, *t))
1441 ));
1442 }
1443
1444 #[test]
1445 fn compute_batch_terminate_fails_when_any_counted_tool_did_not_vote_terminate() {
1446 let registry = registry();
1447 let votes = [("message_result", true), ("shell", false)];
1450 assert!(!compute_batch_terminate(
1451 ®istry,
1452 votes.iter().map(|(n, t)| (*n, *t))
1453 ));
1454 }
1455
1456 #[test]
1457 fn compute_batch_terminate_returns_false_for_all_advisory_batches() {
1458 let registry = registry();
1462 let votes = [("message_info", false), ("message_info", false)];
1463 assert!(!compute_batch_terminate(
1464 ®istry,
1465 votes.iter().map(|(n, t)| (*n, *t))
1466 ));
1467 }
1468
1469 #[test]
1470 fn compute_batch_terminate_returns_false_for_empty_batch() {
1471 let registry = registry();
1472 let votes: Vec<(&str, bool)> = Vec::new();
1473 assert!(!compute_batch_terminate(®istry, votes.into_iter()));
1474 }
1475
1476 #[test]
1477 fn compute_batch_terminate_treats_unknown_tools_as_counted() {
1478 let registry = registry();
1482 let votes = [("message_result", true), ("ghost_tool", false)];
1485 assert!(!compute_batch_terminate(
1486 ®istry,
1487 votes.iter().map(|(n, t)| (*n, *t))
1488 ));
1489
1490 let votes = [("message_result", true), ("ghost_tool", true)];
1494 assert!(compute_batch_terminate(
1495 ®istry,
1496 votes.iter().map(|(n, t)| (*n, *t))
1497 ));
1498 }
1499
1500 #[test]
1501 fn compute_batch_terminate_passes_when_message_ask_is_only_counted_terminator() {
1502 let registry = registry();
1505 let votes = [("message_ask", true), ("message_info", false)];
1506 assert!(compute_batch_terminate(
1507 ®istry,
1508 votes.iter().map(|(n, t)| (*n, *t))
1509 ));
1510 }
1511
1512 #[test]
1513 fn head_tail_for_log_returns_full_text_when_short() {
1514 let (head, tail) = head_tail_for_log("hello");
1519 assert_eq!(head, "hello");
1520 assert_eq!(tail, "");
1521 }
1522
1523 #[test]
1524 fn head_tail_for_log_truncates_long_text_with_head_and_tail() {
1525 let payload: String = "abc".repeat(500);
1526 assert!(payload.len() > TOOL_RESULT_LOG_HEAD + TOOL_RESULT_LOG_TAIL);
1527 let (head, tail) = head_tail_for_log(&payload);
1528 assert_eq!(head.len(), TOOL_RESULT_LOG_HEAD);
1529 assert_eq!(tail.len(), TOOL_RESULT_LOG_TAIL);
1530 assert!(payload.starts_with(&head));
1534 assert!(payload.ends_with(&tail));
1535 }
1536
1537 #[test]
1538 fn head_tail_for_log_respects_utf8_char_boundaries() {
1539 let mid = "πλάκα".repeat(50); let prefix: String = "x".repeat(150);
1545 let suffix: String = "y".repeat(150);
1546 let payload = format!("{prefix}{mid}{suffix}");
1547 let (head, tail) = head_tail_for_log(&payload);
1548 assert!(payload.starts_with(&head));
1553 assert!(payload.ends_with(&tail));
1554 assert!(head.len() <= TOOL_RESULT_LOG_HEAD);
1556 assert!(tail.len() <= TOOL_RESULT_LOG_TAIL + 1); }
1558
1559 #[test]
1560 fn unexecuted_message_mentions_progress_only_calls_when_present() {
1561 let result = unexecuted_tool_call_result(3, 2, 1);
1562 let text = match result.content.first() {
1563 Some(ToolResultBlock::Text(text)) => text.text.as_str(),
1564 _ => panic!("expected text result"),
1565 };
1566
1567 assert!(text.contains("2 limit-counted tool calls"));
1568 assert!(text.contains("3 tool calls total, including progress-only calls"));
1569 assert_eq!(
1570 result
1571 .details
1572 .get("limit_counted_tool_calls")
1573 .and_then(Value::as_u64),
1574 Some(2)
1575 );
1576 }
1577}