1use serde::{Deserialize, Serialize};
31use std::collections::HashSet;
32use std::future::Future;
33use std::pin::Pin;
34use std::sync::Arc;
35use std::task::{Context, Poll};
36use std::time::Instant;
37
38use super::ExecutionContext;
39use super::act_hooks::{self, PostActHook};
40use super::tool_scheduler;
41use crate::error::Result;
42use crate::events::{
43 ActCompletedData, ActStartedData, EventContext, EventRequest, ToolCompletedData,
44 ToolStartedData,
45};
46use crate::message::ContentPart;
47use crate::phase_effects::{PhaseEffectEmitter, PhaseEffectSink};
48use crate::tool_fingerprint::{
49 tool_call_fingerprint, tool_error_fingerprint, tool_result_fingerprint,
50};
51use crate::tool_narration::{
52 GroupHeadlineAction, ToolNarrationContext, ToolNarrationPhase,
53 render_tool_narration_with_locale, summarize_group_actions, tool_call_for_group_summary,
54};
55use crate::tool_types::{SideEffectClass, ToolCall, ToolDefinition, ToolResult};
56use crate::typed_id::{AgentId, HarnessId};
57use crate::{
58 durability::DurableToolResultStore, durability::ToolCallClaimResult,
59 event_emitter::EventEmitter, execution_loading::AgentStore, execution_loading::SessionStore,
60 session_files::SessionFileSystem, tool_context::ToolContext, tool_execution::ToolExecutor,
61};
62use uuid::Uuid;
63
64struct AbortOnDropJoinHandle<T> {
68 handle: tokio::task::JoinHandle<T>,
69}
70
71impl<T> AbortOnDropJoinHandle<T> {
72 fn new(handle: tokio::task::JoinHandle<T>) -> Self {
73 Self { handle }
74 }
75}
76
77impl<T> Future for AbortOnDropJoinHandle<T> {
78 type Output = std::result::Result<T, tokio::task::JoinError>;
79
80 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
81 Pin::new(&mut self.handle).poll(cx)
82 }
83}
84
85impl<T> Drop for AbortOnDropJoinHandle<T> {
86 fn drop(&mut self) {
87 if !self.handle.is_finished() {
88 self.handle.abort();
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ActInput {
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub org_id: Option<i64>,
103 pub context: ExecutionContext,
105 pub harness_id: HarnessId,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub agent_id: Option<AgentId>,
110 pub tool_calls: Vec<ToolCall>,
112 pub tool_definitions: Vec<ToolDefinition>,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub locale: Option<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
120 pub blueprint_id: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub network_access: Option<crate::network_access::NetworkAccessList>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub parallel_tool_calls: Option<bool>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct ToolCallResult {
134 pub tool_call: ToolCall,
136 pub result: ToolResult,
138 pub success: bool,
140 pub status: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub connection_required: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub determinism_fatal: Option<String>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ActResult {
154 pub results: Vec<ToolCallResult>,
156 pub completed: bool,
158 pub success_count: u32,
160 pub error_count: u32,
162 #[serde(default)]
167 pub waiting_for_tool_results: bool,
168 #[serde(default, skip_serializing_if = "is_false")]
170 pub blocked: bool,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub client_tool_calls: Vec<ToolCall>,
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
178 pub client_tool_definitions: Vec<ToolDefinition>,
179}
180
181fn is_false(value: &bool) -> bool {
182 !*value
183}
184
185pub struct ActAtom<T, E>
203where
204 T: ToolExecutor,
205 E: PhaseEffectSink,
206{
207 tool_executor: Arc<T>,
210 event_emitter: PhaseEffectEmitter<E>,
211 context_services: crate::tool_context::ToolContextServices,
213 outbound_tool_rate_limiter: Option<Arc<dyn crate::tool_execution::OutboundToolRateLimiter>>,
217 durable_tool_result_store: Option<Arc<dyn DurableToolResultStore>>,
222 hooks: Vec<Box<dyn PostActHook>>,
225 post_tool_hooks: Vec<Arc<dyn act_hooks::PostToolExecHook>>,
228 pre_tool_hooks: Vec<Arc<dyn act_hooks::PreToolUseHook>>,
234 tool_call_hooks: Vec<Arc<dyn crate::capabilities::ToolCallHook>>,
237 final_post_tool_hooks: Vec<Arc<dyn act_hooks::PostToolExecHook>>,
240}
241
242impl<T, E> ActAtom<T, E>
243where
244 T: ToolExecutor,
245 E: PhaseEffectSink,
246{
247 pub fn new(tool_executor: T, event_emitter: E) -> Self {
249 Self {
250 tool_executor: Arc::new(tool_executor),
251 event_emitter: PhaseEffectEmitter::new(Arc::new(event_emitter)),
252 context_services: crate::tool_context::ToolContextServices::default(),
253 outbound_tool_rate_limiter: None,
254 durable_tool_result_store: None,
255 hooks: Self::default_hooks(),
256 post_tool_hooks: Vec::new(),
257 pre_tool_hooks: Vec::new(),
258 tool_call_hooks: Vec::new(),
259 final_post_tool_hooks: Self::default_final_hooks(),
260 }
261 }
262
263 pub fn with_file_store(
265 tool_executor: T,
266 event_emitter: E,
267 file_store: Arc<dyn SessionFileSystem>,
268 ) -> Self {
269 Self {
270 tool_executor: Arc::new(tool_executor),
271 event_emitter: PhaseEffectEmitter::new(Arc::new(event_emitter)),
272 context_services: crate::tool_context::ToolContextServices {
273 file_store: Some(file_store),
274 ..Default::default()
275 },
276 outbound_tool_rate_limiter: None,
277 durable_tool_result_store: None,
278 hooks: Self::default_hooks(),
279 post_tool_hooks: Vec::new(),
280 pre_tool_hooks: Vec::new(),
281 tool_call_hooks: Vec::new(),
282 final_post_tool_hooks: Self::default_final_hooks(),
283 }
284 }
285
286 pub fn with_context_services(
290 mut self,
291 services: crate::tool_context::ToolContextServices,
292 ) -> Self {
293 self.context_services = services;
294 self
295 }
296
297 pub fn with_hook(mut self, hook: Box<dyn PostActHook>) -> Self {
299 self.hooks.push(hook);
300 self
301 }
302
303 pub fn with_final_post_tool_hook(mut self, hook: Arc<dyn act_hooks::PostToolExecHook>) -> Self {
307 let hard_limit_index = self.final_post_tool_hooks.len().saturating_sub(1);
308 self.final_post_tool_hooks.insert(hard_limit_index, hook);
309 self
310 }
311
312 fn default_hooks() -> Vec<Box<dyn PostActHook>> {
315 vec![
316 Box::new(act_hooks::ConnectionSetupHook),
317 Box::new(act_hooks::ClientSideToolHook),
318 ]
319 }
320
321 fn default_final_hooks() -> Vec<Arc<dyn act_hooks::PostToolExecHook>> {
324 vec![Arc::new(act_hooks::OutputHardLimitHook)]
325 }
326
327 pub fn with_storage_store(
329 mut self,
330 store: Arc<dyn crate::session_services::SessionStorageStore>,
331 ) -> Self {
332 self.context_services.storage_store = Some(store);
333 self
334 }
335
336 pub fn with_image_store(
338 mut self,
339 store: Arc<dyn crate::image_services::ImageArtifactStore>,
340 ) -> Self {
341 self.context_services.image_store = Some(store);
342 self
343 }
344
345 pub fn with_provider_credential_store(
347 mut self,
348 store: Arc<dyn crate::connection_services::ProviderCredentialStore>,
349 ) -> Self {
350 self.context_services.provider_credential_store = Some(store);
351 self
352 }
353
354 pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
356 self.context_services.utility_llm_service = Some(service);
357 self
358 }
359
360 pub fn with_mcp_invoker(mut self, invoker: Arc<dyn crate::McpToolInvoker>) -> Self {
362 self.context_services.mcp_invoker = Some(invoker);
363 self
364 }
365
366 pub fn with_egress_service(mut self, service: Arc<dyn crate::EgressService>) -> Self {
368 self.context_services.egress_service = Some(service);
369 self
370 }
371
372 pub fn with_connection_resolver(
374 mut self,
375 resolver: Arc<dyn crate::connection_services::UserConnectionResolver>,
376 ) -> Self {
377 self.context_services.connection_resolver = Some(resolver);
378 self
379 }
380
381 pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
383 self.context_services.session_store = Some(store);
384 self
385 }
386
387 pub fn with_agent_store(mut self, store: Arc<dyn AgentStore>) -> Self {
389 self.context_services.agent_store = Some(store);
390 self
391 }
392
393 pub fn with_schedule_store(
395 mut self,
396 store: Arc<dyn crate::session_services::SessionScheduleStore>,
397 ) -> Self {
398 self.context_services.schedule_store = Some(store);
399 self
400 }
401
402 pub fn with_subagent_delegate(
404 mut self,
405 store: Arc<dyn crate::subagent_delegation::SubagentSessionDelegate>,
406 ) -> Self {
407 self.context_services.subagent_delegate = Some(store);
408 self
409 }
410
411 pub fn with_leased_resource_store(
413 mut self,
414 store: Arc<dyn crate::session_services::LeasedResourceStore>,
415 ) -> Self {
416 self.context_services.leased_resource_store = Some(store);
417 self
418 }
419
420 pub fn with_session_resource_registry(
422 mut self,
423 registry: Arc<dyn crate::session_services::SessionResourceRegistry>,
424 ) -> Self {
425 self.context_services.session_resource_registry = Some(registry);
426 self
427 }
428
429 pub fn with_session_task_registry(
431 mut self,
432 registry: Arc<dyn crate::session_task::SessionTaskRegistry>,
433 ) -> Self {
434 self.context_services.session_task_registry = Some(registry);
435 self
436 }
437
438 pub fn with_capability_registry(
439 mut self,
440 registry: crate::capabilities::CapabilityRegistry,
441 ) -> Self {
442 self.context_services.capability_registry = Some(registry);
443 self
444 }
445
446 pub fn with_tool_registry(mut self, registry: Arc<crate::tools::ToolRegistry>) -> Self {
448 self.context_services.tool_registry = Some(registry);
449 self
450 }
451
452 pub fn with_post_tool_hooks(
456 mut self,
457 hooks: Vec<Arc<dyn act_hooks::PostToolExecHook>>,
458 ) -> Self {
459 self.post_tool_hooks.extend(hooks);
460 self
461 }
462
463 pub fn with_pre_tool_hooks(mut self, hooks: Vec<Arc<dyn act_hooks::PreToolUseHook>>) -> Self {
467 self.pre_tool_hooks.extend(hooks);
468 self
469 }
470
471 pub fn with_tool_call_hooks(
472 mut self,
473 hooks: Vec<Arc<dyn crate::capabilities::ToolCallHook>>,
474 ) -> Self {
475 self.tool_call_hooks.extend(hooks);
476 self
477 }
478
479 pub fn with_org_id(mut self, org_id: crate::typed_id::OrgId) -> Self {
481 self.context_services.org_id = Some(org_id);
482 self
483 }
484
485 pub fn with_network_access(
487 mut self,
488 network_access: Option<crate::network_access::NetworkAccessList>,
489 ) -> Self {
490 self.context_services.network_access = network_access;
491 self
492 }
493
494 pub fn with_budget_checker(
496 mut self,
497 checker: Arc<dyn crate::tool_execution::BudgetChecker>,
498 ) -> Self {
499 self.context_services.budget_checker = Some(checker);
500 self
501 }
502
503 pub fn with_payment_authority(
505 mut self,
506 authority: Arc<dyn crate::tool_execution::PaymentAuthority>,
507 ) -> Self {
508 self.context_services.payment_authority = Some(authority);
509 self
510 }
511
512 pub fn with_session_creation_authority(
514 mut self,
515 authority: Arc<dyn crate::delegation_services::SessionCreationAuthority>,
516 ) -> Self {
517 self.context_services.session_creation_authority = Some(authority);
518 self
519 }
520
521 pub fn with_outbound_tool_rate_limiter(
523 mut self,
524 limiter: Arc<dyn crate::tool_execution::OutboundToolRateLimiter>,
525 ) -> Self {
526 self.outbound_tool_rate_limiter = Some(limiter);
527 self
528 }
529
530 pub fn with_durable_tool_result_store(
532 mut self,
533 store: Arc<dyn DurableToolResultStore>,
534 ) -> Self {
535 self.durable_tool_result_store = Some(store);
536 self
537 }
538
539 pub fn with_subagent_spawn_store(
541 mut self,
542 store: Arc<dyn crate::delegation_services::SubagentSpawnStore>,
543 ) -> Self {
544 self.context_services.subagent_spawn_store = Some(store);
545 self
546 }
547
548 pub fn with_subagent_nesting_policy(
550 mut self,
551 policy: crate::delegation_services::SubagentNestingPolicy,
552 ) -> Self {
553 self.context_services.subagent_nesting_policy = policy;
554 self
555 }
556
557 pub fn with_reasoning_effort_handle(
561 mut self,
562 handle: crate::tool_context::ReasoningEffortHandle,
563 ) -> Self {
564 self.context_services.reasoning_effort_handle = Some(handle);
565 self
566 }
567}
568
569impl<T, E> ActAtom<T, E>
570where
571 T: ToolExecutor + Send + Sync + 'static,
572 E: EventEmitter + Send + Sync + 'static,
573{
574 pub fn name(&self) -> &'static str {
576 "act"
577 }
578
579 pub async fn execute(&self, input: ActInput) -> Result<ActResult> {
581 let ActInput {
582 context,
583 tool_calls,
584 tool_definitions,
585 locale,
586 network_access,
587 parallel_tool_calls,
588 .. } = input;
590
591 let (server_tool_calls, client_tool_calls): (Vec<_>, Vec<_>) =
594 tool_calls.into_iter().partition(|tc| {
595 tool_definitions
596 .iter()
597 .find(|td| td.name() == tc.name)
598 .map(|td| !matches!(td, ToolDefinition::ClientSide(_)))
599 .unwrap_or(true) });
601
602 let client_tool_calls: Vec<_> = client_tool_calls
603 .into_iter()
604 .map(|tool_call| self.transform_tool_call_for_execution(tool_call))
605 .collect();
606
607 let client_tool_definitions: Vec<_> = if client_tool_calls.is_empty() {
608 vec![]
609 } else {
610 tool_definitions
611 .iter()
612 .filter(|td| {
613 if let ToolDefinition::ClientSide(ct) = td {
614 client_tool_calls.iter().any(|tc| tc.name == ct.name)
615 } else {
616 false
617 }
618 })
619 .cloned()
620 .collect()
621 };
622
623 if server_tool_calls.is_empty() && client_tool_calls.is_empty() {
624 return Ok(ActResult {
625 results: vec![],
626 completed: true,
627 success_count: 0,
628 error_count: 0,
629 waiting_for_tool_results: false,
630 blocked: false,
631 client_tool_calls: vec![],
632 client_tool_definitions: vec![],
633 });
634 }
635
636 if server_tool_calls.is_empty() {
639 let mut result = ActResult {
640 results: vec![],
641 completed: true,
642 success_count: 0,
643 error_count: 0,
644 waiting_for_tool_results: false,
645 blocked: false,
646 client_tool_calls,
647 client_tool_definitions,
648 };
649 act_hooks::run_post_act_hooks(
650 &self.hooks,
651 &context,
652 &mut result,
653 &tool_definitions,
654 &self.event_emitter,
655 locale.as_deref(),
656 )
657 .await;
658 return Ok(result);
659 }
660
661 let tool_calls = server_tool_calls;
663
664 tracing::info!(
665 session_id = %context.session_id,
666 turn_id = %context.turn_id,
667 exec_id = %context.exec_id,
668 tool_count = %tool_calls.len(),
669 "ActAtom: executing tools in parallel"
670 );
671
672 let trace_id = context.turn_id.to_string();
680 let act_span_id = Uuid::now_v7().to_string();
681 let parent_span_id = trace_id.clone(); let event_context = EventContext::from_execution_context(&context).with_span(
685 trace_id.clone(),
686 act_span_id.clone(),
687 Some(parent_span_id.clone()),
688 );
689
690 let act_start = Instant::now();
692
693 let visible_tool_names = Arc::new(
694 tool_definitions
695 .iter()
696 .map(|def| def.name().to_string())
697 .collect::<HashSet<_>>(),
698 );
699
700 let tool_map: std::collections::HashMap<&str, &ToolDefinition> = tool_definitions
702 .iter()
703 .map(|def| {
704 let name = def.name();
705 (name, def)
706 })
707 .collect();
708
709 let mut started_data = ActStartedData::with_definitions_and_locale(
710 &tool_calls,
711 &tool_definitions,
712 locale.as_deref(),
713 );
714 for summary in &mut started_data.tool_calls {
715 if let Some(tool_call) = tool_calls.iter().find(|tc| tc.id == summary.id) {
716 let tool_def = tool_map.get(tool_call.name.as_str()).copied();
717 summary.narration = Some(self.render_tool_narration(
718 &context,
719 tool_def,
720 tool_call,
721 ToolNarrationPhase::Started,
722 locale.as_deref(),
723 ));
724 summary.completed_narration = Some(self.render_tool_narration(
725 &context,
726 tool_def,
727 tool_call,
728 ToolNarrationPhase::Completed,
729 locale.as_deref(),
730 ));
731 }
732 }
733 started_data.headline = self.render_group_headline(
734 &context,
735 &tool_calls,
736 &tool_map,
737 ToolNarrationPhase::Started,
738 locale.as_deref(),
739 );
740
741 if let Err(e) = self
743 .event_emitter
744 .emit(EventRequest::new(
745 context.session_id,
746 event_context.clone(),
747 started_data,
748 ))
749 .await
750 {
751 tracing::warn!(
752 session_id = %context.session_id,
753 error = %e,
754 "ActAtom: failed to emit act.started event"
755 );
756 }
757
758 let classes: Vec<Option<String>> = tool_calls
765 .iter()
766 .map(|tool_call| {
767 tool_map
768 .get(tool_call.name.as_str())
769 .and_then(|def| def.concurrency_class())
770 .map(|class| class.to_string())
771 })
772 .collect();
773 let schedule_config = tool_scheduler::ScheduleConfig {
774 serialize_all: parallel_tool_calls == Some(false),
775 ..tool_scheduler::ScheduleConfig::default()
776 };
777 let results =
778 tool_scheduler::schedule(tool_calls.len(), &classes, schedule_config, |index| {
779 let tool_call = &tool_calls[index];
780 let tool_def = tool_map.get(tool_call.name.as_str()).cloned();
781 self.execute_single_tool(
782 &context,
783 tool_call.clone(),
784 tool_def,
785 &trace_id,
786 &act_span_id,
787 locale.as_deref(),
788 network_access.as_ref(),
789 visible_tool_names.clone(),
790 )
791 })
792 .await;
793
794 let success_count = results.iter().filter(|r| r.success).count() as u32;
796 let error_count = results.iter().filter(|r| !r.success).count() as u32;
797
798 let act_duration_ms = act_start.elapsed().as_millis() as u64;
800
801 let completed_context = EventContext::from_execution_context(&context).with_span(
803 trace_id.clone(),
804 act_span_id.clone(), Some(parent_span_id.clone()),
806 );
807 let mut completed_headline = self.render_group_headline(
808 &context,
809 &tool_calls,
810 &tool_map,
811 ToolNarrationPhase::Completed,
812 locale.as_deref(),
813 );
814 if error_count > 0 {
815 let suffix = crate::localization::format_error_suffix(locale.as_deref(), error_count);
816 completed_headline = Some(match completed_headline {
817 Some(text) => format!("{text}{suffix}"),
818 None => {
819 crate::localization::format_completed_tool_batch(locale.as_deref(), error_count)
820 }
821 });
822 }
823
824 if let Err(e) = self
825 .event_emitter
826 .emit(EventRequest::new(
827 context.session_id,
828 completed_context,
829 ActCompletedData {
830 completed: true,
831 success_count,
832 error_count,
833 duration_ms: Some(act_duration_ms),
834 headline: completed_headline,
835 },
836 ))
837 .await
838 {
839 tracing::warn!(
840 session_id = %context.session_id,
841 error = %e,
842 "ActAtom: failed to emit act.completed event"
843 );
844 }
845
846 tracing::info!(
847 session_id = %context.session_id,
848 turn_id = %context.turn_id,
849 success_count = %success_count,
850 error_count = %error_count,
851 "ActAtom: all tools completed"
852 );
853
854 if let Some(fatal_msg) = results.iter().find_map(|r| r.determinism_fatal.as_deref()) {
857 return Err(crate::error::AgentLoopError::tool(format!(
858 "act activity aborted due to determinism violation: {fatal_msg}"
859 )));
860 }
861
862 let mut act_result = ActResult {
863 results,
864 completed: true,
865 success_count,
866 error_count,
867 waiting_for_tool_results: false,
868 blocked: false,
869 client_tool_calls,
870 client_tool_definitions,
871 };
872
873 act_hooks::run_post_act_hooks(
875 &self.hooks,
876 &context,
877 &mut act_result,
878 &tool_definitions,
879 &self.event_emitter,
880 locale.as_deref(),
881 )
882 .await;
883
884 Ok(act_result)
885 }
886}
887
888impl<T, E> ActAtom<T, E>
889where
890 T: ToolExecutor + Send + Sync + 'static,
891 E: EventEmitter + Send + Sync + 'static,
892{
893 fn render_tool_narration(
894 &self,
895 execution_context: &ExecutionContext,
896 tool_def: Option<&ToolDefinition>,
897 tool_call: &ToolCall,
898 phase: ToolNarrationPhase,
899 locale: Option<&str>,
900 ) -> String {
901 let wrapped_store = self.wrap_file_store_for_narration(execution_context);
902 let ctx = ToolNarrationContext::new(wrapped_store.as_deref());
903 for hook in &self.tool_call_hooks {
904 if let Some(narration) = hook.narration(tool_def, tool_call, phase, locale, ctx) {
905 return narration;
906 }
907 }
908 if let Some(narration) = self
914 .context_services
915 .tool_registry
916 .as_ref()
917 .and_then(|registry| registry.get(&tool_call.name))
918 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
919 {
920 return narration;
921 }
922 render_tool_narration_with_locale(tool_def, tool_call, phase, locale)
923 }
924
925 fn render_group_headline(
926 &self,
927 execution_context: &ExecutionContext,
928 tool_calls: &[ToolCall],
929 tool_map: &std::collections::HashMap<&str, &ToolDefinition>,
930 phase: ToolNarrationPhase,
931 locale: Option<&str>,
932 ) -> Option<String> {
933 if tool_calls.is_empty() {
934 return None;
935 }
936 if let [tool_call] = tool_calls {
937 return Some(self.render_tool_narration(
938 execution_context,
939 tool_map.get(tool_call.name.as_str()).copied(),
940 tool_call,
941 phase,
942 locale,
943 ));
944 }
945
946 let actions = tool_calls
947 .iter()
948 .map(|tool_call| {
949 let tool_def = tool_map.get(tool_call.name.as_str()).copied();
950 let narration = self.render_tool_narration(
951 execution_context,
952 tool_def,
953 tool_call,
954 phase,
955 locale,
956 );
957 let repeated_narration = self.render_tool_narration(
958 execution_context,
959 tool_def,
960 &tool_call_for_group_summary(tool_call),
961 phase,
962 locale,
963 );
964 GroupHeadlineAction::new(tool_call, narration, repeated_narration)
965 })
966 .collect::<Vec<_>>();
967
968 Some(summarize_group_actions(&actions, locale))
969 }
970
971 fn wrap_file_store_for_narration(
974 &self,
975 execution_context: &ExecutionContext,
976 ) -> Option<Arc<dyn SessionFileSystem>> {
977 let store = self.context_services.file_store.as_ref()?.clone();
978 let store = if let Some(workspace_id) = execution_context.workspace_id {
979 crate::session_files::WorkspaceScopedFileSystem::wrap(store, workspace_id)
980 } else {
981 store
982 };
983 Some(crate::mount_fs::MountFs::wrap_if_needed(store))
984 }
985
986 fn transform_tool_call_for_execution(&self, tool_call: ToolCall) -> ToolCall {
987 self.tool_call_hooks
988 .iter()
989 .fold(tool_call, |tool_call, hook| {
990 hook.transform_for_execution(tool_call)
991 })
992 }
993
994 #[allow(clippy::too_many_arguments)]
1000 async fn execute_single_tool(
1001 &self,
1002 context: &ExecutionContext,
1003 tool_call: ToolCall,
1004 tool_def: Option<&ToolDefinition>,
1005 trace_id: &str,
1006 act_span_id: &str,
1007 locale: Option<&str>,
1008 network_access: Option<&crate::network_access::NetworkAccessList>,
1009 visible_tool_names: Arc<HashSet<String>>,
1010 ) -> ToolCallResult {
1011 tracing::debug!(
1012 session_id = %context.session_id,
1013 turn_id = %context.turn_id,
1014 tool_name = %tool_call.name,
1015 tool_call_id = %tool_call.id,
1016 "ActAtom: executing tool"
1017 );
1018
1019 let tool_span_id = Uuid::now_v7().to_string();
1021
1022 let event_context = EventContext::from_execution_context(context).with_span(
1024 trace_id.to_string(),
1025 tool_span_id.clone(),
1026 Some(act_span_id.to_string()),
1027 );
1028
1029 let tool_start = Instant::now();
1031 let tool_call_fingerprint = tool_call_fingerprint(&tool_call);
1032
1033 let display_name = crate::localization::localized_tool_display_name(
1035 &tool_call.name,
1036 tool_def.and_then(|d| d.display_name()),
1037 locale,
1038 );
1039 let capability_attribution = tool_def.and_then(|def| {
1040 def.capability_attribution()
1041 .map(|(id, name)| (id.to_string(), name.map(str::to_string)))
1042 });
1043
1044 if let (Some(limiter), Some(ref org_id)) = (
1048 &self.outbound_tool_rate_limiter,
1049 self.context_services.org_id,
1050 ) && !limiter.check_org(org_id).await
1051 {
1052 tracing::warn!(
1053 session_id = %context.session_id,
1054 tool_name = %tool_call.name,
1055 "ActAtom: outbound tool rate limit exceeded for org"
1056 );
1057 return ToolCallResult {
1058 tool_call: tool_call.clone(),
1059 result: ToolResult {
1060 tool_call_id: tool_call.id.clone(),
1061 result: None,
1062 images: None,
1063 error: Some(
1064 "Outbound tool rate limit exceeded for this organization; back off and retry later.".to_string(),
1065 ),
1066 connection_required: None,
1067 raw_output: None,
1068 },
1069 success: false,
1070 status: "error".to_string(),
1071 connection_required: None,
1072 determinism_fatal: None,
1073 };
1074 }
1075
1076 let claim_token = if let Some(ref store) = self.durable_tool_result_store {
1079 let turn_id = context.turn_id.to_string();
1080 match store
1081 .try_claim_tool_call(
1082 &turn_id,
1083 &tool_call.id,
1084 &tool_call.name,
1085 &tool_call_fingerprint,
1086 )
1087 .await
1088 {
1089 Ok(ToolCallClaimResult::Claimed { claim_token }) => Some(claim_token),
1090
1091 Ok(ToolCallClaimResult::AlreadySettled {
1092 result_json,
1093 args_fingerprint: stored_fp,
1094 }) => {
1095 if stored_fp != tool_call_fingerprint {
1097 let err_msg = format!(
1098 "determinism violation: tool '{}' replay args fingerprint \
1099 does not match prior execution (stored={stored_fp}, \
1100 current={})",
1101 tool_call.name, tool_call_fingerprint
1102 );
1103 tracing::error!(
1104 session_id = %context.session_id,
1105 turn_id = %context.turn_id,
1106 tool_call_id = %tool_call.id,
1107 stored_fp = %stored_fp,
1108 current_fp = %tool_call_fingerprint,
1109 "ActAtom: determinism violation — replay args fingerprint mismatch"
1110 );
1111 let result_fp =
1112 tool_result_fingerprint(&tool_call.name, &ToolResult::error(&err_msg));
1113 let _ = self
1114 .event_emitter
1115 .emit(EventRequest::new(
1116 context.session_id,
1117 event_context,
1118 ToolCompletedData::failure(
1119 tool_call.id.clone(),
1120 tool_call.name.clone(),
1121 "error".to_string(),
1122 err_msg.clone(),
1123 None,
1124 )
1125 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1126 .with_display_name(display_name.clone()),
1127 ))
1128 .await;
1129 return ToolCallResult {
1130 tool_call: tool_call.clone(),
1131 result: ToolResult {
1132 tool_call_id: tool_call.id.clone(),
1133 result: None,
1134 images: None,
1135 error: Some(err_msg.clone()),
1136 connection_required: None,
1137 raw_output: None,
1138 },
1139 success: false,
1140 status: "error".to_string(),
1141 connection_required: None,
1142 determinism_fatal: Some(err_msg),
1143 };
1144 }
1145 tracing::debug!(
1146 session_id = %context.session_id,
1147 turn_id = %context.turn_id,
1148 tool_call_id = %tool_call.id,
1149 "ActAtom: replaying already-settled tool call"
1150 );
1151 let replayed_result: ToolResult = serde_json::from_value(result_json.clone())
1153 .unwrap_or(ToolResult {
1154 tool_call_id: tool_call.id.clone(),
1155 result: Some(result_json),
1156 images: None,
1157 error: None,
1158 connection_required: None,
1159 raw_output: None,
1160 });
1161 let success = replayed_result.error.is_none();
1162 let status = if success { "success" } else { "error" };
1163 let result_fp = tool_result_fingerprint(&tool_call.name, &replayed_result);
1164 let completed_data = if success {
1165 let mut content = replayed_result
1167 .result
1168 .as_ref()
1169 .map(|r| vec![ContentPart::tool_result_text(r)])
1170 .unwrap_or_default();
1171 if let Some(ref images) = replayed_result.images {
1172 for img in images {
1173 content.push(ContentPart::Image(
1174 crate::message::ImageContentPart::from_base64(
1175 &img.base64,
1176 &img.media_type,
1177 ),
1178 ));
1179 }
1180 }
1181 ToolCompletedData::success(
1182 tool_call.id.clone(),
1183 tool_call.name.clone(),
1184 content,
1185 None,
1186 )
1187 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1188 .with_display_name(display_name.clone())
1189 } else {
1190 ToolCompletedData::failure(
1191 tool_call.id.clone(),
1192 tool_call.name.clone(),
1193 status.to_string(),
1194 replayed_result.error.clone().unwrap_or_default(),
1195 None,
1196 )
1197 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1198 .with_display_name(display_name.clone())
1199 };
1200 let _ = self
1201 .event_emitter
1202 .emit(EventRequest::new(
1203 context.session_id,
1204 event_context,
1205 completed_data,
1206 ))
1207 .await;
1208 let conn_req = replayed_result.connection_required.clone();
1209 return ToolCallResult {
1210 tool_call,
1211 result: replayed_result,
1212 success,
1213 status: status.to_string(),
1214 connection_required: conn_req,
1215 determinism_fatal: None,
1216 };
1217 }
1218
1219 Ok(ToolCallClaimResult::AlreadyRunning {
1220 args_fingerprint: stored_fp,
1221 }) => {
1222 if stored_fp != tool_call_fingerprint {
1225 let err_msg = format!(
1226 "determinism violation: tool '{}' args fingerprint changed \
1227 while prior claim is still running (stored={stored_fp}, \
1228 current={tool_call_fingerprint})",
1229 tool_call.name
1230 );
1231 tracing::error!(
1232 session_id = %context.session_id,
1233 turn_id = %context.turn_id,
1234 tool_call_id = %tool_call.id,
1235 stored = %stored_fp,
1236 current = %tool_call_fingerprint,
1237 "ActAtom: determinism violation — running claim fingerprint mismatch"
1238 );
1239 let result_fp =
1240 tool_result_fingerprint(&tool_call.name, &ToolResult::error(&err_msg));
1241 let _ = self
1242 .event_emitter
1243 .emit(EventRequest::new(
1244 context.session_id,
1245 event_context,
1246 ToolCompletedData::failure(
1247 tool_call.id.clone(),
1248 tool_call.name.clone(),
1249 "error".to_string(),
1250 err_msg.clone(),
1251 None,
1252 )
1253 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1254 .with_display_name(display_name.clone()),
1255 ))
1256 .await;
1257 return ToolCallResult {
1258 tool_call: tool_call.clone(),
1259 result: ToolResult {
1260 tool_call_id: tool_call.id.clone(),
1261 result: None,
1262 images: None,
1263 error: Some(err_msg.clone()),
1264 connection_required: None,
1265 raw_output: None,
1266 },
1267 success: false,
1268 status: "error".to_string(),
1269 connection_required: None,
1270 determinism_fatal: Some(err_msg),
1271 };
1272 }
1273
1274 let sec = tool_def
1275 .map(|d| d.side_effect_class())
1276 .unwrap_or(SideEffectClass::AtMostOnce);
1277 match sec {
1278 SideEffectClass::Pure | SideEffectClass::Idempotent => {
1279 tracing::debug!(
1281 session_id = %context.session_id,
1282 tool_call_id = %tool_call.id,
1283 "ActAtom: stale running claim for idempotent tool, re-executing"
1284 );
1285 None
1286 }
1287 SideEffectClass::AtMostOnce => {
1288 tracing::warn!(
1289 session_id = %context.session_id,
1290 turn_id = %context.turn_id,
1291 tool_call_id = %tool_call.id,
1292 "ActAtom: AtMostOnce tool has stale running claim; returning interrupted result"
1293 );
1294 let _ = store
1296 .settle_tool_call(
1297 &turn_id,
1298 &tool_call.id,
1299 serde_json::Value::Null,
1300 "interrupted",
1301 Uuid::nil(), )
1303 .await;
1304 let err_msg = format!(
1305 "tool '{}' was interrupted mid-execution during a prior \
1306 worker failure; result is uncertain and was not re-run \
1307 (AtMostOnce safety)",
1308 tool_call.name
1309 );
1310 let result_fp = tool_result_fingerprint(
1311 &tool_call.name,
1312 &ToolResult::error(&err_msg),
1313 );
1314 let _ = self
1315 .event_emitter
1316 .emit(EventRequest::new(
1317 context.session_id,
1318 event_context,
1319 ToolCompletedData::failure(
1320 tool_call.id.clone(),
1321 tool_call.name.clone(),
1322 "interrupted".to_string(),
1323 err_msg.clone(),
1324 None,
1325 )
1326 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1327 .with_display_name(display_name.clone()),
1328 ))
1329 .await;
1330 return ToolCallResult {
1331 tool_call: tool_call.clone(),
1332 result: ToolResult {
1333 tool_call_id: tool_call.id.clone(),
1334 result: None,
1335 images: None,
1336 error: Some(err_msg),
1337 connection_required: None,
1338 raw_output: None,
1339 },
1340 success: false,
1341 status: "error".to_string(),
1342 connection_required: None,
1343 determinism_fatal: None,
1344 };
1345 }
1346 }
1347 }
1348
1349 Ok(ToolCallClaimResult::DeterminismViolation {
1350 stored_fingerprint,
1351 current_fingerprint,
1352 }) => {
1353 let err_msg = format!(
1354 "determinism violation: tool '{}' args fingerprint changed \
1355 on replay (stored={stored_fingerprint}, \
1356 current={current_fingerprint})",
1357 tool_call.name
1358 );
1359 tracing::error!(
1360 session_id = %context.session_id,
1361 turn_id = %context.turn_id,
1362 tool_call_id = %tool_call.id,
1363 stored = %stored_fingerprint,
1364 current = %current_fingerprint,
1365 "ActAtom: determinism violation on claim"
1366 );
1367 let result_fp =
1368 tool_result_fingerprint(&tool_call.name, &ToolResult::error(&err_msg));
1369 let _ = self
1370 .event_emitter
1371 .emit(EventRequest::new(
1372 context.session_id,
1373 event_context,
1374 ToolCompletedData::failure(
1375 tool_call.id.clone(),
1376 tool_call.name.clone(),
1377 "error".to_string(),
1378 err_msg.clone(),
1379 None,
1380 )
1381 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1382 .with_display_name(display_name.clone()),
1383 ))
1384 .await;
1385 return ToolCallResult {
1386 tool_call: tool_call.clone(),
1387 result: ToolResult {
1388 tool_call_id: tool_call.id.clone(),
1389 result: None,
1390 images: None,
1391 error: Some(err_msg.clone()),
1392 connection_required: None,
1393 raw_output: None,
1394 },
1395 success: false,
1396 status: "error".to_string(),
1397 connection_required: None,
1398 determinism_fatal: Some(err_msg),
1399 };
1400 }
1401
1402 Err(e) => {
1403 tracing::warn!(
1404 session_id = %context.session_id,
1405 tool_call_id = %tool_call.id,
1406 error = %e,
1407 "ActAtom: durable claim failed; proceeding without idempotency"
1408 );
1409 None
1410 }
1411 }
1412 } else {
1413 None
1414 };
1415
1416 if let Err(e) = self
1418 .event_emitter
1419 .emit(EventRequest::new(
1420 context.session_id,
1421 event_context.clone(),
1422 ToolStartedData {
1423 tool_call: tool_call.clone(),
1424 tool_call_fingerprint: Some(tool_call_fingerprint.clone()),
1425 display_name: display_name.clone(),
1426 narration: Some(self.render_tool_narration(
1427 context,
1428 tool_def,
1429 &tool_call,
1430 ToolNarrationPhase::Started,
1431 locale,
1432 )),
1433 },
1434 ))
1435 .await
1436 {
1437 tracing::warn!(
1438 session_id = %context.session_id,
1439 tool_call_id = %tool_call.id,
1440 error = %e,
1441 "ActAtom: failed to emit tool.started event"
1442 );
1443 }
1444
1445 let Some(tool_def) = tool_def else {
1447 let error_msg = format!("Tool definition not found: {}", tool_call.name);
1448 let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1449
1450 if let Err(e) = self
1452 .event_emitter
1453 .emit(EventRequest::new(
1454 context.session_id,
1455 event_context,
1456 ToolCompletedData::failure(
1457 tool_call.id.clone(),
1458 tool_call.name.clone(),
1459 "error".to_string(),
1460 error_msg.clone(),
1461 Some(tool_duration_ms),
1462 )
1463 .with_fingerprints(
1464 tool_call_fingerprint.clone(),
1465 tool_error_fingerprint(&tool_call.name, "error", &error_msg),
1466 )
1467 .with_narration(Some(self.render_tool_narration(
1468 context,
1469 None,
1470 &tool_call,
1471 ToolNarrationPhase::Failed,
1472 locale,
1473 ))),
1474 ))
1475 .await
1476 {
1477 tracing::warn!(
1478 session_id = %context.session_id,
1479 tool_call_id = %tool_call.id,
1480 error = %e,
1481 "ActAtom: failed to emit tool.completed event"
1482 );
1483 }
1484
1485 return ToolCallResult {
1486 tool_call: tool_call.clone(),
1487 result: ToolResult {
1488 tool_call_id: tool_call.id.clone(),
1489 result: None,
1490 images: None,
1491 error: Some(error_msg),
1492 connection_required: None,
1493 raw_output: None,
1494 },
1495 success: false,
1496 status: "error".to_string(),
1497 connection_required: None,
1498 determinism_fatal: None,
1499 };
1500 };
1501
1502 let mut tool_context =
1504 ToolContext::from_services(context.session_id, &self.context_services);
1505 if let Some(workspace_id) = context.workspace_id {
1510 tool_context.workspace_id = workspace_id;
1511 if let Some(store) = tool_context.file_store.take() {
1512 tool_context.file_store = Some(
1513 crate::session_files::WorkspaceScopedFileSystem::wrap(store, workspace_id),
1514 );
1515 }
1516 }
1517 if let Some(store) = tool_context.file_store.take() {
1521 tool_context.file_store = Some(crate::mount_fs::MountFs::wrap_if_needed(store));
1522 }
1523 tool_context.visible_tool_names = Some(visible_tool_names.clone());
1524 tool_context.network_access = network_access
1526 .cloned()
1527 .or_else(|| self.context_services.network_access.clone());
1528 if tool_context.event_emitter.is_none() {
1530 tool_context.event_emitter =
1531 Some(Arc::new(self.event_emitter.clone()) as Arc<dyn EventEmitter>);
1532 }
1533 tool_context.event_context = Some(event_context.clone());
1534 tool_context.tool_call_id = Some(tool_call.id.clone());
1535
1536 let call_cancellation = tokio_util::sync::CancellationToken::new();
1544 tool_context.cancellation = Some(call_cancellation.clone());
1545 let _cancel_on_call_end = call_cancellation.drop_guard();
1546
1547 let execution_tool_call = self.transform_tool_call_for_execution(tool_call.clone());
1548
1549 let (execution_tool_call, pre_block_reason) = if self.pre_tool_hooks.is_empty() {
1554 (execution_tool_call, None)
1555 } else {
1556 match act_hooks::run_pre_tool_use_hooks(
1557 &self.pre_tool_hooks,
1558 execution_tool_call.clone(),
1559 tool_def,
1560 &tool_context,
1561 )
1562 .await
1563 {
1564 act_hooks::PreToolUseDecision::Continue(updated) => (updated, None),
1565 act_hooks::PreToolUseDecision::Block {
1566 tool_call: blocked,
1567 reason,
1568 ..
1569 } => (blocked, Some(reason)),
1570 }
1571 };
1572
1573 let result = if let Some(reason) = pre_block_reason {
1574 tracing::warn!(
1575 session_id = %context.session_id,
1576 tool_call_id = %execution_tool_call.id,
1577 tool_name = %execution_tool_call.name,
1578 reason = %reason,
1579 "ActAtom: pre_tool_use hook blocked execution"
1580 );
1581 Ok(crate::tool_types::ToolResult {
1582 tool_call_id: execution_tool_call.id.clone(),
1583 result: None,
1584 images: None,
1585 error: Some(format!("blocked by pre_tool_use hook: {reason}")),
1586 connection_required: None,
1587 raw_output: None,
1588 })
1589 } else if tool_def.is_cpu_bound() {
1590 let executor = self.tool_executor.clone();
1596 let call = execution_tool_call.clone();
1597 let def = tool_def.clone();
1598 let ctx = tool_context.clone();
1599 match AbortOnDropJoinHandle::new(tokio::spawn(async move {
1600 executor.execute_with_context(&call, &def, &ctx).await
1601 }))
1602 .await
1603 {
1604 Ok(result) => result,
1605 Err(join_err) => Err(crate::error::AgentLoopError::tool(format!(
1606 "tool task failed to complete: {join_err}"
1607 ))),
1608 }
1609 } else {
1610 self.tool_executor
1611 .execute_with_context(&execution_tool_call, tool_def, &tool_context)
1612 .await
1613 };
1614
1615 match result {
1616 Ok(mut tool_result) => {
1617 act_hooks::run_post_tool_exec_hooks(
1619 &self.post_tool_hooks,
1620 &self.final_post_tool_hooks,
1621 &execution_tool_call,
1622 tool_def,
1623 &mut tool_result,
1624 &tool_context,
1625 )
1626 .await;
1627
1628 let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1629 let success = tool_result.error.is_none();
1630 let status = if success { "success" } else { "error" };
1631
1632 let completed_data = if success {
1634 let result_fingerprint = tool_result_fingerprint(&tool_call.name, &tool_result);
1635 let mut result_content = tool_result
1637 .result
1638 .as_ref()
1639 .map(|r| vec![ContentPart::tool_result_text(r)])
1640 .unwrap_or_default();
1641 if let Some(ref images) = tool_result.images {
1643 for img in images {
1644 result_content.push(ContentPart::Image(
1645 crate::message::ImageContentPart::from_base64(
1646 &img.base64,
1647 &img.media_type,
1648 ),
1649 ));
1650 }
1651 }
1652 ToolCompletedData::success(
1653 tool_call.id.clone(),
1654 tool_call.name.clone(),
1655 result_content,
1656 Some(tool_duration_ms),
1657 )
1658 .with_fingerprints(tool_call_fingerprint.clone(), result_fingerprint)
1659 .with_display_name(display_name.clone())
1660 .with_capability_attribution(
1661 capability_attribution.as_ref().map(|(id, _)| id.clone()),
1662 capability_attribution
1663 .as_ref()
1664 .and_then(|(_, name)| name.clone()),
1665 )
1666 .with_narration(Some(self.render_tool_narration(
1667 context,
1668 Some(tool_def),
1669 &tool_call,
1670 ToolNarrationPhase::Completed,
1671 locale,
1672 )))
1673 } else {
1674 let result_fingerprint = tool_result_fingerprint(&tool_call.name, &tool_result);
1675 ToolCompletedData::failure(
1676 tool_call.id.clone(),
1677 tool_call.name.clone(),
1678 status.to_string(),
1679 tool_result.error.clone().unwrap_or_default(),
1680 Some(tool_duration_ms),
1681 )
1682 .with_fingerprints(tool_call_fingerprint.clone(), result_fingerprint)
1683 .with_display_name(display_name.clone())
1684 .with_capability_attribution(
1685 capability_attribution.as_ref().map(|(id, _)| id.clone()),
1686 capability_attribution
1687 .as_ref()
1688 .and_then(|(_, name)| name.clone()),
1689 )
1690 .with_narration(Some(self.render_tool_narration(
1691 context,
1692 Some(tool_def),
1693 &tool_call,
1694 ToolNarrationPhase::Failed,
1695 locale,
1696 )))
1697 };
1698
1699 if let Err(e) = self
1700 .event_emitter
1701 .emit(EventRequest::new(
1702 context.session_id,
1703 event_context.clone(),
1704 completed_data,
1705 ))
1706 .await
1707 {
1708 tracing::warn!(
1709 session_id = %context.session_id,
1710 tool_call_id = %tool_call.id,
1711 error = %e,
1712 "ActAtom: failed to emit tool.completed event"
1713 );
1714 }
1715
1716 tracing::debug!(
1717 session_id = %context.session_id,
1718 tool_name = %tool_call.name,
1719 tool_call_id = %tool_call.id,
1720 success = %success,
1721 "ActAtom: tool execution completed"
1722 );
1723
1724 if let (Some(store), Some(token)) = (&self.durable_tool_result_store, claim_token) {
1726 let result_snapshot =
1727 serde_json::to_value(&tool_result).unwrap_or(serde_json::Value::Null);
1728 match store
1729 .settle_tool_call(
1730 &context.turn_id.to_string(),
1731 &tool_call.id,
1732 result_snapshot,
1733 "settled",
1734 token,
1735 )
1736 .await
1737 {
1738 Ok(false) => {
1739 tracing::warn!(
1740 session_id = %context.session_id,
1741 tool_call_id = %tool_call.id,
1742 "ActAtom: settle ownership check failed (task reclaimed)"
1743 );
1744 }
1745 Err(e) => {
1746 tracing::warn!(
1747 session_id = %context.session_id,
1748 tool_call_id = %tool_call.id,
1749 error = %e,
1750 "ActAtom: settle_tool_call failed"
1751 );
1752 }
1753 Ok(true) => {}
1754 }
1755 }
1756
1757 let conn_req = tool_result.connection_required.clone();
1758 ToolCallResult {
1759 tool_call,
1760 result: tool_result,
1761 success,
1762 status: status.to_string(),
1763 connection_required: conn_req,
1764 determinism_fatal: None,
1765 }
1766 }
1767 Err(e) => {
1768 let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1769 let error_msg = e.to_string();
1770
1771 if let Err(emit_err) = self
1773 .event_emitter
1774 .emit(EventRequest::new(
1775 context.session_id,
1776 event_context,
1777 ToolCompletedData::failure(
1778 tool_call.id.clone(),
1779 tool_call.name.clone(),
1780 "error".to_string(),
1781 error_msg.clone(),
1782 Some(tool_duration_ms),
1783 )
1784 .with_fingerprints(
1785 tool_call_fingerprint.clone(),
1786 tool_error_fingerprint(&tool_call.name, "error", &error_msg),
1787 )
1788 .with_display_name(display_name.clone())
1789 .with_capability_attribution(
1790 capability_attribution.as_ref().map(|(id, _)| id.clone()),
1791 capability_attribution
1792 .as_ref()
1793 .and_then(|(_, name)| name.clone()),
1794 )
1795 .with_narration(Some(self.render_tool_narration(
1796 context,
1797 Some(tool_def),
1798 &tool_call,
1799 ToolNarrationPhase::Failed,
1800 locale,
1801 ))),
1802 ))
1803 .await
1804 {
1805 tracing::warn!(
1806 session_id = %context.session_id,
1807 tool_call_id = %tool_call.id,
1808 error = %emit_err,
1809 "ActAtom: failed to emit tool.completed event"
1810 );
1811 }
1812
1813 tracing::warn!(
1814 session_id = %context.session_id,
1815 tool_name = %tool_call.name,
1816 tool_call_id = %tool_call.id,
1817 error = %e,
1818 "ActAtom: tool execution failed"
1819 );
1820
1821 ToolCallResult {
1822 tool_call: tool_call.clone(),
1823 result: ToolResult {
1824 tool_call_id: tool_call.id.clone(),
1825 result: None,
1826 images: None,
1827 error: Some(error_msg),
1828 connection_required: None,
1829 raw_output: None,
1830 },
1831 success: false,
1832 status: "error".to_string(),
1833 connection_required: None,
1834 determinism_fatal: None,
1835 }
1836 }
1837 }
1838 }
1839}
1840
1841#[cfg(test)]
1846mod tests {
1847 use super::*;
1848 use crate::test_fixtures::NoopEventEmitter;
1849 use crate::tools::ToolRegistry;
1850 use crate::typed_id::{AgentId, HarnessId, MessageId, SessionId, TurnId};
1851 use async_trait::async_trait;
1852 use everruns_core::{Capability, DisabledUtilityLlmService, Tool, ToolExecutionResult};
1853 use everruns_provider::{BuiltinTool, ClientSideTool};
1854 use serde_json::json;
1855
1856 struct ArgumentEchoTool;
1857
1858 struct NarratingGrepTool;
1859
1860 struct HumanIntentFixtureHook;
1861
1862 impl crate::capabilities::ToolCallHook for HumanIntentFixtureHook {
1863 fn narration(
1864 &self,
1865 _tool_def: Option<&ToolDefinition>,
1866 tool_call: &ToolCall,
1867 _phase: crate::tool_narration::ToolNarrationPhase,
1868 _locale: Option<&str>,
1869 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1870 ) -> Option<String> {
1871 crate::tool_types::human_intent(&tool_call.arguments).map(str::to_string)
1872 }
1873
1874 fn transform_for_execution(&self, mut tool_call: ToolCall) -> ToolCall {
1875 tool_call.arguments = tool_call.execution_arguments();
1876 tool_call
1877 }
1878 }
1879
1880 #[async_trait]
1881 impl crate::tools::Tool for NarratingGrepTool {
1882 fn name(&self) -> &str {
1883 "grep_files"
1884 }
1885
1886 fn description(&self) -> &str {
1887 "Search files"
1888 }
1889
1890 fn parameters_schema(&self) -> serde_json::Value {
1891 json!({"type": "object"})
1892 }
1893
1894 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1895 ToolExecutionResult::success(json!({}))
1896 }
1897
1898 fn narrate(
1899 &self,
1900 tool_call: &ToolCall,
1901 phase: crate::tool_narration::ToolNarrationPhase,
1902 locale: Option<&str>,
1903 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1904 ) -> Option<String> {
1905 Some(crate::tool_narration::narrate_grep_files(
1906 &tool_call.arguments,
1907 phase,
1908 locale,
1909 ))
1910 }
1911 }
1912
1913 struct NarratingCapability;
1914
1915 #[async_trait]
1916 impl Capability for NarratingCapability {
1917 fn id(&self) -> &str {
1918 "narrating_test"
1919 }
1920
1921 fn name(&self) -> &str {
1922 "Narrating test"
1923 }
1924
1925 fn description(&self) -> &str {
1926 "Test-only narration capability"
1927 }
1928
1929 fn tools(&self) -> Vec<Box<dyn Tool>> {
1930 vec![Box::new(NarratingGrepTool)]
1931 }
1932 }
1933
1934 #[async_trait]
1935 impl crate::tools::Tool for ArgumentEchoTool {
1936 fn name(&self) -> &str {
1937 "argument_echo"
1938 }
1939
1940 fn description(&self) -> &str {
1941 "returns the execution arguments"
1942 }
1943
1944 fn parameters_schema(&self) -> serde_json::Value {
1945 json!({
1946 "type": "object",
1947 "properties": {
1948 "value": { "type": "string" }
1949 }
1950 })
1951 }
1952
1953 async fn execute(&self, arguments: serde_json::Value) -> ToolExecutionResult {
1954 ToolExecutionResult::success(arguments)
1955 }
1956 }
1957
1958 #[test]
1959 fn grouped_headline_uses_tool_owned_narration_for_repeated_actions() {
1960 use crate::capabilities::{Capability, CapabilityNarrationHook};
1961
1962 let capability: Arc<dyn Capability> = Arc::new(NarratingCapability);
1963 let tool_definitions = capability
1964 .tools()
1965 .into_iter()
1966 .map(|tool| tool.to_definition())
1967 .collect::<Vec<_>>();
1968 let tool_map = tool_definitions
1969 .iter()
1970 .map(|tool_def| (tool_def.name(), tool_def))
1971 .collect::<std::collections::HashMap<_, _>>();
1972 let atom = ActAtom::new(ToolRegistry::new(), NoopEventEmitter)
1973 .with_tool_call_hooks(vec![Arc::new(CapabilityNarrationHook(capability))]);
1974 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
1975 let tool_calls = vec![
1976 ToolCall {
1977 id: "grep-1".to_string(),
1978 name: "grep_files".to_string(),
1979 arguments: json!({ "pattern": "full_name" }),
1980 },
1981 ToolCall {
1982 id: "grep-2".to_string(),
1983 name: "grep_files".to_string(),
1984 arguments: json!({ "pattern": "login" }),
1985 },
1986 ];
1987
1988 assert_eq!(
1989 atom.render_group_headline(
1990 &context,
1991 &tool_calls,
1992 &tool_map,
1993 ToolNarrationPhase::Started,
1994 None,
1995 )
1996 .as_deref(),
1997 Some("Searching files twice")
1998 );
1999 assert_eq!(
2000 atom.render_group_headline(
2001 &context,
2002 &tool_calls,
2003 &tool_map,
2004 ToolNarrationPhase::Completed,
2005 None,
2006 )
2007 .as_deref(),
2008 Some("Searched files twice")
2009 );
2010 }
2011
2012 #[test]
2017 fn registry_owned_tool_narrates_itself_without_a_capability_hook() {
2018 let mut registry = ToolRegistry::new();
2019 registry.register_boxed(Box::new(NarratingGrepTool));
2020 let atom = ActAtom::new(ToolRegistry::new(), NoopEventEmitter)
2021 .with_tool_registry(Arc::new(registry));
2022 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2023 let tool_call = ToolCall {
2024 id: "grep-1".to_string(),
2025 name: "grep_files".to_string(),
2026 arguments: json!({ "pattern": "full_name" }),
2027 };
2028
2029 assert_eq!(
2030 atom.render_tool_narration(
2031 &context,
2032 None,
2033 &tool_call,
2034 ToolNarrationPhase::Started,
2035 None,
2036 ),
2037 "Searching files for full_name"
2038 );
2039 }
2040
2041 struct UtilityLlmContextProbeTool;
2042
2043 #[async_trait]
2044 impl crate::tools::Tool for UtilityLlmContextProbeTool {
2045 fn name(&self) -> &str {
2046 "utility_llm_context_probe"
2047 }
2048
2049 fn description(&self) -> &str {
2050 "checks whether the utility LLM service is present in tool context"
2051 }
2052
2053 fn parameters_schema(&self) -> serde_json::Value {
2054 json!({
2055 "type": "object",
2056 "properties": {}
2057 })
2058 }
2059
2060 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2061 ToolExecutionResult::tool_error("context required")
2062 }
2063
2064 async fn execute_with_context(
2065 &self,
2066 _arguments: serde_json::Value,
2067 context: &crate::tool_context::ToolContext,
2068 ) -> ToolExecutionResult {
2069 ToolExecutionResult::success(json!({
2070 "utility_llm_service": context.utility_llm_service.is_some(),
2071 "configured": context
2072 .utility_llm_service
2073 .as_ref()
2074 .is_some_and(|service| service.is_configured()),
2075 }))
2076 }
2077
2078 fn requires_context(&self) -> bool {
2079 true
2080 }
2081 }
2082
2083 #[derive(Default)]
2085 struct SchedObservations {
2086 class_inflight: std::collections::HashMap<String, usize>,
2088 class_max: std::collections::HashMap<String, usize>,
2090 global_inflight: usize,
2092 global_max: usize,
2094 }
2095
2096 struct RecordingTool {
2099 name: String,
2100 class: Option<String>,
2101 obs: Arc<std::sync::Mutex<SchedObservations>>,
2102 }
2103
2104 #[async_trait]
2105 impl crate::tools::Tool for RecordingTool {
2106 fn name(&self) -> &str {
2107 &self.name
2108 }
2109 fn description(&self) -> &str {
2110 "records scheduling order"
2111 }
2112 fn parameters_schema(&self) -> serde_json::Value {
2113 json!({ "type": "object", "properties": {} })
2114 }
2115 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2116 {
2118 let mut obs = self.obs.lock().unwrap();
2119 obs.global_inflight += 1;
2120 let g = obs.global_inflight;
2121 if g > obs.global_max {
2122 obs.global_max = g;
2123 }
2124 if let Some(class) = &self.class {
2125 let n = obs.class_inflight.entry(class.clone()).or_default();
2126 *n += 1;
2127 let cur = *n;
2128 let m = obs.class_max.entry(class.clone()).or_default();
2129 if cur > *m {
2130 *m = cur;
2131 }
2132 }
2133 }
2134 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2136 {
2138 let mut obs = self.obs.lock().unwrap();
2139 obs.global_inflight -= 1;
2140 if let Some(class) = &self.class
2141 && let Some(n) = obs.class_inflight.get_mut(class)
2142 {
2143 *n -= 1;
2144 }
2145 }
2146 ToolExecutionResult::success(json!({ "tool": self.name }))
2147 }
2148 }
2149
2150 struct CancellationProbeTool {
2151 started: Arc<tokio::sync::Notify>,
2152 dropped_tx: Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
2153 }
2154
2155 impl CancellationProbeTool {
2156 fn new(
2157 started: Arc<tokio::sync::Notify>,
2158 dropped_tx: tokio::sync::oneshot::Sender<()>,
2159 ) -> Self {
2160 Self {
2161 started,
2162 dropped_tx: Arc::new(std::sync::Mutex::new(Some(dropped_tx))),
2163 }
2164 }
2165 }
2166
2167 #[async_trait]
2168 impl crate::tools::Tool for CancellationProbeTool {
2169 fn name(&self) -> &str {
2170 "cancellation_probe"
2171 }
2172
2173 fn description(&self) -> &str {
2174 "waits until cancelled"
2175 }
2176
2177 fn parameters_schema(&self) -> serde_json::Value {
2178 json!({ "type": "object", "properties": {} })
2179 }
2180
2181 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2182 struct DropSignal {
2183 tx: Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
2184 }
2185
2186 impl Drop for DropSignal {
2187 fn drop(&mut self) {
2188 if let Ok(mut guard) = self.tx.lock()
2189 && let Some(tx) = guard.take()
2190 {
2191 let _ = tx.send(());
2192 }
2193 }
2194 }
2195
2196 let _drop_signal = DropSignal {
2197 tx: self.dropped_tx.clone(),
2198 };
2199 self.started.notify_one();
2200 std::future::pending::<()>().await;
2201 unreachable!("pending cancellation probe should only finish by cancellation")
2202 }
2203 }
2204
2205 fn recording_tool_def(name: &str, class: Option<&str>, cpu_bound: bool) -> ToolDefinition {
2207 let mut hints = crate::tool_types::ToolHints::default();
2208 if let Some(class) = class {
2209 hints = hints.with_concurrency_class(class);
2210 }
2211 if cpu_bound {
2212 hints = hints.with_cpu_bound(true);
2213 }
2214 ToolDefinition::Builtin(BuiltinTool {
2215 name: name.to_string(),
2216 display_name: None,
2217 description: "records scheduling order".to_string(),
2218 parameters: json!({ "type": "object", "properties": {} }),
2219 policy: Default::default(),
2220 category: None,
2221 deferrable: Default::default(),
2222 hints,
2223 full_parameters: None,
2224 })
2225 }
2226
2227 #[tokio::test]
2228 async fn test_act_atom_empty_tool_calls() {
2229 let executor = ToolRegistry::with_defaults();
2230 let event_emitter = NoopEventEmitter;
2231 let atom = ActAtom::new(executor, event_emitter);
2232
2233 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2234 let input = ActInput {
2235 org_id: Some(1),
2236 context,
2237 harness_id: HarnessId::from_seed(1),
2238 agent_id: Some(AgentId::new()),
2239 tool_calls: vec![],
2240 tool_definitions: vec![],
2241 locale: None,
2242 blueprint_id: None,
2243 network_access: None,
2244 parallel_tool_calls: None,
2245 };
2246
2247 let result = atom.execute(input).await.unwrap();
2248
2249 assert!(result.completed);
2250 assert!(result.results.is_empty());
2251 assert_eq!(result.success_count, 0);
2252 assert_eq!(result.error_count, 0);
2253 }
2254
2255 #[tokio::test]
2256 async fn test_act_atom_threads_utility_llm_service_to_tool_context() {
2257 let mut executor = ToolRegistry::with_defaults();
2258 executor.register(UtilityLlmContextProbeTool);
2259 let event_emitter = NoopEventEmitter;
2260 let atom = ActAtom::new(executor, event_emitter)
2261 .with_utility_llm_service(Arc::new(DisabledUtilityLlmService));
2262
2263 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2264 let input = ActInput {
2265 org_id: Some(1),
2266 context,
2267 harness_id: HarnessId::from_seed(1),
2268 agent_id: Some(AgentId::new()),
2269 tool_calls: vec![ToolCall {
2270 id: "call_1".to_string(),
2271 name: "utility_llm_context_probe".to_string(),
2272 arguments: json!({}),
2273 }],
2274 tool_definitions: vec![ToolDefinition::Builtin(BuiltinTool {
2275 name: "utility_llm_context_probe".to_string(),
2276 display_name: None,
2277 description: "checks context".to_string(),
2278 parameters: json!({
2279 "type": "object",
2280 "properties": {}
2281 }),
2282 policy: Default::default(),
2283 category: None,
2284 deferrable: Default::default(),
2285 hints: crate::tool_types::ToolHints::default(),
2286 full_parameters: None,
2287 })],
2288 locale: None,
2289 blueprint_id: None,
2290 network_access: None,
2291 parallel_tool_calls: None,
2292 };
2293
2294 let result = atom.execute(input).await.unwrap();
2295
2296 assert_eq!(result.success_count, 1);
2297 let payload = result.results[0].result.result.as_ref().unwrap();
2298 assert_eq!(payload["utility_llm_service"], true);
2299 assert_eq!(payload["configured"], false);
2300 }
2301
2302 #[tokio::test]
2307 async fn test_act_atom_schedules_batch_by_concurrency_class() {
2308 let obs = Arc::new(std::sync::Mutex::new(SchedObservations::default()));
2309
2310 let mut executor = ToolRegistry::new();
2311 executor.register(RecordingTool {
2312 name: "writer_a".to_string(),
2313 class: Some("ws".to_string()),
2314 obs: obs.clone(),
2315 });
2316 executor.register(RecordingTool {
2317 name: "writer_b".to_string(),
2318 class: Some("ws".to_string()),
2319 obs: obs.clone(),
2320 });
2321 executor.register(RecordingTool {
2322 name: "reader".to_string(),
2323 class: None,
2324 obs: obs.clone(),
2325 });
2326
2327 let atom = ActAtom::new(executor, NoopEventEmitter);
2328 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2329
2330 let input = ActInput {
2333 org_id: Some(1),
2334 context,
2335 harness_id: HarnessId::from_seed(1),
2336 agent_id: Some(AgentId::new()),
2337 tool_calls: vec![
2338 ToolCall {
2339 id: "call_a".to_string(),
2340 name: "writer_a".to_string(),
2341 arguments: json!({}),
2342 },
2343 ToolCall {
2344 id: "call_r".to_string(),
2345 name: "reader".to_string(),
2346 arguments: json!({}),
2347 },
2348 ToolCall {
2349 id: "call_b".to_string(),
2350 name: "writer_b".to_string(),
2351 arguments: json!({}),
2352 },
2353 ],
2354 tool_definitions: vec![
2355 recording_tool_def("writer_a", Some("ws"), false),
2356 recording_tool_def("reader", None, false),
2357 recording_tool_def("writer_b", Some("ws"), true),
2358 ],
2359 locale: None,
2360 blueprint_id: None,
2361 network_access: None,
2362 parallel_tool_calls: None,
2363 };
2364
2365 let result = atom.execute(input).await.unwrap();
2366
2367 assert_eq!(result.success_count, 3, "all three tools should succeed");
2369 let names: Vec<&str> = result
2371 .results
2372 .iter()
2373 .map(|r| r.tool_call.name.as_str())
2374 .collect();
2375 assert_eq!(names, vec!["writer_a", "reader", "writer_b"]);
2376
2377 let obs = obs.lock().unwrap();
2378 assert_eq!(
2381 obs.class_max.get("ws").copied(),
2382 Some(1),
2383 "same-class tools must serialize"
2384 );
2385 assert!(
2388 obs.global_max >= 2,
2389 "independent tool should run concurrently with the class group (global_max={})",
2390 obs.global_max
2391 );
2392 }
2393
2394 struct DetachedWorkTool {
2398 cancelled_tx: Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
2399 }
2400
2401 impl DetachedWorkTool {
2402 fn new(cancelled_tx: tokio::sync::oneshot::Sender<()>) -> Self {
2403 Self {
2404 cancelled_tx: Arc::new(std::sync::Mutex::new(Some(cancelled_tx))),
2405 }
2406 }
2407 }
2408
2409 #[async_trait]
2410 impl crate::tools::Tool for DetachedWorkTool {
2411 fn name(&self) -> &str {
2412 "detached_work"
2413 }
2414
2415 fn description(&self) -> &str {
2416 "spawns work that outlives the call unless cancelled"
2417 }
2418
2419 fn parameters_schema(&self) -> serde_json::Value {
2420 json!({ "type": "object", "properties": {} })
2421 }
2422
2423 fn requires_context(&self) -> bool {
2424 true
2425 }
2426
2427 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2428 ToolExecutionResult::tool_error("requires context")
2429 }
2430
2431 async fn execute_with_context(
2432 &self,
2433 _arguments: serde_json::Value,
2434 context: &crate::tool_context::ToolContext,
2435 ) -> ToolExecutionResult {
2436 let token = context
2437 .cancellation
2438 .clone()
2439 .expect("act must supply a cancellation token");
2440 assert!(!token.is_cancelled(), "token is live during the call");
2441 let tx = self.cancelled_tx.clone();
2442 tokio::spawn(async move {
2443 token.cancelled().await;
2444 if let Ok(mut guard) = tx.lock()
2445 && let Some(tx) = guard.take()
2446 {
2447 let _ = tx.send(());
2448 }
2449 });
2450 ToolExecutionResult::success(json!({ "spawned": true }))
2451 }
2452 }
2453
2454 #[tokio::test]
2458 async fn test_act_atom_cancels_detached_tool_work_when_the_call_ends() {
2459 let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel();
2460
2461 let mut executor = ToolRegistry::new();
2462 executor.register(DetachedWorkTool::new(cancelled_tx));
2463
2464 let atom = ActAtom::new(executor, NoopEventEmitter);
2465 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2466 let input = ActInput {
2467 org_id: Some(1),
2468 context,
2469 harness_id: HarnessId::from_seed(1),
2470 agent_id: Some(AgentId::new()),
2471 tool_calls: vec![ToolCall {
2472 id: "call_1".to_string(),
2473 name: "detached_work".to_string(),
2474 arguments: json!({}),
2475 }],
2476 tool_definitions: vec![recording_tool_def("detached_work", None, false)],
2477 locale: None,
2478 blueprint_id: None,
2479 network_access: None,
2480 parallel_tool_calls: None,
2481 };
2482
2483 atom.execute(input).await.expect("act should succeed");
2484
2485 tokio::time::timeout(std::time::Duration::from_secs(1), cancelled_rx)
2486 .await
2487 .expect("detached work should be cancelled once the call ends")
2488 .expect("cancellation signal should be sent");
2489 }
2490
2491 #[tokio::test]
2492 async fn test_act_atom_cancels_detached_tool_work_when_the_turn_is_cancelled() {
2493 let started = Arc::new(tokio::sync::Notify::new());
2494 let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
2495
2496 let mut executor = ToolRegistry::new();
2497 executor.register(CancellationProbeTool::new(started.clone(), dropped_tx));
2498
2499 let atom = ActAtom::new(executor, NoopEventEmitter);
2500 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2501 let input = ActInput {
2502 org_id: Some(1),
2503 context,
2504 harness_id: HarnessId::from_seed(1),
2505 agent_id: Some(AgentId::new()),
2506 tool_calls: vec![ToolCall {
2507 id: "call_1".to_string(),
2508 name: "cancellation_probe".to_string(),
2509 arguments: json!({}),
2510 }],
2511 tool_definitions: vec![recording_tool_def("cancellation_probe", None, true)],
2512 locale: None,
2513 blueprint_id: None,
2514 network_access: None,
2515 parallel_tool_calls: None,
2516 };
2517
2518 let act_task = tokio::spawn(async move { atom.execute(input).await });
2519 started.notified().await;
2520 act_task.abort();
2521 assert!(act_task.await.unwrap_err().is_cancelled());
2522
2523 tokio::time::timeout(std::time::Duration::from_secs(1), dropped_rx)
2525 .await
2526 .expect("tool future should be dropped when the turn is cancelled")
2527 .expect("drop signal should be sent");
2528 }
2529
2530 #[tokio::test]
2531 async fn test_act_atom_aborts_cpu_bound_tool_task_on_cancellation() {
2532 let started = Arc::new(tokio::sync::Notify::new());
2533 let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
2534
2535 let mut executor = ToolRegistry::new();
2536 executor.register(CancellationProbeTool::new(started.clone(), dropped_tx));
2537
2538 let atom = ActAtom::new(executor, NoopEventEmitter);
2539 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2540 let input = ActInput {
2541 org_id: Some(1),
2542 context,
2543 harness_id: HarnessId::from_seed(1),
2544 agent_id: Some(AgentId::new()),
2545 tool_calls: vec![ToolCall {
2546 id: "call_1".to_string(),
2547 name: "cancellation_probe".to_string(),
2548 arguments: json!({}),
2549 }],
2550 tool_definitions: vec![recording_tool_def("cancellation_probe", None, true)],
2551 locale: None,
2552 blueprint_id: None,
2553 network_access: None,
2554 parallel_tool_calls: None,
2555 };
2556
2557 let act_task = tokio::spawn(async move { atom.execute(input).await });
2558 started.notified().await;
2559 act_task.abort();
2560 assert!(act_task.await.unwrap_err().is_cancelled());
2561
2562 tokio::time::timeout(std::time::Duration::from_secs(1), dropped_rx)
2563 .await
2564 .expect("cpu-bound tool task should be aborted when ActAtom is cancelled")
2565 .expect("drop signal should be sent by cancelled tool future");
2566 }
2567
2568 #[tokio::test]
2571 async fn test_act_atom_parallel_tool_calls_false_serializes_everything() {
2572 let obs = Arc::new(std::sync::Mutex::new(SchedObservations::default()));
2573 let mut executor = ToolRegistry::new();
2574 for name in ["t0", "t1", "t2"] {
2575 executor.register(RecordingTool {
2576 name: name.to_string(),
2577 class: None,
2578 obs: obs.clone(),
2579 });
2580 }
2581 let atom = ActAtom::new(executor, NoopEventEmitter);
2582 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2583 let input = ActInput {
2584 org_id: Some(1),
2585 context,
2586 harness_id: HarnessId::from_seed(1),
2587 agent_id: Some(AgentId::new()),
2588 tool_calls: vec![
2589 ToolCall {
2590 id: "c0".to_string(),
2591 name: "t0".to_string(),
2592 arguments: json!({}),
2593 },
2594 ToolCall {
2595 id: "c1".to_string(),
2596 name: "t1".to_string(),
2597 arguments: json!({}),
2598 },
2599 ToolCall {
2600 id: "c2".to_string(),
2601 name: "t2".to_string(),
2602 arguments: json!({}),
2603 },
2604 ],
2605 tool_definitions: vec![
2606 recording_tool_def("t0", None, false),
2607 recording_tool_def("t1", None, false),
2608 recording_tool_def("t2", None, false),
2609 ],
2610 locale: None,
2611 blueprint_id: None,
2612 network_access: None,
2613 parallel_tool_calls: Some(false),
2614 };
2615
2616 let result = atom.execute(input).await.unwrap();
2617 assert_eq!(result.success_count, 3);
2618 assert_eq!(
2619 obs.lock().unwrap().global_max,
2620 1,
2621 "parallel_tool_calls=false must serialize the whole batch"
2622 );
2623 }
2624
2625 #[tokio::test]
2626 async fn test_act_atom_tool_not_found() {
2627 let executor = ToolRegistry::with_defaults();
2628 let event_emitter = NoopEventEmitter;
2629 let atom = ActAtom::new(executor, event_emitter);
2630
2631 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2632 let input = ActInput {
2633 org_id: Some(1),
2634 context,
2635 harness_id: HarnessId::from_seed(1),
2636 agent_id: Some(AgentId::new()),
2637 tool_calls: vec![ToolCall {
2638 id: "call_1".to_string(),
2639 name: "nonexistent_tool".to_string(),
2640 arguments: json!({}),
2641 }],
2642 tool_definitions: vec![],
2643 locale: None,
2644 blueprint_id: None,
2645 network_access: None,
2646 parallel_tool_calls: None,
2647 };
2648
2649 let result = atom.execute(input).await.unwrap();
2650
2651 assert!(result.completed);
2652 assert_eq!(result.results.len(), 1);
2653 assert!(!result.results[0].success);
2654 assert_eq!(result.results[0].status, "error");
2655 assert!(
2656 result.results[0]
2657 .result
2658 .error
2659 .as_ref()
2660 .unwrap()
2661 .contains("not found")
2662 );
2663 }
2664
2665 #[tokio::test]
2666 async fn test_act_atom_uses_tool_call_hooks_for_execution_arguments() {
2667 let mut executor = ToolRegistry::new();
2668 executor.register(ArgumentEchoTool);
2669 let tool_def = executor.get("argument_echo").unwrap().to_definition();
2670 let emitter = crate::test_fixtures::TestEventEmitter::new();
2671 let atom = ActAtom::new(executor, emitter.clone())
2672 .with_tool_call_hooks(vec![std::sync::Arc::new(HumanIntentFixtureHook)]);
2673
2674 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2675 let input = ActInput {
2676 org_id: Some(1),
2677 context,
2678 harness_id: HarnessId::from_seed(1),
2679 agent_id: Some(AgentId::new()),
2680 tool_calls: vec![ToolCall {
2681 id: "call_1".to_string(),
2682 name: "argument_echo".to_string(),
2683 arguments: json!({
2684 "value": "visible",
2685 "human_intent": "Echoing test arguments"
2686 }),
2687 }],
2688 tool_definitions: vec![tool_def],
2689 locale: None,
2690 blueprint_id: None,
2691 network_access: None,
2692 parallel_tool_calls: None,
2693 };
2694
2695 let result = atom.execute(input).await.unwrap();
2696
2697 assert!(result.results[0].success);
2698 assert_eq!(
2699 result.results[0].result.result,
2700 Some(json!({ "value": "visible" }))
2701 );
2702
2703 let events = emitter.events().await;
2704 assert_eq!(
2705 events
2706 .iter()
2707 .map(|event| event.event_type.as_str())
2708 .collect::<Vec<_>>(),
2709 vec![
2710 "act.started",
2711 "tool.started",
2712 "tool.completed",
2713 "act.completed",
2714 ],
2715 "all hosts must observe the engine-owned phase order",
2716 );
2717 let act_started = events
2718 .iter()
2719 .find(|event| event.event_type == "act.started")
2720 .expect("act.started event");
2721 let crate::events::EventData::ActStarted(data) = &act_started.data else {
2722 panic!("expected act.started data");
2723 };
2724 assert_eq!(data.headline.as_deref(), Some("Echoing test arguments"));
2725 assert_eq!(
2726 data.tool_calls[0].narration.as_deref(),
2727 Some("Echoing test arguments")
2728 );
2729
2730 let tool_started = events
2731 .iter()
2732 .find(|event| event.event_type == "tool.started")
2733 .expect("tool.started event");
2734 let crate::events::EventData::ToolStarted(data) = &tool_started.data else {
2735 panic!("expected tool.started data");
2736 };
2737 let started_fingerprint = data
2738 .tool_call_fingerprint
2739 .as_ref()
2740 .expect("tool.started call fingerprint");
2741 assert_eq!(data.narration.as_deref(), Some("Echoing test arguments"));
2742
2743 let tool_completed = events
2744 .iter()
2745 .find(|event| event.event_type == "tool.completed")
2746 .expect("tool.completed event");
2747 let crate::events::EventData::ToolCompleted(data) = &tool_completed.data else {
2748 panic!("expected tool.completed data");
2749 };
2750 assert_eq!(
2751 data.tool_call_fingerprint.as_ref(),
2752 Some(started_fingerprint)
2753 );
2754 assert!(data.tool_result_fingerprint.is_some());
2755 assert_eq!(data.narration.as_deref(), Some("Echoing test arguments"));
2756 }
2757
2758 #[tokio::test]
2759 async fn test_act_atom_strips_human_intent_from_client_tool_calls() {
2760 let executor = ToolRegistry::new();
2761 let emitter = crate::test_fixtures::TestEventEmitter::new();
2762 let atom = ActAtom::new(executor, emitter)
2763 .with_tool_call_hooks(vec![std::sync::Arc::new(HumanIntentFixtureHook)]);
2764
2765 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2766 let input = ActInput {
2767 org_id: Some(1),
2768 context,
2769 harness_id: HarnessId::from_seed(1),
2770 agent_id: Some(AgentId::new()),
2771 tool_calls: vec![ToolCall {
2772 id: "call_client".to_string(),
2773 name: "browser_click".to_string(),
2774 arguments: json!({
2775 "selector": "#btn",
2776 "human_intent": "Clicking approve"
2777 }),
2778 }],
2779 tool_definitions: vec![ToolDefinition::ClientSide(ClientSideTool {
2780 name: "browser_click".to_string(),
2781 display_name: None,
2782 description: "Click button".to_string(),
2783 parameters: json!({
2784 "type": "object",
2785 "properties": {
2786 "selector": {"type": "string"}
2787 },
2788 "required": ["selector"]
2789 }),
2790 category: None,
2791 deferrable: Default::default(),
2792 hints: crate::tool_types::ToolHints::default(),
2793 full_parameters: None,
2794 })],
2795 locale: None,
2796 blueprint_id: None,
2797 network_access: None,
2798 parallel_tool_calls: None,
2799 };
2800
2801 let result = atom.execute(input).await.unwrap();
2802
2803 assert_eq!(result.client_tool_calls.len(), 1);
2804 assert_eq!(
2805 result.client_tool_calls[0].arguments,
2806 json!({ "selector": "#btn" })
2807 );
2808 }
2809
2810 #[test]
2811 fn test_act_result_connection_required_serialization() {
2812 let result = ActResult {
2813 results: vec![ToolCallResult {
2814 tool_call: ToolCall {
2815 id: "call_1".to_string(),
2816 name: "daytona_create_sandbox".to_string(),
2817 arguments: json!({}),
2818 },
2819 result: ToolResult {
2820 tool_call_id: "call_1".to_string(),
2821 result: Some(json!({"connection_required": "daytona"})),
2822 images: None,
2823 error: None,
2824 connection_required: Some("daytona".to_string()),
2825 raw_output: None,
2826 },
2827 success: false,
2828 status: "success".to_string(),
2829 connection_required: Some("daytona".to_string()),
2830 determinism_fatal: None,
2831 }],
2832 completed: true,
2833 success_count: 0,
2834 error_count: 0,
2835 waiting_for_tool_results: true,
2836 blocked: false,
2837 client_tool_calls: vec![],
2838 client_tool_definitions: vec![],
2839 };
2840
2841 let json_str = serde_json::to_string(&result).unwrap();
2842 let parsed: ActResult = serde_json::from_str(&json_str).unwrap();
2843
2844 assert!(parsed.waiting_for_tool_results);
2845 assert_eq!(
2846 parsed.results[0].connection_required,
2847 Some("daytona".to_string())
2848 );
2849 }
2850
2851 #[test]
2852 fn test_act_result_backward_compat_deserialization() {
2853 let json_str = r#"{
2855 "results": [],
2856 "completed": true,
2857 "success_count": 0,
2858 "error_count": 0
2859 }"#;
2860 let parsed: ActResult = serde_json::from_str(json_str).unwrap();
2861
2862 assert!(!parsed.waiting_for_tool_results);
2863 assert!(parsed.client_tool_calls.is_empty());
2864 }
2865
2866 #[tokio::test]
2869 async fn test_outbound_tool_rate_limiter_blocks_execution() {
2870 use crate::typed_id::OrgId;
2871
2872 struct DenyAll;
2873 #[async_trait]
2874 impl crate::tool_execution::OutboundToolRateLimiter for DenyAll {
2875 async fn check_org(&self, _org_id: &OrgId) -> bool {
2876 false
2877 }
2878 }
2879
2880 let mut executor = ToolRegistry::with_defaults();
2881 executor.register(ArgumentEchoTool);
2882 let atom = ActAtom::new(executor, NoopEventEmitter)
2883 .with_org_id(OrgId::from_seed(1))
2884 .with_outbound_tool_rate_limiter(Arc::new(DenyAll));
2885
2886 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2887 let input = ActInput {
2888 org_id: Some(1),
2889 context,
2890 harness_id: HarnessId::from_seed(1),
2891 agent_id: Some(AgentId::new()),
2892 tool_calls: vec![ToolCall {
2893 id: "call_1".to_string(),
2894 name: "argument_echo".to_string(),
2895 arguments: json!({"value": "should_not_reach"}),
2896 }],
2897 tool_definitions: vec![ToolDefinition::Builtin(BuiltinTool {
2898 name: "argument_echo".to_string(),
2899 display_name: None,
2900 description: "echo".to_string(),
2901 parameters: json!({"type": "object"}),
2902 policy: Default::default(),
2903 category: None,
2904 deferrable: Default::default(),
2905 hints: crate::tool_types::ToolHints::default(),
2906 full_parameters: None,
2907 })],
2908 locale: None,
2909 blueprint_id: None,
2910 network_access: None,
2911 parallel_tool_calls: None,
2912 };
2913
2914 let result = atom.execute(input).await.unwrap();
2915
2916 assert_eq!(result.success_count, 0);
2917 assert_eq!(result.error_count, 1);
2918 let tool_result = &result.results[0];
2919 assert!(!tool_result.success);
2920 assert_eq!(tool_result.status, "error");
2921 assert!(
2922 tool_result
2923 .result
2924 .error
2925 .as_deref()
2926 .unwrap_or("")
2927 .contains("rate limit exceeded")
2928 );
2929 assert!(tool_result.result.result.is_none());
2930 }
2931
2932 #[tokio::test]
2934 async fn test_outbound_tool_rate_limiter_allows_execution() {
2935 use crate::typed_id::OrgId;
2936
2937 struct AllowAll;
2938 #[async_trait]
2939 impl crate::tool_execution::OutboundToolRateLimiter for AllowAll {
2940 async fn check_org(&self, _org_id: &OrgId) -> bool {
2941 true
2942 }
2943 }
2944
2945 let mut executor = ToolRegistry::with_defaults();
2946 executor.register(ArgumentEchoTool);
2947 let atom = ActAtom::new(executor, NoopEventEmitter)
2948 .with_org_id(OrgId::from_seed(1))
2949 .with_outbound_tool_rate_limiter(Arc::new(AllowAll));
2950
2951 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2952 let input = ActInput {
2953 org_id: Some(1),
2954 context,
2955 harness_id: HarnessId::from_seed(1),
2956 agent_id: Some(AgentId::new()),
2957 tool_calls: vec![ToolCall {
2958 id: "call_1".to_string(),
2959 name: "argument_echo".to_string(),
2960 arguments: json!({"value": "hello"}),
2961 }],
2962 tool_definitions: vec![ToolDefinition::Builtin(BuiltinTool {
2963 name: "argument_echo".to_string(),
2964 display_name: None,
2965 description: "echo".to_string(),
2966 parameters: json!({"type": "object"}),
2967 policy: Default::default(),
2968 category: None,
2969 deferrable: Default::default(),
2970 hints: crate::tool_types::ToolHints::default(),
2971 full_parameters: None,
2972 })],
2973 locale: None,
2974 blueprint_id: None,
2975 network_access: None,
2976 parallel_tool_calls: None,
2977 };
2978
2979 let result = atom.execute(input).await.unwrap();
2980
2981 assert_eq!(result.success_count, 1);
2982 assert_eq!(result.error_count, 0);
2983 }
2984
2985 use crate::tool_types::{SideEffectClass, ToolHints};
2990 use crate::{durability::DurableToolResultStore, durability::ToolCallClaimResult};
2991 use std::collections::HashMap;
2992 use std::sync::Mutex;
2993
2994 #[derive(Default)]
2995 struct InMemoryDurableStore {
2996 rows: Mutex<HashMap<(String, String), StoreRow>>,
2997 }
2998
2999 #[derive(Clone)]
3000 struct StoreRow {
3001 status: String,
3002 result_json: serde_json::Value,
3003 args_fingerprint: String,
3004 #[allow(dead_code)]
3005 claim_token: Uuid,
3006 }
3007
3008 #[async_trait]
3009 impl DurableToolResultStore for InMemoryDurableStore {
3010 async fn try_claim_tool_call(
3011 &self,
3012 turn_id: &str,
3013 tool_call_id: &str,
3014 _tool_name: &str,
3015 args_fingerprint: &str,
3016 ) -> crate::error::Result<ToolCallClaimResult> {
3017 let key = (turn_id.to_string(), tool_call_id.to_string());
3018 let mut rows = self.rows.lock().unwrap();
3019 if let Some(row) = rows.get(&key) {
3020 match row.status.as_str() {
3021 "settled" => {
3022 if row.args_fingerprint != args_fingerprint {
3023 return Ok(ToolCallClaimResult::DeterminismViolation {
3024 stored_fingerprint: row.args_fingerprint.clone(),
3025 current_fingerprint: args_fingerprint.to_string(),
3026 });
3027 }
3028 return Ok(ToolCallClaimResult::AlreadySettled {
3029 result_json: row.result_json.clone(),
3030 args_fingerprint: row.args_fingerprint.clone(),
3031 });
3032 }
3033 _ => {
3034 return Ok(ToolCallClaimResult::AlreadyRunning {
3035 args_fingerprint: row.args_fingerprint.clone(),
3036 });
3037 }
3038 }
3039 }
3040 let token = Uuid::new_v4();
3041 rows.insert(
3042 key,
3043 StoreRow {
3044 status: "running".to_string(),
3045 result_json: serde_json::Value::Null,
3046 args_fingerprint: args_fingerprint.to_string(),
3047 claim_token: token,
3048 },
3049 );
3050 Ok(ToolCallClaimResult::Claimed { claim_token: token })
3051 }
3052
3053 async fn settle_tool_call(
3054 &self,
3055 turn_id: &str,
3056 tool_call_id: &str,
3057 result_json: serde_json::Value,
3058 status: &str,
3059 _claim_token: Uuid,
3060 ) -> crate::error::Result<bool> {
3061 let key = (turn_id.to_string(), tool_call_id.to_string());
3062 let mut rows = self.rows.lock().unwrap();
3063 if let Some(row) = rows.get_mut(&key) {
3064 row.status = status.to_string();
3065 row.result_json = result_json;
3066 return Ok(true);
3067 }
3068 Ok(false)
3069 }
3070
3071 async fn get_tool_call_status(
3072 &self,
3073 turn_id: &str,
3074 tool_call_id: &str,
3075 ) -> crate::error::Result<Option<crate::durability::DurableToolCallStatus>> {
3076 let key = (turn_id.to_string(), tool_call_id.to_string());
3077 let rows = self.rows.lock().unwrap();
3078 Ok(rows.get(&key).map(|row| match row.status.as_str() {
3079 "settled" => crate::durability::DurableToolCallStatus::Settled {
3080 result_json: row.result_json.clone(),
3081 },
3082 "interrupted" => crate::durability::DurableToolCallStatus::Interrupted {
3083 result_json: Some(row.result_json.clone()),
3084 },
3085 _ => crate::durability::DurableToolCallStatus::Running,
3086 }))
3087 }
3088 }
3089
3090 fn make_act_input_with_store(
3091 tool_call: ToolCall,
3092 tool_defs: Vec<ToolDefinition>,
3093 context: ExecutionContext,
3094 ) -> ActInput {
3095 ActInput {
3096 org_id: None,
3097 context,
3098 harness_id: HarnessId::from_seed(1),
3099 agent_id: Some(AgentId::new()),
3100 tool_calls: vec![tool_call],
3101 tool_definitions: tool_defs,
3102 locale: None,
3103 blueprint_id: None,
3104 network_access: None,
3105 parallel_tool_calls: None,
3106 }
3107 }
3108
3109 fn arg_echo_tool_def(side_effect: SideEffectClass) -> ToolDefinition {
3110 ToolDefinition::Builtin(BuiltinTool {
3111 name: "argument_echo".to_string(),
3112 display_name: None,
3113 description: "echo".to_string(),
3114 parameters: json!({"type": "object"}),
3115 policy: Default::default(),
3116 category: None,
3117 deferrable: Default::default(),
3118 hints: ToolHints::default().with_side_effect_class(side_effect),
3119 full_parameters: None,
3120 })
3121 }
3122
3123 #[tokio::test]
3125 async fn test_idempotency_first_execution_claims_and_settles() {
3126 let store = Arc::new(InMemoryDurableStore::default());
3127 let mut executor = ToolRegistry::with_defaults();
3128 executor.register(ArgumentEchoTool);
3129 let atom =
3130 ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3131
3132 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
3133 let tc = ToolCall {
3134 id: "c1".to_string(),
3135 name: "argument_echo".to_string(),
3136 arguments: json!({"value": "hello"}),
3137 };
3138 let input = make_act_input_with_store(
3139 tc,
3140 vec![arg_echo_tool_def(SideEffectClass::AtMostOnce)],
3141 context,
3142 );
3143
3144 let result = atom.execute(input).await.unwrap();
3145 assert_eq!(result.success_count, 1);
3146 assert_eq!(result.error_count, 0);
3147
3148 let rows = store.rows.lock().unwrap();
3150 let row = rows.values().next().unwrap();
3151 assert_eq!(row.status, "settled");
3152 }
3153
3154 #[tokio::test]
3156 async fn test_idempotency_replay_already_settled() {
3157 use crate::tool_fingerprint::tool_call_fingerprint;
3158
3159 let store = Arc::new(InMemoryDurableStore::default());
3160 let tc = ToolCall {
3161 id: "c1".to_string(),
3162 name: "argument_echo".to_string(),
3163 arguments: json!({"value": "hello"}),
3164 };
3165 let fp = tool_call_fingerprint(&tc);
3166
3167 {
3169 let stored_result = serde_json::to_value(ToolResult {
3170 tool_call_id: "c1".to_string(),
3171 result: Some(json!({"value": "hello"})),
3172 images: None,
3173 error: None,
3174 connection_required: None,
3175 raw_output: None,
3176 })
3177 .unwrap();
3178 store.rows.lock().unwrap().insert(
3179 (
3180 "turn_00000000000000000000000000000000".to_string(),
3181 "c1".to_string(),
3182 ),
3183 StoreRow {
3184 status: "settled".to_string(),
3185 result_json: stored_result,
3186 args_fingerprint: fp,
3187 claim_token: Uuid::new_v4(),
3188 },
3189 );
3190 }
3191
3192 let mut executor = ToolRegistry::with_defaults();
3193 executor.register(ArgumentEchoTool);
3194 let atom =
3195 ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3196
3197 let context = ExecutionContext::new(
3198 SessionId::new(),
3199 TurnId::from_uuid(Uuid::nil()),
3200 MessageId::new(),
3201 );
3202 let input = make_act_input_with_store(
3203 tc,
3204 vec![arg_echo_tool_def(SideEffectClass::AtMostOnce)],
3205 context,
3206 );
3207
3208 let result = atom.execute(input).await.unwrap();
3209 assert_eq!(result.success_count, 1, "replay should count as success");
3210 assert_eq!(result.error_count, 0);
3211 }
3212
3213 #[tokio::test]
3215 async fn test_idempotency_at_most_once_stale_running_returns_interrupted() {
3216 use crate::tool_fingerprint::tool_call_fingerprint;
3217
3218 let store = Arc::new(InMemoryDurableStore::default());
3219 let tc = ToolCall {
3220 id: "c1".to_string(),
3221 name: "argument_echo".to_string(),
3222 arguments: json!({"value": "x"}),
3223 };
3224 let fp = tool_call_fingerprint(&tc);
3225
3226 store.rows.lock().unwrap().insert(
3228 (
3229 "turn_00000000000000000000000000000000".to_string(),
3230 "c1".to_string(),
3231 ),
3232 StoreRow {
3233 status: "running".to_string(),
3234 result_json: serde_json::Value::Null,
3235 args_fingerprint: fp,
3236 claim_token: Uuid::new_v4(),
3237 },
3238 );
3239
3240 let mut executor = ToolRegistry::with_defaults();
3241 executor.register(ArgumentEchoTool);
3242 let atom =
3243 ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3244
3245 let context = ExecutionContext::new(
3246 SessionId::new(),
3247 TurnId::from_uuid(Uuid::nil()),
3248 MessageId::new(),
3249 );
3250 let input = make_act_input_with_store(
3251 tc,
3252 vec![arg_echo_tool_def(SideEffectClass::AtMostOnce)],
3253 context,
3254 );
3255
3256 let result = atom.execute(input).await.unwrap();
3257 assert_eq!(
3258 result.error_count, 1,
3259 "AtMostOnce stale running should error"
3260 );
3261 let err = result.results[0].result.error.as_deref().unwrap_or("");
3262 assert!(
3263 err.contains("interrupted"),
3264 "error should mention interrupted: {err}"
3265 );
3266
3267 let rows = store.rows.lock().unwrap();
3269 let row = rows.values().next().unwrap();
3270 assert_eq!(row.status, "interrupted");
3271 }
3272
3273 #[tokio::test]
3275 async fn test_idempotency_idempotent_tool_stale_running_reexecutes() {
3276 use crate::tool_fingerprint::tool_call_fingerprint;
3277
3278 let store = Arc::new(InMemoryDurableStore::default());
3279 let tc = ToolCall {
3280 id: "c1".to_string(),
3281 name: "argument_echo".to_string(),
3282 arguments: json!({"value": "x"}),
3283 };
3284 let fp = tool_call_fingerprint(&tc);
3285
3286 store.rows.lock().unwrap().insert(
3287 (
3288 "turn_00000000000000000000000000000000".to_string(),
3289 "c1".to_string(),
3290 ),
3291 StoreRow {
3292 status: "running".to_string(),
3293 result_json: serde_json::Value::Null,
3294 args_fingerprint: fp,
3295 claim_token: Uuid::new_v4(),
3296 },
3297 );
3298
3299 let mut executor = ToolRegistry::with_defaults();
3300 executor.register(ArgumentEchoTool);
3301 let atom =
3302 ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3303
3304 let context = ExecutionContext::new(
3305 SessionId::new(),
3306 TurnId::from_uuid(Uuid::nil()),
3307 MessageId::new(),
3308 );
3309 let input = make_act_input_with_store(
3310 tc,
3311 vec![arg_echo_tool_def(SideEffectClass::Idempotent)],
3312 context,
3313 );
3314
3315 let result = atom.execute(input).await.unwrap();
3316 assert_eq!(
3317 result.success_count, 1,
3318 "Idempotent should re-execute successfully"
3319 );
3320 assert_eq!(result.error_count, 0);
3321 }
3322}