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::argument_validation_error(
848 &call.name,
849 format_arg_parse_error(&call.name, parse_err, raw),
850 ),
851 is_error: true,
852 });
853 }
854
855 let prepared_args = tool.prepare_arguments(call.arguments.clone());
856
857 if let Err(err) = tool.validate(&prepared_args) {
858 return PreparedCall::Immediate(ExecutedOutcome {
859 result: ToolResult::argument_validation_error(&call.name, err.to_string()),
860 is_error: true,
861 });
862 }
863
864 let ctx = BeforeToolCallContext {
865 assistant_message: assistant,
866 assistant_content,
867 tool_call: call,
868 args: &prepared_args,
869 messages: &context.messages,
870 };
871 for hook in &config.plugins.before_tool_call {
872 let decision = hook
873 .on_before_tool_call(BeforeToolCallContext {
874 assistant_message: ctx.assistant_message,
875 assistant_content: ctx.assistant_content,
876 tool_call: ctx.tool_call,
877 args: ctx.args,
878 messages: ctx.messages,
879 })
880 .await;
881 if decision.block {
882 let reason = decision
883 .reason
884 .unwrap_or_else(|| format!("blocked by {}", hook.name()));
885 let mut result = ToolResult::error(reason);
886 if let Some(details) = decision.details {
887 result.details = details;
888 }
889 return PreparedCall::Immediate(ExecutedOutcome {
890 result,
891 is_error: true,
892 });
893 }
894 }
895
896 PreparedCall::Prepared {
897 tool,
898 args: prepared_args,
899 }
900}
901
902async fn execute_prepared(
903 tool: &Arc<dyn AgentTool>,
904 call: &ToolCall,
905 args: Value,
906 signal: CancellationToken,
907 on_update: Box<dyn Fn(ToolResult) + Send + Sync + 'static>,
908) -> Result<ExecutedOutcome, LoopError> {
909 let (tx, mut rx) = mpsc::unbounded_channel::<ToolResult>();
910
911 let mut drain_handle = tokio::spawn(async move {
913 while let Some(partial) = rx.recv().await {
914 on_update(partial);
915 }
916 });
917
918 let result = match tool.execute(&call.id, args, signal, tx).await {
919 Ok(result) => {
920 let is_error = result.is_error;
921 Ok(ExecutedOutcome { result, is_error })
922 }
923 Err(ToolError::Execution(reason)) => Ok(ExecutedOutcome {
924 result: ToolResult::error(ToolError::Execution(reason).to_string()),
925 is_error: true,
926 }),
927 Err(ToolError::Aborted) => Err(LoopError::Aborted),
928 Err(ToolError::Fatal(reason)) => Err(LoopError::ToolFatal {
929 tool: call.name.clone(),
930 reason,
931 }),
932 };
933
934 match timeout(TOOL_UPDATE_DRAIN_GRACE, &mut drain_handle).await {
935 Ok(joined) => {
936 if let Err(error) = joined {
937 tracing::debug!(?error, "tool update dispatcher join failed");
938 }
939 }
940 Err(_) => {
941 drain_handle.abort();
942 if let Err(error) = drain_handle.await {
943 tracing::debug!(?error, "aborted tool update dispatcher");
944 }
945 }
946 }
947 result
948}
949
950#[allow(clippy::too_many_arguments)]
951async fn finalize(
952 assistant: &AgentMessage,
953 _assistant_content: &AssistantContent,
954 call: &ToolCall,
955 args: &Value,
956 mut executed: ExecutedOutcome,
957 messages: &[AgentMessage],
958 after_hooks: &[Arc<dyn crate::plugin::AfterToolCall>],
959) -> FinalizedOutcome {
960 for hook in after_hooks {
961 let ctx = AfterToolCallContext {
962 assistant_message: assistant,
963 tool_call: call,
964 args,
965 result: &executed.result,
966 is_error: executed.is_error,
967 messages,
968 };
969 let decision = hook.on_after_tool_call(ctx).await;
970 if let Some(new_result) = decision.result {
971 executed.is_error = new_result.is_error;
972 executed.result = new_result;
973 }
974 if let Some(mark_error) = decision.mark_error {
975 executed.is_error = mark_error;
976 executed.result.is_error = mark_error;
977 }
978 if let Some(terminate) = decision.terminate {
979 executed.result.terminate = terminate;
980 }
981 }
982
983 FinalizedOutcome {
984 result: executed.result,
985 is_error: executed.is_error,
986 terminate: false,
987 }
988 .with_vote()
991}
992
993impl FinalizedOutcome {
994 fn with_vote(mut self) -> Self {
995 self.terminate = self.result.terminate;
996 self
997 }
998}
999
1000fn outcome_to_message(call: &ToolCall, outcome: FinalizedOutcome) -> AgentMessage {
1001 let details = match outcome.result.details {
1002 serde_json::Value::Null => None,
1003 other => Some(other),
1004 };
1005 let message = AgentMessage::ToolResult {
1006 tool_call_id: call.id.clone(),
1007 tool_name: call.name.clone(),
1008 content: ToolResultContent {
1009 blocks: outcome.result.content,
1010 },
1011 is_error: outcome.is_error,
1012 narration: outcome.result.narration,
1018 details,
1023 timestamp: Some(now_ms()),
1024 };
1025 if let AgentMessage::ToolResult {
1033 content,
1034 is_error,
1035 tool_call_id,
1036 tool_name,
1037 ..
1038 } = &message
1039 {
1040 let plain = content.plain_text();
1041 let (head, tail) = head_tail_for_log(&plain);
1042 tracing::debug!(
1043 target: "clark_agent::exec::tool_result_built",
1044 tool_call_id = %tool_call_id,
1045 tool_name = %tool_name,
1046 is_error = *is_error,
1047 content_len = plain.len(),
1048 content_head = %head,
1049 content_tail = %tail,
1050 "outcome_to_message wrote ToolResult into messages"
1051 );
1052 }
1053 message
1054}
1055
1056const TOOL_RESULT_LOG_HEAD: usize = 200;
1057const TOOL_RESULT_LOG_TAIL: usize = 200;
1058
1059fn head_tail_for_log(text: &str) -> (String, String) {
1064 if text.len() <= TOOL_RESULT_LOG_HEAD + TOOL_RESULT_LOG_TAIL {
1065 return (text.to_string(), String::new());
1066 }
1067 let head_end = char_boundary_at_or_before(text, TOOL_RESULT_LOG_HEAD);
1068 let tail_start = char_boundary_at_or_after(text, text.len() - TOOL_RESULT_LOG_TAIL);
1069 (text[..head_end].to_string(), text[tail_start..].to_string())
1070}
1071
1072fn char_boundary_at_or_before(text: &str, mut idx: usize) -> usize {
1073 if idx >= text.len() {
1074 return text.len();
1075 }
1076 while idx > 0 && !text.is_char_boundary(idx) {
1077 idx -= 1;
1078 }
1079 idx
1080}
1081
1082fn char_boundary_at_or_after(text: &str, mut idx: usize) -> usize {
1083 if idx >= text.len() {
1084 return text.len();
1085 }
1086 while idx < text.len() && !text.is_char_boundary(idx) {
1087 idx += 1;
1088 }
1089 idx
1090}
1091
1092fn now_ms() -> u64 {
1093 SystemTime::now()
1094 .duration_since(UNIX_EPOCH)
1095 .map(|d| d.as_millis() as u64)
1096 .unwrap_or(0)
1097}
1098
1099async fn emit_tool_start(config: &LoopConfig, call: &ToolCall) {
1100 let event = AgentEvent::ToolExecutionStart {
1101 tool_call_id: call.id.clone(),
1102 tool_name: call.name.clone(),
1103 args: call.arguments.clone(),
1104 };
1105 config.event_sink.emit(event.clone()).await;
1106 for o in &config.plugins.event_observer {
1107 o.on_event(&event).await;
1108 }
1109}
1110
1111fn format_arg_parse_error(tool_name: &str, parse_err: &str, raw: &str) -> String {
1117 const RAW_MAX: usize = 1024;
1118 let raw_snippet = if raw.len() > RAW_MAX {
1119 format!(
1120 "{}…<{} bytes truncated>",
1121 &raw[..RAW_MAX],
1122 raw.len() - RAW_MAX
1123 )
1124 } else {
1125 raw.to_string()
1126 };
1127 format!(
1128 "Tool `{tool_name}` arguments were not valid JSON: {parse_err}. \
1129 You sent (raw): {raw_snippet}. \
1130 Re-emit the call with a JSON object matching the tool's schema; \
1131 this is a syntax error in your tool-call arguments, not a problem \
1132 with the file or the runtime."
1133 )
1134}
1135
1136async fn emit_tool_end(config: &LoopConfig, call: &ToolCall, outcome: &FinalizedOutcome) {
1137 let event = AgentEvent::ToolExecutionEnd {
1138 tool_call_id: call.id.clone(),
1139 tool_name: call.name.clone(),
1140 result: outcome.result.clone(),
1141 is_error: outcome.is_error,
1142 };
1143 config.event_sink.emit(event.clone()).await;
1144 for o in &config.plugins.event_observer {
1145 o.on_event(&event).await;
1146 }
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::*;
1152 use crate::ToolResultBlock;
1153 use std::sync::Arc;
1154
1155 struct LimitTool {
1156 name: &'static str,
1157 counts: bool,
1158 vote_counts: bool,
1159 parallel_safe: bool,
1160 }
1161
1162 #[async_trait::async_trait]
1163 impl AgentTool for LimitTool {
1164 fn name(&self) -> &str {
1165 self.name
1166 }
1167
1168 fn description(&self) -> &str {
1169 "test tool"
1170 }
1171
1172 fn parameters_schema(&self) -> Value {
1173 json!({"type": "object"})
1174 }
1175
1176 fn counts_toward_tool_call_limit(&self) -> bool {
1177 self.counts
1178 }
1179
1180 fn parallel_safe_per_turn(&self) -> bool {
1181 self.parallel_safe
1182 }
1183
1184 fn counts_toward_termination_vote(&self) -> bool {
1185 self.vote_counts
1186 }
1187
1188 async fn execute(
1189 &self,
1190 _call_id: &str,
1191 _args: Value,
1192 _signal: CancellationToken,
1193 _update: mpsc::UnboundedSender<ToolResult>,
1194 ) -> Result<ToolResult, ToolError> {
1195 unreachable!("split tests do not execute tools")
1196 }
1197 }
1198
1199 fn registry() -> crate::tool::ToolRegistry {
1200 crate::tool::ToolRegistry::new()
1204 .with(Arc::new(LimitTool {
1205 name: "message_info",
1206 counts: false,
1207 vote_counts: false,
1208 parallel_safe: false,
1209 }))
1210 .with(Arc::new(LimitTool {
1211 name: "browser_navigate",
1212 counts: true,
1213 vote_counts: true,
1214 parallel_safe: true,
1215 }))
1216 .with(Arc::new(LimitTool {
1217 name: "browser_capture",
1218 counts: true,
1219 vote_counts: true,
1220 parallel_safe: true,
1221 }))
1222 .with(Arc::new(LimitTool {
1223 name: "browser_inspect",
1224 counts: true,
1225 vote_counts: true,
1226 parallel_safe: true,
1227 }))
1228 .with(Arc::new(LimitTool {
1229 name: "shell",
1230 counts: true,
1231 vote_counts: true,
1232 parallel_safe: false,
1233 }))
1234 .with(Arc::new(LimitTool {
1235 name: "message_result",
1236 counts: true,
1237 vote_counts: true,
1238 parallel_safe: false,
1239 }))
1240 .with(Arc::new(LimitTool {
1241 name: "message_ask",
1242 counts: true,
1243 vote_counts: true,
1244 parallel_safe: false,
1245 }))
1246 .with(Arc::new(LimitTool {
1247 name: "web_search",
1248 counts: true,
1249 vote_counts: true,
1250 parallel_safe: true,
1251 }))
1252 .with(Arc::new(LimitTool {
1253 name: "file_read",
1254 counts: true,
1255 vote_counts: true,
1256 parallel_safe: true,
1257 }))
1258 }
1259
1260 fn call(name: &str) -> ToolCall {
1261 ToolCall {
1262 id: format!("tc-{name}"),
1263 name: name.to_string(),
1264 arguments: Value::Null,
1265 }
1266 }
1267
1268 fn names(calls: &[ToolCall]) -> Vec<&str> {
1269 calls.iter().map(|call| call.name.as_str()).collect()
1270 }
1271
1272 #[test]
1273 fn progress_only_tools_do_not_starve_first_work_tool() {
1274 let registry = registry();
1275 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1276 vec![call("message_info"), call("browser_navigate")],
1277 ®istry,
1278 Some(1),
1279 );
1280
1281 assert_eq!(max, Some(1));
1282 assert_eq!(names(&executable), vec!["message_info", "browser_navigate"]);
1283 assert!(unexecuted.is_empty());
1284 }
1285
1286 #[test]
1287 fn extra_limit_counted_tools_still_get_synthetic_errors() {
1288 let registry = registry();
1289 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1290 vec![call("message_info"), call("shell"), call("message_result")],
1291 ®istry,
1292 Some(1),
1293 );
1294
1295 assert_eq!(max, Some(1));
1296 assert_eq!(names(&executable), vec!["message_info", "shell"]);
1297 assert_eq!(names(&unexecuted), vec!["message_result"]);
1298 }
1299
1300 #[test]
1301 fn parallel_safe_reads_do_not_burn_the_per_turn_cap() {
1302 let registry = registry();
1307 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1308 vec![
1309 call("web_search"),
1310 call("web_search"),
1311 call("browser_navigate"),
1312 ],
1313 ®istry,
1314 Some(1),
1315 );
1316
1317 assert_eq!(max, Some(1));
1318 assert_eq!(
1319 names(&executable),
1320 vec!["web_search", "web_search", "browser_navigate"]
1321 );
1322 assert!(
1323 unexecuted.is_empty(),
1324 "unexecuted: {:?}",
1325 names(&unexecuted)
1326 );
1327 }
1328
1329 #[test]
1330 fn parallel_safe_reads_do_not_compete_with_a_write_for_the_cap() {
1331 let registry = registry();
1334 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1335 vec![
1336 call("file_read"),
1337 call("file_read"),
1338 call("shell"),
1339 call("shell"),
1340 ],
1341 ®istry,
1342 Some(1),
1343 );
1344
1345 assert_eq!(max, Some(1));
1346 assert_eq!(names(&executable), vec!["file_read", "file_read", "shell"]);
1347 assert_eq!(names(&unexecuted), vec!["shell"]);
1348 }
1349
1350 #[test]
1351 fn browser_tools_do_not_burn_the_per_turn_cap() {
1352 let registry = registry();
1359 let (executable, unexecuted, max) = split_tool_calls_for_execution(
1360 vec![
1361 call("browser_navigate"),
1362 call("browser_navigate"),
1363 call("browser_capture"),
1364 call("browser_inspect"),
1365 call("shell"),
1366 ],
1367 ®istry,
1368 Some(1),
1369 );
1370
1371 assert_eq!(max, Some(1));
1372 assert_eq!(
1373 names(&executable),
1374 vec![
1375 "browser_navigate",
1376 "browser_navigate",
1377 "browser_capture",
1378 "browser_inspect",
1379 "shell",
1380 ]
1381 );
1382 assert!(
1383 unexecuted.is_empty(),
1384 "unexecuted: {:?}",
1385 names(&unexecuted)
1386 );
1387 }
1388
1389 #[test]
1390 fn malformed_calls_do_not_burn_the_cap_or_preempt_real_work() {
1391 let registry = registry();
1399
1400 let (executable, unexecuted, _) = split_tool_calls_for_execution(
1402 vec![call("missing"), call("shell")],
1403 ®istry,
1404 Some(1),
1405 );
1406 assert_eq!(names(&executable), vec!["missing", "shell"]);
1407 assert!(
1408 unexecuted.is_empty(),
1409 "real work must not be preempted by an unknown name: {:?}",
1410 names(&unexecuted)
1411 );
1412
1413 let (executable, unexecuted, _) =
1415 split_tool_calls_for_execution(vec![call(""), call("shell")], ®istry, Some(1));
1416 assert_eq!(names(&executable), vec!["", "shell"]);
1417 assert!(
1418 unexecuted.is_empty(),
1419 "empty-name glitch must not preempt real work: {:?}",
1420 names(&unexecuted)
1421 );
1422
1423 let (executable, unexecuted, _) =
1425 split_tool_calls_for_execution(vec![call("shell"), call("shell")], ®istry, Some(1));
1426 assert_eq!(names(&executable), vec!["shell"]);
1427 assert_eq!(names(&unexecuted), vec!["shell"]);
1428 }
1429
1430 #[test]
1431 fn compute_batch_terminate_passes_when_only_advisory_siblings_dont_vote() {
1432 let registry = registry();
1440 let votes = [("message_result", true), ("message_info", false)];
1441 assert!(compute_batch_terminate(
1442 ®istry,
1443 votes.iter().map(|(n, t)| (*n, *t))
1444 ));
1445 }
1446
1447 #[test]
1448 fn compute_batch_terminate_fails_when_any_counted_tool_did_not_vote_terminate() {
1449 let registry = registry();
1450 let votes = [("message_result", true), ("shell", false)];
1453 assert!(!compute_batch_terminate(
1454 ®istry,
1455 votes.iter().map(|(n, t)| (*n, *t))
1456 ));
1457 }
1458
1459 #[test]
1460 fn compute_batch_terminate_returns_false_for_all_advisory_batches() {
1461 let registry = registry();
1465 let votes = [("message_info", false), ("message_info", false)];
1466 assert!(!compute_batch_terminate(
1467 ®istry,
1468 votes.iter().map(|(n, t)| (*n, *t))
1469 ));
1470 }
1471
1472 #[test]
1473 fn compute_batch_terminate_returns_false_for_empty_batch() {
1474 let registry = registry();
1475 let votes: Vec<(&str, bool)> = Vec::new();
1476 assert!(!compute_batch_terminate(®istry, votes.into_iter()));
1477 }
1478
1479 #[test]
1480 fn compute_batch_terminate_treats_unknown_tools_as_counted() {
1481 let registry = registry();
1485 let votes = [("message_result", true), ("ghost_tool", false)];
1488 assert!(!compute_batch_terminate(
1489 ®istry,
1490 votes.iter().map(|(n, t)| (*n, *t))
1491 ));
1492
1493 let votes = [("message_result", true), ("ghost_tool", true)];
1497 assert!(compute_batch_terminate(
1498 ®istry,
1499 votes.iter().map(|(n, t)| (*n, *t))
1500 ));
1501 }
1502
1503 #[test]
1504 fn compute_batch_terminate_passes_when_message_ask_is_only_counted_terminator() {
1505 let registry = registry();
1508 let votes = [("message_ask", true), ("message_info", false)];
1509 assert!(compute_batch_terminate(
1510 ®istry,
1511 votes.iter().map(|(n, t)| (*n, *t))
1512 ));
1513 }
1514
1515 #[test]
1516 fn head_tail_for_log_returns_full_text_when_short() {
1517 let (head, tail) = head_tail_for_log("hello");
1522 assert_eq!(head, "hello");
1523 assert_eq!(tail, "");
1524 }
1525
1526 #[test]
1527 fn head_tail_for_log_truncates_long_text_with_head_and_tail() {
1528 let payload: String = "abc".repeat(500);
1529 assert!(payload.len() > TOOL_RESULT_LOG_HEAD + TOOL_RESULT_LOG_TAIL);
1530 let (head, tail) = head_tail_for_log(&payload);
1531 assert_eq!(head.len(), TOOL_RESULT_LOG_HEAD);
1532 assert_eq!(tail.len(), TOOL_RESULT_LOG_TAIL);
1533 assert!(payload.starts_with(&head));
1537 assert!(payload.ends_with(&tail));
1538 }
1539
1540 #[test]
1541 fn head_tail_for_log_respects_utf8_char_boundaries() {
1542 let mid = "πλάκα".repeat(50); let prefix: String = "x".repeat(150);
1548 let suffix: String = "y".repeat(150);
1549 let payload = format!("{prefix}{mid}{suffix}");
1550 let (head, tail) = head_tail_for_log(&payload);
1551 assert!(payload.starts_with(&head));
1556 assert!(payload.ends_with(&tail));
1557 assert!(head.len() <= TOOL_RESULT_LOG_HEAD);
1559 assert!(tail.len() <= TOOL_RESULT_LOG_TAIL + 1); }
1561
1562 #[test]
1563 fn unexecuted_message_mentions_progress_only_calls_when_present() {
1564 let result = unexecuted_tool_call_result(3, 2, 1);
1565 let text = match result.content.first() {
1566 Some(ToolResultBlock::Text(text)) => text.text.as_str(),
1567 _ => panic!("expected text result"),
1568 };
1569
1570 assert!(text.contains("2 limit-counted tool calls"));
1571 assert!(text.contains("3 tool calls total, including progress-only calls"));
1572 assert_eq!(
1573 result
1574 .details
1575 .get("limit_counted_tool_calls")
1576 .and_then(Value::as_u64),
1577 Some(2)
1578 );
1579 }
1580}