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 render_tool_narration_with_locale(tool_def, tool_call, phase, locale)
909 }
910
911 fn render_group_headline(
912 &self,
913 execution_context: &ExecutionContext,
914 tool_calls: &[ToolCall],
915 tool_map: &std::collections::HashMap<&str, &ToolDefinition>,
916 phase: ToolNarrationPhase,
917 locale: Option<&str>,
918 ) -> Option<String> {
919 if tool_calls.is_empty() {
920 return None;
921 }
922 if let [tool_call] = tool_calls {
923 return Some(self.render_tool_narration(
924 execution_context,
925 tool_map.get(tool_call.name.as_str()).copied(),
926 tool_call,
927 phase,
928 locale,
929 ));
930 }
931
932 let actions = tool_calls
933 .iter()
934 .map(|tool_call| {
935 let tool_def = tool_map.get(tool_call.name.as_str()).copied();
936 let narration = self.render_tool_narration(
937 execution_context,
938 tool_def,
939 tool_call,
940 phase,
941 locale,
942 );
943 let repeated_narration = self.render_tool_narration(
944 execution_context,
945 tool_def,
946 &tool_call_for_group_summary(tool_call),
947 phase,
948 locale,
949 );
950 GroupHeadlineAction::new(tool_call, narration, repeated_narration)
951 })
952 .collect::<Vec<_>>();
953
954 Some(summarize_group_actions(&actions, locale))
955 }
956
957 fn wrap_file_store_for_narration(
960 &self,
961 execution_context: &ExecutionContext,
962 ) -> Option<Arc<dyn SessionFileSystem>> {
963 let store = self.context_services.file_store.as_ref()?.clone();
964 let store = if let Some(workspace_id) = execution_context.workspace_id {
965 crate::session_files::WorkspaceScopedFileSystem::wrap(store, workspace_id)
966 } else {
967 store
968 };
969 Some(crate::mount_fs::MountFs::wrap_if_needed(store))
970 }
971
972 fn transform_tool_call_for_execution(&self, tool_call: ToolCall) -> ToolCall {
973 self.tool_call_hooks
974 .iter()
975 .fold(tool_call, |tool_call, hook| {
976 hook.transform_for_execution(tool_call)
977 })
978 }
979
980 #[allow(clippy::too_many_arguments)]
986 async fn execute_single_tool(
987 &self,
988 context: &ExecutionContext,
989 tool_call: ToolCall,
990 tool_def: Option<&ToolDefinition>,
991 trace_id: &str,
992 act_span_id: &str,
993 locale: Option<&str>,
994 network_access: Option<&crate::network_access::NetworkAccessList>,
995 visible_tool_names: Arc<HashSet<String>>,
996 ) -> ToolCallResult {
997 tracing::debug!(
998 session_id = %context.session_id,
999 turn_id = %context.turn_id,
1000 tool_name = %tool_call.name,
1001 tool_call_id = %tool_call.id,
1002 "ActAtom: executing tool"
1003 );
1004
1005 let tool_span_id = Uuid::now_v7().to_string();
1007
1008 let event_context = EventContext::from_execution_context(context).with_span(
1010 trace_id.to_string(),
1011 tool_span_id.clone(),
1012 Some(act_span_id.to_string()),
1013 );
1014
1015 let tool_start = Instant::now();
1017 let tool_call_fingerprint = tool_call_fingerprint(&tool_call);
1018
1019 let display_name = crate::localization::localized_tool_display_name(
1021 &tool_call.name,
1022 tool_def.and_then(|d| d.display_name()),
1023 locale,
1024 );
1025 let capability_attribution = tool_def.and_then(|def| {
1026 def.capability_attribution()
1027 .map(|(id, name)| (id.to_string(), name.map(str::to_string)))
1028 });
1029
1030 if let (Some(limiter), Some(ref org_id)) = (
1034 &self.outbound_tool_rate_limiter,
1035 self.context_services.org_id,
1036 ) && !limiter.check_org(org_id).await
1037 {
1038 tracing::warn!(
1039 session_id = %context.session_id,
1040 tool_name = %tool_call.name,
1041 "ActAtom: outbound tool rate limit exceeded for org"
1042 );
1043 return ToolCallResult {
1044 tool_call: tool_call.clone(),
1045 result: ToolResult {
1046 tool_call_id: tool_call.id.clone(),
1047 result: None,
1048 images: None,
1049 error: Some(
1050 "Outbound tool rate limit exceeded for this organization; back off and retry later.".to_string(),
1051 ),
1052 connection_required: None,
1053 raw_output: None,
1054 },
1055 success: false,
1056 status: "error".to_string(),
1057 connection_required: None,
1058 determinism_fatal: None,
1059 };
1060 }
1061
1062 let claim_token = if let Some(ref store) = self.durable_tool_result_store {
1065 let turn_id = context.turn_id.to_string();
1066 match store
1067 .try_claim_tool_call(
1068 &turn_id,
1069 &tool_call.id,
1070 &tool_call.name,
1071 &tool_call_fingerprint,
1072 )
1073 .await
1074 {
1075 Ok(ToolCallClaimResult::Claimed { claim_token }) => Some(claim_token),
1076
1077 Ok(ToolCallClaimResult::AlreadySettled {
1078 result_json,
1079 args_fingerprint: stored_fp,
1080 }) => {
1081 if stored_fp != tool_call_fingerprint {
1083 let err_msg = format!(
1084 "determinism violation: tool '{}' replay args fingerprint \
1085 does not match prior execution (stored={stored_fp}, \
1086 current={})",
1087 tool_call.name, tool_call_fingerprint
1088 );
1089 tracing::error!(
1090 session_id = %context.session_id,
1091 turn_id = %context.turn_id,
1092 tool_call_id = %tool_call.id,
1093 stored_fp = %stored_fp,
1094 current_fp = %tool_call_fingerprint,
1095 "ActAtom: determinism violation — replay args fingerprint mismatch"
1096 );
1097 let result_fp =
1098 tool_result_fingerprint(&tool_call.name, &ToolResult::error(&err_msg));
1099 let _ = self
1100 .event_emitter
1101 .emit(EventRequest::new(
1102 context.session_id,
1103 event_context,
1104 ToolCompletedData::failure(
1105 tool_call.id.clone(),
1106 tool_call.name.clone(),
1107 "error".to_string(),
1108 err_msg.clone(),
1109 None,
1110 )
1111 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1112 .with_display_name(display_name.clone()),
1113 ))
1114 .await;
1115 return ToolCallResult {
1116 tool_call: tool_call.clone(),
1117 result: ToolResult {
1118 tool_call_id: tool_call.id.clone(),
1119 result: None,
1120 images: None,
1121 error: Some(err_msg.clone()),
1122 connection_required: None,
1123 raw_output: None,
1124 },
1125 success: false,
1126 status: "error".to_string(),
1127 connection_required: None,
1128 determinism_fatal: Some(err_msg),
1129 };
1130 }
1131 tracing::debug!(
1132 session_id = %context.session_id,
1133 turn_id = %context.turn_id,
1134 tool_call_id = %tool_call.id,
1135 "ActAtom: replaying already-settled tool call"
1136 );
1137 let replayed_result: ToolResult = serde_json::from_value(result_json.clone())
1139 .unwrap_or(ToolResult {
1140 tool_call_id: tool_call.id.clone(),
1141 result: Some(result_json),
1142 images: None,
1143 error: None,
1144 connection_required: None,
1145 raw_output: None,
1146 });
1147 let success = replayed_result.error.is_none();
1148 let status = if success { "success" } else { "error" };
1149 let result_fp = tool_result_fingerprint(&tool_call.name, &replayed_result);
1150 let completed_data = if success {
1151 let mut content = replayed_result
1153 .result
1154 .as_ref()
1155 .map(|r| vec![ContentPart::tool_result_text(r)])
1156 .unwrap_or_default();
1157 if let Some(ref images) = replayed_result.images {
1158 for img in images {
1159 content.push(ContentPart::Image(
1160 crate::message::ImageContentPart::from_base64(
1161 &img.base64,
1162 &img.media_type,
1163 ),
1164 ));
1165 }
1166 }
1167 ToolCompletedData::success(
1168 tool_call.id.clone(),
1169 tool_call.name.clone(),
1170 content,
1171 None,
1172 )
1173 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1174 .with_display_name(display_name.clone())
1175 } else {
1176 ToolCompletedData::failure(
1177 tool_call.id.clone(),
1178 tool_call.name.clone(),
1179 status.to_string(),
1180 replayed_result.error.clone().unwrap_or_default(),
1181 None,
1182 )
1183 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1184 .with_display_name(display_name.clone())
1185 };
1186 let _ = self
1187 .event_emitter
1188 .emit(EventRequest::new(
1189 context.session_id,
1190 event_context,
1191 completed_data,
1192 ))
1193 .await;
1194 let conn_req = replayed_result.connection_required.clone();
1195 return ToolCallResult {
1196 tool_call,
1197 result: replayed_result,
1198 success,
1199 status: status.to_string(),
1200 connection_required: conn_req,
1201 determinism_fatal: None,
1202 };
1203 }
1204
1205 Ok(ToolCallClaimResult::AlreadyRunning {
1206 args_fingerprint: stored_fp,
1207 }) => {
1208 if stored_fp != tool_call_fingerprint {
1211 let err_msg = format!(
1212 "determinism violation: tool '{}' args fingerprint changed \
1213 while prior claim is still running (stored={stored_fp}, \
1214 current={tool_call_fingerprint})",
1215 tool_call.name
1216 );
1217 tracing::error!(
1218 session_id = %context.session_id,
1219 turn_id = %context.turn_id,
1220 tool_call_id = %tool_call.id,
1221 stored = %stored_fp,
1222 current = %tool_call_fingerprint,
1223 "ActAtom: determinism violation — running claim fingerprint mismatch"
1224 );
1225 let result_fp =
1226 tool_result_fingerprint(&tool_call.name, &ToolResult::error(&err_msg));
1227 let _ = self
1228 .event_emitter
1229 .emit(EventRequest::new(
1230 context.session_id,
1231 event_context,
1232 ToolCompletedData::failure(
1233 tool_call.id.clone(),
1234 tool_call.name.clone(),
1235 "error".to_string(),
1236 err_msg.clone(),
1237 None,
1238 )
1239 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1240 .with_display_name(display_name.clone()),
1241 ))
1242 .await;
1243 return ToolCallResult {
1244 tool_call: tool_call.clone(),
1245 result: ToolResult {
1246 tool_call_id: tool_call.id.clone(),
1247 result: None,
1248 images: None,
1249 error: Some(err_msg.clone()),
1250 connection_required: None,
1251 raw_output: None,
1252 },
1253 success: false,
1254 status: "error".to_string(),
1255 connection_required: None,
1256 determinism_fatal: Some(err_msg),
1257 };
1258 }
1259
1260 let sec = tool_def
1261 .map(|d| d.side_effect_class())
1262 .unwrap_or(SideEffectClass::AtMostOnce);
1263 match sec {
1264 SideEffectClass::Pure | SideEffectClass::Idempotent => {
1265 tracing::debug!(
1267 session_id = %context.session_id,
1268 tool_call_id = %tool_call.id,
1269 "ActAtom: stale running claim for idempotent tool, re-executing"
1270 );
1271 None
1272 }
1273 SideEffectClass::AtMostOnce => {
1274 tracing::warn!(
1275 session_id = %context.session_id,
1276 turn_id = %context.turn_id,
1277 tool_call_id = %tool_call.id,
1278 "ActAtom: AtMostOnce tool has stale running claim; returning interrupted result"
1279 );
1280 let _ = store
1282 .settle_tool_call(
1283 &turn_id,
1284 &tool_call.id,
1285 serde_json::Value::Null,
1286 "interrupted",
1287 Uuid::nil(), )
1289 .await;
1290 let err_msg = format!(
1291 "tool '{}' was interrupted mid-execution during a prior \
1292 worker failure; result is uncertain and was not re-run \
1293 (AtMostOnce safety)",
1294 tool_call.name
1295 );
1296 let result_fp = tool_result_fingerprint(
1297 &tool_call.name,
1298 &ToolResult::error(&err_msg),
1299 );
1300 let _ = self
1301 .event_emitter
1302 .emit(EventRequest::new(
1303 context.session_id,
1304 event_context,
1305 ToolCompletedData::failure(
1306 tool_call.id.clone(),
1307 tool_call.name.clone(),
1308 "interrupted".to_string(),
1309 err_msg.clone(),
1310 None,
1311 )
1312 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1313 .with_display_name(display_name.clone()),
1314 ))
1315 .await;
1316 return ToolCallResult {
1317 tool_call: tool_call.clone(),
1318 result: ToolResult {
1319 tool_call_id: tool_call.id.clone(),
1320 result: None,
1321 images: None,
1322 error: Some(err_msg),
1323 connection_required: None,
1324 raw_output: None,
1325 },
1326 success: false,
1327 status: "error".to_string(),
1328 connection_required: None,
1329 determinism_fatal: None,
1330 };
1331 }
1332 }
1333 }
1334
1335 Ok(ToolCallClaimResult::DeterminismViolation {
1336 stored_fingerprint,
1337 current_fingerprint,
1338 }) => {
1339 let err_msg = format!(
1340 "determinism violation: tool '{}' args fingerprint changed \
1341 on replay (stored={stored_fingerprint}, \
1342 current={current_fingerprint})",
1343 tool_call.name
1344 );
1345 tracing::error!(
1346 session_id = %context.session_id,
1347 turn_id = %context.turn_id,
1348 tool_call_id = %tool_call.id,
1349 stored = %stored_fingerprint,
1350 current = %current_fingerprint,
1351 "ActAtom: determinism violation on claim"
1352 );
1353 let result_fp =
1354 tool_result_fingerprint(&tool_call.name, &ToolResult::error(&err_msg));
1355 let _ = self
1356 .event_emitter
1357 .emit(EventRequest::new(
1358 context.session_id,
1359 event_context,
1360 ToolCompletedData::failure(
1361 tool_call.id.clone(),
1362 tool_call.name.clone(),
1363 "error".to_string(),
1364 err_msg.clone(),
1365 None,
1366 )
1367 .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1368 .with_display_name(display_name.clone()),
1369 ))
1370 .await;
1371 return ToolCallResult {
1372 tool_call: tool_call.clone(),
1373 result: ToolResult {
1374 tool_call_id: tool_call.id.clone(),
1375 result: None,
1376 images: None,
1377 error: Some(err_msg.clone()),
1378 connection_required: None,
1379 raw_output: None,
1380 },
1381 success: false,
1382 status: "error".to_string(),
1383 connection_required: None,
1384 determinism_fatal: Some(err_msg),
1385 };
1386 }
1387
1388 Err(e) => {
1389 tracing::warn!(
1390 session_id = %context.session_id,
1391 tool_call_id = %tool_call.id,
1392 error = %e,
1393 "ActAtom: durable claim failed; proceeding without idempotency"
1394 );
1395 None
1396 }
1397 }
1398 } else {
1399 None
1400 };
1401
1402 if let Err(e) = self
1404 .event_emitter
1405 .emit(EventRequest::new(
1406 context.session_id,
1407 event_context.clone(),
1408 ToolStartedData {
1409 tool_call: tool_call.clone(),
1410 tool_call_fingerprint: Some(tool_call_fingerprint.clone()),
1411 display_name: display_name.clone(),
1412 narration: Some(self.render_tool_narration(
1413 context,
1414 tool_def,
1415 &tool_call,
1416 ToolNarrationPhase::Started,
1417 locale,
1418 )),
1419 },
1420 ))
1421 .await
1422 {
1423 tracing::warn!(
1424 session_id = %context.session_id,
1425 tool_call_id = %tool_call.id,
1426 error = %e,
1427 "ActAtom: failed to emit tool.started event"
1428 );
1429 }
1430
1431 let Some(tool_def) = tool_def else {
1433 let error_msg = format!("Tool definition not found: {}", tool_call.name);
1434 let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1435
1436 if let Err(e) = self
1438 .event_emitter
1439 .emit(EventRequest::new(
1440 context.session_id,
1441 event_context,
1442 ToolCompletedData::failure(
1443 tool_call.id.clone(),
1444 tool_call.name.clone(),
1445 "error".to_string(),
1446 error_msg.clone(),
1447 Some(tool_duration_ms),
1448 )
1449 .with_fingerprints(
1450 tool_call_fingerprint.clone(),
1451 tool_error_fingerprint(&tool_call.name, "error", &error_msg),
1452 )
1453 .with_narration(Some(self.render_tool_narration(
1454 context,
1455 None,
1456 &tool_call,
1457 ToolNarrationPhase::Failed,
1458 locale,
1459 ))),
1460 ))
1461 .await
1462 {
1463 tracing::warn!(
1464 session_id = %context.session_id,
1465 tool_call_id = %tool_call.id,
1466 error = %e,
1467 "ActAtom: failed to emit tool.completed event"
1468 );
1469 }
1470
1471 return ToolCallResult {
1472 tool_call: tool_call.clone(),
1473 result: ToolResult {
1474 tool_call_id: tool_call.id.clone(),
1475 result: None,
1476 images: None,
1477 error: Some(error_msg),
1478 connection_required: None,
1479 raw_output: None,
1480 },
1481 success: false,
1482 status: "error".to_string(),
1483 connection_required: None,
1484 determinism_fatal: None,
1485 };
1486 };
1487
1488 let mut tool_context =
1490 ToolContext::from_services(context.session_id, &self.context_services);
1491 if let Some(workspace_id) = context.workspace_id {
1496 tool_context.workspace_id = workspace_id;
1497 if let Some(store) = tool_context.file_store.take() {
1498 tool_context.file_store = Some(
1499 crate::session_files::WorkspaceScopedFileSystem::wrap(store, workspace_id),
1500 );
1501 }
1502 }
1503 if let Some(store) = tool_context.file_store.take() {
1507 tool_context.file_store = Some(crate::mount_fs::MountFs::wrap_if_needed(store));
1508 }
1509 tool_context.visible_tool_names = Some(visible_tool_names.clone());
1510 tool_context.network_access = network_access
1512 .cloned()
1513 .or_else(|| self.context_services.network_access.clone());
1514 if tool_context.event_emitter.is_none() {
1516 tool_context.event_emitter =
1517 Some(Arc::new(self.event_emitter.clone()) as Arc<dyn EventEmitter>);
1518 }
1519 tool_context.event_context = Some(event_context.clone());
1520 tool_context.tool_call_id = Some(tool_call.id.clone());
1521
1522 let call_cancellation = tokio_util::sync::CancellationToken::new();
1530 tool_context.cancellation = Some(call_cancellation.clone());
1531 let _cancel_on_call_end = call_cancellation.drop_guard();
1532
1533 let execution_tool_call = self.transform_tool_call_for_execution(tool_call.clone());
1534
1535 let (execution_tool_call, pre_block_reason) = if self.pre_tool_hooks.is_empty() {
1540 (execution_tool_call, None)
1541 } else {
1542 match act_hooks::run_pre_tool_use_hooks(
1543 &self.pre_tool_hooks,
1544 execution_tool_call.clone(),
1545 tool_def,
1546 &tool_context,
1547 )
1548 .await
1549 {
1550 act_hooks::PreToolUseDecision::Continue(updated) => (updated, None),
1551 act_hooks::PreToolUseDecision::Block {
1552 tool_call: blocked,
1553 reason,
1554 ..
1555 } => (blocked, Some(reason)),
1556 }
1557 };
1558
1559 let result = if let Some(reason) = pre_block_reason {
1560 tracing::warn!(
1561 session_id = %context.session_id,
1562 tool_call_id = %execution_tool_call.id,
1563 tool_name = %execution_tool_call.name,
1564 reason = %reason,
1565 "ActAtom: pre_tool_use hook blocked execution"
1566 );
1567 Ok(crate::tool_types::ToolResult {
1568 tool_call_id: execution_tool_call.id.clone(),
1569 result: None,
1570 images: None,
1571 error: Some(format!("blocked by pre_tool_use hook: {reason}")),
1572 connection_required: None,
1573 raw_output: None,
1574 })
1575 } else if tool_def.is_cpu_bound() {
1576 let executor = self.tool_executor.clone();
1582 let call = execution_tool_call.clone();
1583 let def = tool_def.clone();
1584 let ctx = tool_context.clone();
1585 match AbortOnDropJoinHandle::new(tokio::spawn(async move {
1586 executor.execute_with_context(&call, &def, &ctx).await
1587 }))
1588 .await
1589 {
1590 Ok(result) => result,
1591 Err(join_err) => Err(crate::error::AgentLoopError::tool(format!(
1592 "tool task failed to complete: {join_err}"
1593 ))),
1594 }
1595 } else {
1596 self.tool_executor
1597 .execute_with_context(&execution_tool_call, tool_def, &tool_context)
1598 .await
1599 };
1600
1601 match result {
1602 Ok(mut tool_result) => {
1603 act_hooks::run_post_tool_exec_hooks(
1605 &self.post_tool_hooks,
1606 &self.final_post_tool_hooks,
1607 &execution_tool_call,
1608 tool_def,
1609 &mut tool_result,
1610 &tool_context,
1611 )
1612 .await;
1613
1614 let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1615 let success = tool_result.error.is_none();
1616 let status = if success { "success" } else { "error" };
1617
1618 let completed_data = if success {
1620 let result_fingerprint = tool_result_fingerprint(&tool_call.name, &tool_result);
1621 let mut result_content = tool_result
1623 .result
1624 .as_ref()
1625 .map(|r| vec![ContentPart::tool_result_text(r)])
1626 .unwrap_or_default();
1627 if let Some(ref images) = tool_result.images {
1629 for img in images {
1630 result_content.push(ContentPart::Image(
1631 crate::message::ImageContentPart::from_base64(
1632 &img.base64,
1633 &img.media_type,
1634 ),
1635 ));
1636 }
1637 }
1638 ToolCompletedData::success(
1639 tool_call.id.clone(),
1640 tool_call.name.clone(),
1641 result_content,
1642 Some(tool_duration_ms),
1643 )
1644 .with_fingerprints(tool_call_fingerprint.clone(), result_fingerprint)
1645 .with_display_name(display_name.clone())
1646 .with_capability_attribution(
1647 capability_attribution.as_ref().map(|(id, _)| id.clone()),
1648 capability_attribution
1649 .as_ref()
1650 .and_then(|(_, name)| name.clone()),
1651 )
1652 .with_narration(Some(self.render_tool_narration(
1653 context,
1654 Some(tool_def),
1655 &tool_call,
1656 ToolNarrationPhase::Completed,
1657 locale,
1658 )))
1659 } else {
1660 let result_fingerprint = tool_result_fingerprint(&tool_call.name, &tool_result);
1661 ToolCompletedData::failure(
1662 tool_call.id.clone(),
1663 tool_call.name.clone(),
1664 status.to_string(),
1665 tool_result.error.clone().unwrap_or_default(),
1666 Some(tool_duration_ms),
1667 )
1668 .with_fingerprints(tool_call_fingerprint.clone(), result_fingerprint)
1669 .with_display_name(display_name.clone())
1670 .with_capability_attribution(
1671 capability_attribution.as_ref().map(|(id, _)| id.clone()),
1672 capability_attribution
1673 .as_ref()
1674 .and_then(|(_, name)| name.clone()),
1675 )
1676 .with_narration(Some(self.render_tool_narration(
1677 context,
1678 Some(tool_def),
1679 &tool_call,
1680 ToolNarrationPhase::Failed,
1681 locale,
1682 )))
1683 };
1684
1685 if let Err(e) = self
1686 .event_emitter
1687 .emit(EventRequest::new(
1688 context.session_id,
1689 event_context.clone(),
1690 completed_data,
1691 ))
1692 .await
1693 {
1694 tracing::warn!(
1695 session_id = %context.session_id,
1696 tool_call_id = %tool_call.id,
1697 error = %e,
1698 "ActAtom: failed to emit tool.completed event"
1699 );
1700 }
1701
1702 tracing::debug!(
1703 session_id = %context.session_id,
1704 tool_name = %tool_call.name,
1705 tool_call_id = %tool_call.id,
1706 success = %success,
1707 "ActAtom: tool execution completed"
1708 );
1709
1710 if let (Some(store), Some(token)) = (&self.durable_tool_result_store, claim_token) {
1712 let result_snapshot =
1713 serde_json::to_value(&tool_result).unwrap_or(serde_json::Value::Null);
1714 match store
1715 .settle_tool_call(
1716 &context.turn_id.to_string(),
1717 &tool_call.id,
1718 result_snapshot,
1719 "settled",
1720 token,
1721 )
1722 .await
1723 {
1724 Ok(false) => {
1725 tracing::warn!(
1726 session_id = %context.session_id,
1727 tool_call_id = %tool_call.id,
1728 "ActAtom: settle ownership check failed (task reclaimed)"
1729 );
1730 }
1731 Err(e) => {
1732 tracing::warn!(
1733 session_id = %context.session_id,
1734 tool_call_id = %tool_call.id,
1735 error = %e,
1736 "ActAtom: settle_tool_call failed"
1737 );
1738 }
1739 Ok(true) => {}
1740 }
1741 }
1742
1743 let conn_req = tool_result.connection_required.clone();
1744 ToolCallResult {
1745 tool_call,
1746 result: tool_result,
1747 success,
1748 status: status.to_string(),
1749 connection_required: conn_req,
1750 determinism_fatal: None,
1751 }
1752 }
1753 Err(e) => {
1754 let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1755 let error_msg = e.to_string();
1756
1757 if let Err(emit_err) = self
1759 .event_emitter
1760 .emit(EventRequest::new(
1761 context.session_id,
1762 event_context,
1763 ToolCompletedData::failure(
1764 tool_call.id.clone(),
1765 tool_call.name.clone(),
1766 "error".to_string(),
1767 error_msg.clone(),
1768 Some(tool_duration_ms),
1769 )
1770 .with_fingerprints(
1771 tool_call_fingerprint.clone(),
1772 tool_error_fingerprint(&tool_call.name, "error", &error_msg),
1773 )
1774 .with_display_name(display_name.clone())
1775 .with_capability_attribution(
1776 capability_attribution.as_ref().map(|(id, _)| id.clone()),
1777 capability_attribution
1778 .as_ref()
1779 .and_then(|(_, name)| name.clone()),
1780 )
1781 .with_narration(Some(self.render_tool_narration(
1782 context,
1783 Some(tool_def),
1784 &tool_call,
1785 ToolNarrationPhase::Failed,
1786 locale,
1787 ))),
1788 ))
1789 .await
1790 {
1791 tracing::warn!(
1792 session_id = %context.session_id,
1793 tool_call_id = %tool_call.id,
1794 error = %emit_err,
1795 "ActAtom: failed to emit tool.completed event"
1796 );
1797 }
1798
1799 tracing::warn!(
1800 session_id = %context.session_id,
1801 tool_name = %tool_call.name,
1802 tool_call_id = %tool_call.id,
1803 error = %e,
1804 "ActAtom: tool execution failed"
1805 );
1806
1807 ToolCallResult {
1808 tool_call: tool_call.clone(),
1809 result: ToolResult {
1810 tool_call_id: tool_call.id.clone(),
1811 result: None,
1812 images: None,
1813 error: Some(error_msg),
1814 connection_required: None,
1815 raw_output: None,
1816 },
1817 success: false,
1818 status: "error".to_string(),
1819 connection_required: None,
1820 determinism_fatal: None,
1821 }
1822 }
1823 }
1824 }
1825}
1826
1827#[cfg(test)]
1832mod tests {
1833 use super::*;
1834 use crate::test_fixtures::NoopEventEmitter;
1835 use crate::tools::ToolRegistry;
1836 use crate::typed_id::{AgentId, HarnessId, MessageId, SessionId, TurnId};
1837 use async_trait::async_trait;
1838 use everruns_core::{Capability, DisabledUtilityLlmService, Tool, ToolExecutionResult};
1839 use everruns_provider::{BuiltinTool, ClientSideTool};
1840 use serde_json::json;
1841
1842 struct ArgumentEchoTool;
1843
1844 struct NarratingGrepTool;
1845
1846 struct HumanIntentFixtureHook;
1847
1848 impl crate::capabilities::ToolCallHook for HumanIntentFixtureHook {
1849 fn narration(
1850 &self,
1851 _tool_def: Option<&ToolDefinition>,
1852 tool_call: &ToolCall,
1853 _phase: crate::tool_narration::ToolNarrationPhase,
1854 _locale: Option<&str>,
1855 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1856 ) -> Option<String> {
1857 crate::tool_types::human_intent(&tool_call.arguments).map(str::to_string)
1858 }
1859
1860 fn transform_for_execution(&self, mut tool_call: ToolCall) -> ToolCall {
1861 tool_call.arguments = tool_call.execution_arguments();
1862 tool_call
1863 }
1864 }
1865
1866 #[async_trait]
1867 impl crate::tools::Tool for NarratingGrepTool {
1868 fn name(&self) -> &str {
1869 "grep_files"
1870 }
1871
1872 fn description(&self) -> &str {
1873 "Search files"
1874 }
1875
1876 fn parameters_schema(&self) -> serde_json::Value {
1877 json!({"type": "object"})
1878 }
1879
1880 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1881 ToolExecutionResult::success(json!({}))
1882 }
1883
1884 fn narrate(
1885 &self,
1886 tool_call: &ToolCall,
1887 phase: crate::tool_narration::ToolNarrationPhase,
1888 locale: Option<&str>,
1889 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1890 ) -> Option<String> {
1891 Some(crate::tool_narration::narrate_grep_files(
1892 &tool_call.arguments,
1893 phase,
1894 locale,
1895 ))
1896 }
1897 }
1898
1899 struct NarratingCapability;
1900
1901 #[async_trait]
1902 impl Capability for NarratingCapability {
1903 fn id(&self) -> &str {
1904 "narrating_test"
1905 }
1906
1907 fn name(&self) -> &str {
1908 "Narrating test"
1909 }
1910
1911 fn description(&self) -> &str {
1912 "Test-only narration capability"
1913 }
1914
1915 fn tools(&self) -> Vec<Box<dyn Tool>> {
1916 vec![Box::new(NarratingGrepTool)]
1917 }
1918 }
1919
1920 #[async_trait]
1921 impl crate::tools::Tool for ArgumentEchoTool {
1922 fn name(&self) -> &str {
1923 "argument_echo"
1924 }
1925
1926 fn description(&self) -> &str {
1927 "returns the execution arguments"
1928 }
1929
1930 fn parameters_schema(&self) -> serde_json::Value {
1931 json!({
1932 "type": "object",
1933 "properties": {
1934 "value": { "type": "string" }
1935 }
1936 })
1937 }
1938
1939 async fn execute(&self, arguments: serde_json::Value) -> ToolExecutionResult {
1940 ToolExecutionResult::success(arguments)
1941 }
1942 }
1943
1944 #[test]
1945 fn grouped_headline_uses_tool_owned_narration_for_repeated_actions() {
1946 use crate::capabilities::{Capability, CapabilityNarrationHook};
1947
1948 let capability: Arc<dyn Capability> = Arc::new(NarratingCapability);
1949 let tool_definitions = capability
1950 .tools()
1951 .into_iter()
1952 .map(|tool| tool.to_definition())
1953 .collect::<Vec<_>>();
1954 let tool_map = tool_definitions
1955 .iter()
1956 .map(|tool_def| (tool_def.name(), tool_def))
1957 .collect::<std::collections::HashMap<_, _>>();
1958 let atom = ActAtom::new(ToolRegistry::new(), NoopEventEmitter)
1959 .with_tool_call_hooks(vec![Arc::new(CapabilityNarrationHook(capability))]);
1960 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
1961 let tool_calls = vec![
1962 ToolCall {
1963 id: "grep-1".to_string(),
1964 name: "grep_files".to_string(),
1965 arguments: json!({ "pattern": "full_name" }),
1966 },
1967 ToolCall {
1968 id: "grep-2".to_string(),
1969 name: "grep_files".to_string(),
1970 arguments: json!({ "pattern": "login" }),
1971 },
1972 ];
1973
1974 assert_eq!(
1975 atom.render_group_headline(
1976 &context,
1977 &tool_calls,
1978 &tool_map,
1979 ToolNarrationPhase::Started,
1980 None,
1981 )
1982 .as_deref(),
1983 Some("Searching files twice")
1984 );
1985 assert_eq!(
1986 atom.render_group_headline(
1987 &context,
1988 &tool_calls,
1989 &tool_map,
1990 ToolNarrationPhase::Completed,
1991 None,
1992 )
1993 .as_deref(),
1994 Some("Searched files twice")
1995 );
1996 }
1997
1998 struct UtilityLlmContextProbeTool;
1999
2000 #[async_trait]
2001 impl crate::tools::Tool for UtilityLlmContextProbeTool {
2002 fn name(&self) -> &str {
2003 "utility_llm_context_probe"
2004 }
2005
2006 fn description(&self) -> &str {
2007 "checks whether the utility LLM service is present in tool context"
2008 }
2009
2010 fn parameters_schema(&self) -> serde_json::Value {
2011 json!({
2012 "type": "object",
2013 "properties": {}
2014 })
2015 }
2016
2017 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2018 ToolExecutionResult::tool_error("context required")
2019 }
2020
2021 async fn execute_with_context(
2022 &self,
2023 _arguments: serde_json::Value,
2024 context: &crate::tool_context::ToolContext,
2025 ) -> ToolExecutionResult {
2026 ToolExecutionResult::success(json!({
2027 "utility_llm_service": context.utility_llm_service.is_some(),
2028 "configured": context
2029 .utility_llm_service
2030 .as_ref()
2031 .is_some_and(|service| service.is_configured()),
2032 }))
2033 }
2034
2035 fn requires_context(&self) -> bool {
2036 true
2037 }
2038 }
2039
2040 #[derive(Default)]
2042 struct SchedObservations {
2043 class_inflight: std::collections::HashMap<String, usize>,
2045 class_max: std::collections::HashMap<String, usize>,
2047 global_inflight: usize,
2049 global_max: usize,
2051 }
2052
2053 struct RecordingTool {
2056 name: String,
2057 class: Option<String>,
2058 obs: Arc<std::sync::Mutex<SchedObservations>>,
2059 }
2060
2061 #[async_trait]
2062 impl crate::tools::Tool for RecordingTool {
2063 fn name(&self) -> &str {
2064 &self.name
2065 }
2066 fn description(&self) -> &str {
2067 "records scheduling order"
2068 }
2069 fn parameters_schema(&self) -> serde_json::Value {
2070 json!({ "type": "object", "properties": {} })
2071 }
2072 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2073 {
2075 let mut obs = self.obs.lock().unwrap();
2076 obs.global_inflight += 1;
2077 let g = obs.global_inflight;
2078 if g > obs.global_max {
2079 obs.global_max = g;
2080 }
2081 if let Some(class) = &self.class {
2082 let n = obs.class_inflight.entry(class.clone()).or_default();
2083 *n += 1;
2084 let cur = *n;
2085 let m = obs.class_max.entry(class.clone()).or_default();
2086 if cur > *m {
2087 *m = cur;
2088 }
2089 }
2090 }
2091 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2093 {
2095 let mut obs = self.obs.lock().unwrap();
2096 obs.global_inflight -= 1;
2097 if let Some(class) = &self.class
2098 && let Some(n) = obs.class_inflight.get_mut(class)
2099 {
2100 *n -= 1;
2101 }
2102 }
2103 ToolExecutionResult::success(json!({ "tool": self.name }))
2104 }
2105 }
2106
2107 struct CancellationProbeTool {
2108 started: Arc<tokio::sync::Notify>,
2109 dropped_tx: Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
2110 }
2111
2112 impl CancellationProbeTool {
2113 fn new(
2114 started: Arc<tokio::sync::Notify>,
2115 dropped_tx: tokio::sync::oneshot::Sender<()>,
2116 ) -> Self {
2117 Self {
2118 started,
2119 dropped_tx: Arc::new(std::sync::Mutex::new(Some(dropped_tx))),
2120 }
2121 }
2122 }
2123
2124 #[async_trait]
2125 impl crate::tools::Tool for CancellationProbeTool {
2126 fn name(&self) -> &str {
2127 "cancellation_probe"
2128 }
2129
2130 fn description(&self) -> &str {
2131 "waits until cancelled"
2132 }
2133
2134 fn parameters_schema(&self) -> serde_json::Value {
2135 json!({ "type": "object", "properties": {} })
2136 }
2137
2138 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2139 struct DropSignal {
2140 tx: Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
2141 }
2142
2143 impl Drop for DropSignal {
2144 fn drop(&mut self) {
2145 if let Ok(mut guard) = self.tx.lock()
2146 && let Some(tx) = guard.take()
2147 {
2148 let _ = tx.send(());
2149 }
2150 }
2151 }
2152
2153 let _drop_signal = DropSignal {
2154 tx: self.dropped_tx.clone(),
2155 };
2156 self.started.notify_one();
2157 std::future::pending::<()>().await;
2158 unreachable!("pending cancellation probe should only finish by cancellation")
2159 }
2160 }
2161
2162 fn recording_tool_def(name: &str, class: Option<&str>, cpu_bound: bool) -> ToolDefinition {
2164 let mut hints = crate::tool_types::ToolHints::default();
2165 if let Some(class) = class {
2166 hints = hints.with_concurrency_class(class);
2167 }
2168 if cpu_bound {
2169 hints = hints.with_cpu_bound(true);
2170 }
2171 ToolDefinition::Builtin(BuiltinTool {
2172 name: name.to_string(),
2173 display_name: None,
2174 description: "records scheduling order".to_string(),
2175 parameters: json!({ "type": "object", "properties": {} }),
2176 policy: Default::default(),
2177 category: None,
2178 deferrable: Default::default(),
2179 hints,
2180 full_parameters: None,
2181 })
2182 }
2183
2184 #[tokio::test]
2185 async fn test_act_atom_empty_tool_calls() {
2186 let executor = ToolRegistry::with_defaults();
2187 let event_emitter = NoopEventEmitter;
2188 let atom = ActAtom::new(executor, event_emitter);
2189
2190 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2191 let input = ActInput {
2192 org_id: Some(1),
2193 context,
2194 harness_id: HarnessId::from_seed(1),
2195 agent_id: Some(AgentId::new()),
2196 tool_calls: vec![],
2197 tool_definitions: vec![],
2198 locale: None,
2199 blueprint_id: None,
2200 network_access: None,
2201 parallel_tool_calls: None,
2202 };
2203
2204 let result = atom.execute(input).await.unwrap();
2205
2206 assert!(result.completed);
2207 assert!(result.results.is_empty());
2208 assert_eq!(result.success_count, 0);
2209 assert_eq!(result.error_count, 0);
2210 }
2211
2212 #[tokio::test]
2213 async fn test_act_atom_threads_utility_llm_service_to_tool_context() {
2214 let mut executor = ToolRegistry::with_defaults();
2215 executor.register(UtilityLlmContextProbeTool);
2216 let event_emitter = NoopEventEmitter;
2217 let atom = ActAtom::new(executor, event_emitter)
2218 .with_utility_llm_service(Arc::new(DisabledUtilityLlmService));
2219
2220 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2221 let input = ActInput {
2222 org_id: Some(1),
2223 context,
2224 harness_id: HarnessId::from_seed(1),
2225 agent_id: Some(AgentId::new()),
2226 tool_calls: vec![ToolCall {
2227 id: "call_1".to_string(),
2228 name: "utility_llm_context_probe".to_string(),
2229 arguments: json!({}),
2230 }],
2231 tool_definitions: vec![ToolDefinition::Builtin(BuiltinTool {
2232 name: "utility_llm_context_probe".to_string(),
2233 display_name: None,
2234 description: "checks context".to_string(),
2235 parameters: json!({
2236 "type": "object",
2237 "properties": {}
2238 }),
2239 policy: Default::default(),
2240 category: None,
2241 deferrable: Default::default(),
2242 hints: crate::tool_types::ToolHints::default(),
2243 full_parameters: None,
2244 })],
2245 locale: None,
2246 blueprint_id: None,
2247 network_access: None,
2248 parallel_tool_calls: None,
2249 };
2250
2251 let result = atom.execute(input).await.unwrap();
2252
2253 assert_eq!(result.success_count, 1);
2254 let payload = result.results[0].result.result.as_ref().unwrap();
2255 assert_eq!(payload["utility_llm_service"], true);
2256 assert_eq!(payload["configured"], false);
2257 }
2258
2259 #[tokio::test]
2264 async fn test_act_atom_schedules_batch_by_concurrency_class() {
2265 let obs = Arc::new(std::sync::Mutex::new(SchedObservations::default()));
2266
2267 let mut executor = ToolRegistry::new();
2268 executor.register(RecordingTool {
2269 name: "writer_a".to_string(),
2270 class: Some("ws".to_string()),
2271 obs: obs.clone(),
2272 });
2273 executor.register(RecordingTool {
2274 name: "writer_b".to_string(),
2275 class: Some("ws".to_string()),
2276 obs: obs.clone(),
2277 });
2278 executor.register(RecordingTool {
2279 name: "reader".to_string(),
2280 class: None,
2281 obs: obs.clone(),
2282 });
2283
2284 let atom = ActAtom::new(executor, NoopEventEmitter);
2285 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2286
2287 let input = ActInput {
2290 org_id: Some(1),
2291 context,
2292 harness_id: HarnessId::from_seed(1),
2293 agent_id: Some(AgentId::new()),
2294 tool_calls: vec![
2295 ToolCall {
2296 id: "call_a".to_string(),
2297 name: "writer_a".to_string(),
2298 arguments: json!({}),
2299 },
2300 ToolCall {
2301 id: "call_r".to_string(),
2302 name: "reader".to_string(),
2303 arguments: json!({}),
2304 },
2305 ToolCall {
2306 id: "call_b".to_string(),
2307 name: "writer_b".to_string(),
2308 arguments: json!({}),
2309 },
2310 ],
2311 tool_definitions: vec![
2312 recording_tool_def("writer_a", Some("ws"), false),
2313 recording_tool_def("reader", None, false),
2314 recording_tool_def("writer_b", Some("ws"), true),
2315 ],
2316 locale: None,
2317 blueprint_id: None,
2318 network_access: None,
2319 parallel_tool_calls: None,
2320 };
2321
2322 let result = atom.execute(input).await.unwrap();
2323
2324 assert_eq!(result.success_count, 3, "all three tools should succeed");
2326 let names: Vec<&str> = result
2328 .results
2329 .iter()
2330 .map(|r| r.tool_call.name.as_str())
2331 .collect();
2332 assert_eq!(names, vec!["writer_a", "reader", "writer_b"]);
2333
2334 let obs = obs.lock().unwrap();
2335 assert_eq!(
2338 obs.class_max.get("ws").copied(),
2339 Some(1),
2340 "same-class tools must serialize"
2341 );
2342 assert!(
2345 obs.global_max >= 2,
2346 "independent tool should run concurrently with the class group (global_max={})",
2347 obs.global_max
2348 );
2349 }
2350
2351 struct DetachedWorkTool {
2355 cancelled_tx: Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
2356 }
2357
2358 impl DetachedWorkTool {
2359 fn new(cancelled_tx: tokio::sync::oneshot::Sender<()>) -> Self {
2360 Self {
2361 cancelled_tx: Arc::new(std::sync::Mutex::new(Some(cancelled_tx))),
2362 }
2363 }
2364 }
2365
2366 #[async_trait]
2367 impl crate::tools::Tool for DetachedWorkTool {
2368 fn name(&self) -> &str {
2369 "detached_work"
2370 }
2371
2372 fn description(&self) -> &str {
2373 "spawns work that outlives the call unless cancelled"
2374 }
2375
2376 fn parameters_schema(&self) -> serde_json::Value {
2377 json!({ "type": "object", "properties": {} })
2378 }
2379
2380 fn requires_context(&self) -> bool {
2381 true
2382 }
2383
2384 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2385 ToolExecutionResult::tool_error("requires context")
2386 }
2387
2388 async fn execute_with_context(
2389 &self,
2390 _arguments: serde_json::Value,
2391 context: &crate::tool_context::ToolContext,
2392 ) -> ToolExecutionResult {
2393 let token = context
2394 .cancellation
2395 .clone()
2396 .expect("act must supply a cancellation token");
2397 assert!(!token.is_cancelled(), "token is live during the call");
2398 let tx = self.cancelled_tx.clone();
2399 tokio::spawn(async move {
2400 token.cancelled().await;
2401 if let Ok(mut guard) = tx.lock()
2402 && let Some(tx) = guard.take()
2403 {
2404 let _ = tx.send(());
2405 }
2406 });
2407 ToolExecutionResult::success(json!({ "spawned": true }))
2408 }
2409 }
2410
2411 #[tokio::test]
2415 async fn test_act_atom_cancels_detached_tool_work_when_the_call_ends() {
2416 let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel();
2417
2418 let mut executor = ToolRegistry::new();
2419 executor.register(DetachedWorkTool::new(cancelled_tx));
2420
2421 let atom = ActAtom::new(executor, NoopEventEmitter);
2422 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2423 let input = ActInput {
2424 org_id: Some(1),
2425 context,
2426 harness_id: HarnessId::from_seed(1),
2427 agent_id: Some(AgentId::new()),
2428 tool_calls: vec![ToolCall {
2429 id: "call_1".to_string(),
2430 name: "detached_work".to_string(),
2431 arguments: json!({}),
2432 }],
2433 tool_definitions: vec![recording_tool_def("detached_work", None, false)],
2434 locale: None,
2435 blueprint_id: None,
2436 network_access: None,
2437 parallel_tool_calls: None,
2438 };
2439
2440 atom.execute(input).await.expect("act should succeed");
2441
2442 tokio::time::timeout(std::time::Duration::from_secs(1), cancelled_rx)
2443 .await
2444 .expect("detached work should be cancelled once the call ends")
2445 .expect("cancellation signal should be sent");
2446 }
2447
2448 #[tokio::test]
2449 async fn test_act_atom_cancels_detached_tool_work_when_the_turn_is_cancelled() {
2450 let started = Arc::new(tokio::sync::Notify::new());
2451 let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
2452
2453 let mut executor = ToolRegistry::new();
2454 executor.register(CancellationProbeTool::new(started.clone(), dropped_tx));
2455
2456 let atom = ActAtom::new(executor, NoopEventEmitter);
2457 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2458 let input = ActInput {
2459 org_id: Some(1),
2460 context,
2461 harness_id: HarnessId::from_seed(1),
2462 agent_id: Some(AgentId::new()),
2463 tool_calls: vec![ToolCall {
2464 id: "call_1".to_string(),
2465 name: "cancellation_probe".to_string(),
2466 arguments: json!({}),
2467 }],
2468 tool_definitions: vec![recording_tool_def("cancellation_probe", None, true)],
2469 locale: None,
2470 blueprint_id: None,
2471 network_access: None,
2472 parallel_tool_calls: None,
2473 };
2474
2475 let act_task = tokio::spawn(async move { atom.execute(input).await });
2476 started.notified().await;
2477 act_task.abort();
2478 assert!(act_task.await.unwrap_err().is_cancelled());
2479
2480 tokio::time::timeout(std::time::Duration::from_secs(1), dropped_rx)
2482 .await
2483 .expect("tool future should be dropped when the turn is cancelled")
2484 .expect("drop signal should be sent");
2485 }
2486
2487 #[tokio::test]
2488 async fn test_act_atom_aborts_cpu_bound_tool_task_on_cancellation() {
2489 let started = Arc::new(tokio::sync::Notify::new());
2490 let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
2491
2492 let mut executor = ToolRegistry::new();
2493 executor.register(CancellationProbeTool::new(started.clone(), dropped_tx));
2494
2495 let atom = ActAtom::new(executor, NoopEventEmitter);
2496 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2497 let input = ActInput {
2498 org_id: Some(1),
2499 context,
2500 harness_id: HarnessId::from_seed(1),
2501 agent_id: Some(AgentId::new()),
2502 tool_calls: vec![ToolCall {
2503 id: "call_1".to_string(),
2504 name: "cancellation_probe".to_string(),
2505 arguments: json!({}),
2506 }],
2507 tool_definitions: vec![recording_tool_def("cancellation_probe", None, true)],
2508 locale: None,
2509 blueprint_id: None,
2510 network_access: None,
2511 parallel_tool_calls: None,
2512 };
2513
2514 let act_task = tokio::spawn(async move { atom.execute(input).await });
2515 started.notified().await;
2516 act_task.abort();
2517 assert!(act_task.await.unwrap_err().is_cancelled());
2518
2519 tokio::time::timeout(std::time::Duration::from_secs(1), dropped_rx)
2520 .await
2521 .expect("cpu-bound tool task should be aborted when ActAtom is cancelled")
2522 .expect("drop signal should be sent by cancelled tool future");
2523 }
2524
2525 #[tokio::test]
2528 async fn test_act_atom_parallel_tool_calls_false_serializes_everything() {
2529 let obs = Arc::new(std::sync::Mutex::new(SchedObservations::default()));
2530 let mut executor = ToolRegistry::new();
2531 for name in ["t0", "t1", "t2"] {
2532 executor.register(RecordingTool {
2533 name: name.to_string(),
2534 class: None,
2535 obs: obs.clone(),
2536 });
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![
2546 ToolCall {
2547 id: "c0".to_string(),
2548 name: "t0".to_string(),
2549 arguments: json!({}),
2550 },
2551 ToolCall {
2552 id: "c1".to_string(),
2553 name: "t1".to_string(),
2554 arguments: json!({}),
2555 },
2556 ToolCall {
2557 id: "c2".to_string(),
2558 name: "t2".to_string(),
2559 arguments: json!({}),
2560 },
2561 ],
2562 tool_definitions: vec![
2563 recording_tool_def("t0", None, false),
2564 recording_tool_def("t1", None, false),
2565 recording_tool_def("t2", None, false),
2566 ],
2567 locale: None,
2568 blueprint_id: None,
2569 network_access: None,
2570 parallel_tool_calls: Some(false),
2571 };
2572
2573 let result = atom.execute(input).await.unwrap();
2574 assert_eq!(result.success_count, 3);
2575 assert_eq!(
2576 obs.lock().unwrap().global_max,
2577 1,
2578 "parallel_tool_calls=false must serialize the whole batch"
2579 );
2580 }
2581
2582 #[tokio::test]
2583 async fn test_act_atom_tool_not_found() {
2584 let executor = ToolRegistry::with_defaults();
2585 let event_emitter = NoopEventEmitter;
2586 let atom = ActAtom::new(executor, event_emitter);
2587
2588 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2589 let input = ActInput {
2590 org_id: Some(1),
2591 context,
2592 harness_id: HarnessId::from_seed(1),
2593 agent_id: Some(AgentId::new()),
2594 tool_calls: vec![ToolCall {
2595 id: "call_1".to_string(),
2596 name: "nonexistent_tool".to_string(),
2597 arguments: json!({}),
2598 }],
2599 tool_definitions: vec![],
2600 locale: None,
2601 blueprint_id: None,
2602 network_access: None,
2603 parallel_tool_calls: None,
2604 };
2605
2606 let result = atom.execute(input).await.unwrap();
2607
2608 assert!(result.completed);
2609 assert_eq!(result.results.len(), 1);
2610 assert!(!result.results[0].success);
2611 assert_eq!(result.results[0].status, "error");
2612 assert!(
2613 result.results[0]
2614 .result
2615 .error
2616 .as_ref()
2617 .unwrap()
2618 .contains("not found")
2619 );
2620 }
2621
2622 #[tokio::test]
2623 async fn test_act_atom_uses_tool_call_hooks_for_execution_arguments() {
2624 let mut executor = ToolRegistry::new();
2625 executor.register(ArgumentEchoTool);
2626 let tool_def = executor.get("argument_echo").unwrap().to_definition();
2627 let emitter = crate::test_fixtures::TestEventEmitter::new();
2628 let atom = ActAtom::new(executor, emitter.clone())
2629 .with_tool_call_hooks(vec![std::sync::Arc::new(HumanIntentFixtureHook)]);
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: "argument_echo".to_string(),
2640 arguments: json!({
2641 "value": "visible",
2642 "human_intent": "Echoing test arguments"
2643 }),
2644 }],
2645 tool_definitions: vec![tool_def],
2646 locale: None,
2647 blueprint_id: None,
2648 network_access: None,
2649 parallel_tool_calls: None,
2650 };
2651
2652 let result = atom.execute(input).await.unwrap();
2653
2654 assert!(result.results[0].success);
2655 assert_eq!(
2656 result.results[0].result.result,
2657 Some(json!({ "value": "visible" }))
2658 );
2659
2660 let events = emitter.events().await;
2661 assert_eq!(
2662 events
2663 .iter()
2664 .map(|event| event.event_type.as_str())
2665 .collect::<Vec<_>>(),
2666 vec![
2667 "act.started",
2668 "tool.started",
2669 "tool.completed",
2670 "act.completed",
2671 ],
2672 "all hosts must observe the engine-owned phase order",
2673 );
2674 let act_started = events
2675 .iter()
2676 .find(|event| event.event_type == "act.started")
2677 .expect("act.started event");
2678 let crate::events::EventData::ActStarted(data) = &act_started.data else {
2679 panic!("expected act.started data");
2680 };
2681 assert_eq!(data.headline.as_deref(), Some("Echoing test arguments"));
2682 assert_eq!(
2683 data.tool_calls[0].narration.as_deref(),
2684 Some("Echoing test arguments")
2685 );
2686
2687 let tool_started = events
2688 .iter()
2689 .find(|event| event.event_type == "tool.started")
2690 .expect("tool.started event");
2691 let crate::events::EventData::ToolStarted(data) = &tool_started.data else {
2692 panic!("expected tool.started data");
2693 };
2694 let started_fingerprint = data
2695 .tool_call_fingerprint
2696 .as_ref()
2697 .expect("tool.started call fingerprint");
2698 assert_eq!(data.narration.as_deref(), Some("Echoing test arguments"));
2699
2700 let tool_completed = events
2701 .iter()
2702 .find(|event| event.event_type == "tool.completed")
2703 .expect("tool.completed event");
2704 let crate::events::EventData::ToolCompleted(data) = &tool_completed.data else {
2705 panic!("expected tool.completed data");
2706 };
2707 assert_eq!(
2708 data.tool_call_fingerprint.as_ref(),
2709 Some(started_fingerprint)
2710 );
2711 assert!(data.tool_result_fingerprint.is_some());
2712 assert_eq!(data.narration.as_deref(), Some("Echoing test arguments"));
2713 }
2714
2715 #[tokio::test]
2716 async fn test_act_atom_strips_human_intent_from_client_tool_calls() {
2717 let executor = ToolRegistry::new();
2718 let emitter = crate::test_fixtures::TestEventEmitter::new();
2719 let atom = ActAtom::new(executor, emitter)
2720 .with_tool_call_hooks(vec![std::sync::Arc::new(HumanIntentFixtureHook)]);
2721
2722 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2723 let input = ActInput {
2724 org_id: Some(1),
2725 context,
2726 harness_id: HarnessId::from_seed(1),
2727 agent_id: Some(AgentId::new()),
2728 tool_calls: vec![ToolCall {
2729 id: "call_client".to_string(),
2730 name: "browser_click".to_string(),
2731 arguments: json!({
2732 "selector": "#btn",
2733 "human_intent": "Clicking approve"
2734 }),
2735 }],
2736 tool_definitions: vec![ToolDefinition::ClientSide(ClientSideTool {
2737 name: "browser_click".to_string(),
2738 display_name: None,
2739 description: "Click button".to_string(),
2740 parameters: json!({
2741 "type": "object",
2742 "properties": {
2743 "selector": {"type": "string"}
2744 },
2745 "required": ["selector"]
2746 }),
2747 category: None,
2748 deferrable: Default::default(),
2749 hints: crate::tool_types::ToolHints::default(),
2750 full_parameters: None,
2751 })],
2752 locale: None,
2753 blueprint_id: None,
2754 network_access: None,
2755 parallel_tool_calls: None,
2756 };
2757
2758 let result = atom.execute(input).await.unwrap();
2759
2760 assert_eq!(result.client_tool_calls.len(), 1);
2761 assert_eq!(
2762 result.client_tool_calls[0].arguments,
2763 json!({ "selector": "#btn" })
2764 );
2765 }
2766
2767 #[test]
2768 fn test_act_result_connection_required_serialization() {
2769 let result = ActResult {
2770 results: vec![ToolCallResult {
2771 tool_call: ToolCall {
2772 id: "call_1".to_string(),
2773 name: "daytona_create_sandbox".to_string(),
2774 arguments: json!({}),
2775 },
2776 result: ToolResult {
2777 tool_call_id: "call_1".to_string(),
2778 result: Some(json!({"connection_required": "daytona"})),
2779 images: None,
2780 error: None,
2781 connection_required: Some("daytona".to_string()),
2782 raw_output: None,
2783 },
2784 success: false,
2785 status: "success".to_string(),
2786 connection_required: Some("daytona".to_string()),
2787 determinism_fatal: None,
2788 }],
2789 completed: true,
2790 success_count: 0,
2791 error_count: 0,
2792 waiting_for_tool_results: true,
2793 blocked: false,
2794 client_tool_calls: vec![],
2795 client_tool_definitions: vec![],
2796 };
2797
2798 let json_str = serde_json::to_string(&result).unwrap();
2799 let parsed: ActResult = serde_json::from_str(&json_str).unwrap();
2800
2801 assert!(parsed.waiting_for_tool_results);
2802 assert_eq!(
2803 parsed.results[0].connection_required,
2804 Some("daytona".to_string())
2805 );
2806 }
2807
2808 #[test]
2809 fn test_act_result_backward_compat_deserialization() {
2810 let json_str = r#"{
2812 "results": [],
2813 "completed": true,
2814 "success_count": 0,
2815 "error_count": 0
2816 }"#;
2817 let parsed: ActResult = serde_json::from_str(json_str).unwrap();
2818
2819 assert!(!parsed.waiting_for_tool_results);
2820 assert!(parsed.client_tool_calls.is_empty());
2821 }
2822
2823 #[tokio::test]
2826 async fn test_outbound_tool_rate_limiter_blocks_execution() {
2827 use crate::typed_id::OrgId;
2828
2829 struct DenyAll;
2830 #[async_trait]
2831 impl crate::tool_execution::OutboundToolRateLimiter for DenyAll {
2832 async fn check_org(&self, _org_id: &OrgId) -> bool {
2833 false
2834 }
2835 }
2836
2837 let mut executor = ToolRegistry::with_defaults();
2838 executor.register(ArgumentEchoTool);
2839 let atom = ActAtom::new(executor, NoopEventEmitter)
2840 .with_org_id(OrgId::from_seed(1))
2841 .with_outbound_tool_rate_limiter(Arc::new(DenyAll));
2842
2843 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2844 let input = ActInput {
2845 org_id: Some(1),
2846 context,
2847 harness_id: HarnessId::from_seed(1),
2848 agent_id: Some(AgentId::new()),
2849 tool_calls: vec![ToolCall {
2850 id: "call_1".to_string(),
2851 name: "argument_echo".to_string(),
2852 arguments: json!({"value": "should_not_reach"}),
2853 }],
2854 tool_definitions: vec![ToolDefinition::Builtin(BuiltinTool {
2855 name: "argument_echo".to_string(),
2856 display_name: None,
2857 description: "echo".to_string(),
2858 parameters: json!({"type": "object"}),
2859 policy: Default::default(),
2860 category: None,
2861 deferrable: Default::default(),
2862 hints: crate::tool_types::ToolHints::default(),
2863 full_parameters: None,
2864 })],
2865 locale: None,
2866 blueprint_id: None,
2867 network_access: None,
2868 parallel_tool_calls: None,
2869 };
2870
2871 let result = atom.execute(input).await.unwrap();
2872
2873 assert_eq!(result.success_count, 0);
2874 assert_eq!(result.error_count, 1);
2875 let tool_result = &result.results[0];
2876 assert!(!tool_result.success);
2877 assert_eq!(tool_result.status, "error");
2878 assert!(
2879 tool_result
2880 .result
2881 .error
2882 .as_deref()
2883 .unwrap_or("")
2884 .contains("rate limit exceeded")
2885 );
2886 assert!(tool_result.result.result.is_none());
2887 }
2888
2889 #[tokio::test]
2891 async fn test_outbound_tool_rate_limiter_allows_execution() {
2892 use crate::typed_id::OrgId;
2893
2894 struct AllowAll;
2895 #[async_trait]
2896 impl crate::tool_execution::OutboundToolRateLimiter for AllowAll {
2897 async fn check_org(&self, _org_id: &OrgId) -> bool {
2898 true
2899 }
2900 }
2901
2902 let mut executor = ToolRegistry::with_defaults();
2903 executor.register(ArgumentEchoTool);
2904 let atom = ActAtom::new(executor, NoopEventEmitter)
2905 .with_org_id(OrgId::from_seed(1))
2906 .with_outbound_tool_rate_limiter(Arc::new(AllowAll));
2907
2908 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2909 let input = ActInput {
2910 org_id: Some(1),
2911 context,
2912 harness_id: HarnessId::from_seed(1),
2913 agent_id: Some(AgentId::new()),
2914 tool_calls: vec![ToolCall {
2915 id: "call_1".to_string(),
2916 name: "argument_echo".to_string(),
2917 arguments: json!({"value": "hello"}),
2918 }],
2919 tool_definitions: vec![ToolDefinition::Builtin(BuiltinTool {
2920 name: "argument_echo".to_string(),
2921 display_name: None,
2922 description: "echo".to_string(),
2923 parameters: json!({"type": "object"}),
2924 policy: Default::default(),
2925 category: None,
2926 deferrable: Default::default(),
2927 hints: crate::tool_types::ToolHints::default(),
2928 full_parameters: None,
2929 })],
2930 locale: None,
2931 blueprint_id: None,
2932 network_access: None,
2933 parallel_tool_calls: None,
2934 };
2935
2936 let result = atom.execute(input).await.unwrap();
2937
2938 assert_eq!(result.success_count, 1);
2939 assert_eq!(result.error_count, 0);
2940 }
2941
2942 use crate::tool_types::{SideEffectClass, ToolHints};
2947 use crate::{durability::DurableToolResultStore, durability::ToolCallClaimResult};
2948 use std::collections::HashMap;
2949 use std::sync::Mutex;
2950
2951 #[derive(Default)]
2952 struct InMemoryDurableStore {
2953 rows: Mutex<HashMap<(String, String), StoreRow>>,
2954 }
2955
2956 #[derive(Clone)]
2957 struct StoreRow {
2958 status: String,
2959 result_json: serde_json::Value,
2960 args_fingerprint: String,
2961 #[allow(dead_code)]
2962 claim_token: Uuid,
2963 }
2964
2965 #[async_trait]
2966 impl DurableToolResultStore for InMemoryDurableStore {
2967 async fn try_claim_tool_call(
2968 &self,
2969 turn_id: &str,
2970 tool_call_id: &str,
2971 _tool_name: &str,
2972 args_fingerprint: &str,
2973 ) -> crate::error::Result<ToolCallClaimResult> {
2974 let key = (turn_id.to_string(), tool_call_id.to_string());
2975 let mut rows = self.rows.lock().unwrap();
2976 if let Some(row) = rows.get(&key) {
2977 match row.status.as_str() {
2978 "settled" => {
2979 if row.args_fingerprint != args_fingerprint {
2980 return Ok(ToolCallClaimResult::DeterminismViolation {
2981 stored_fingerprint: row.args_fingerprint.clone(),
2982 current_fingerprint: args_fingerprint.to_string(),
2983 });
2984 }
2985 return Ok(ToolCallClaimResult::AlreadySettled {
2986 result_json: row.result_json.clone(),
2987 args_fingerprint: row.args_fingerprint.clone(),
2988 });
2989 }
2990 _ => {
2991 return Ok(ToolCallClaimResult::AlreadyRunning {
2992 args_fingerprint: row.args_fingerprint.clone(),
2993 });
2994 }
2995 }
2996 }
2997 let token = Uuid::new_v4();
2998 rows.insert(
2999 key,
3000 StoreRow {
3001 status: "running".to_string(),
3002 result_json: serde_json::Value::Null,
3003 args_fingerprint: args_fingerprint.to_string(),
3004 claim_token: token,
3005 },
3006 );
3007 Ok(ToolCallClaimResult::Claimed { claim_token: token })
3008 }
3009
3010 async fn settle_tool_call(
3011 &self,
3012 turn_id: &str,
3013 tool_call_id: &str,
3014 result_json: serde_json::Value,
3015 status: &str,
3016 _claim_token: Uuid,
3017 ) -> crate::error::Result<bool> {
3018 let key = (turn_id.to_string(), tool_call_id.to_string());
3019 let mut rows = self.rows.lock().unwrap();
3020 if let Some(row) = rows.get_mut(&key) {
3021 row.status = status.to_string();
3022 row.result_json = result_json;
3023 return Ok(true);
3024 }
3025 Ok(false)
3026 }
3027
3028 async fn get_tool_call_status(
3029 &self,
3030 turn_id: &str,
3031 tool_call_id: &str,
3032 ) -> crate::error::Result<Option<crate::durability::DurableToolCallStatus>> {
3033 let key = (turn_id.to_string(), tool_call_id.to_string());
3034 let rows = self.rows.lock().unwrap();
3035 Ok(rows.get(&key).map(|row| match row.status.as_str() {
3036 "settled" => crate::durability::DurableToolCallStatus::Settled {
3037 result_json: row.result_json.clone(),
3038 },
3039 "interrupted" => crate::durability::DurableToolCallStatus::Interrupted {
3040 result_json: Some(row.result_json.clone()),
3041 },
3042 _ => crate::durability::DurableToolCallStatus::Running,
3043 }))
3044 }
3045 }
3046
3047 fn make_act_input_with_store(
3048 tool_call: ToolCall,
3049 tool_defs: Vec<ToolDefinition>,
3050 context: ExecutionContext,
3051 ) -> ActInput {
3052 ActInput {
3053 org_id: None,
3054 context,
3055 harness_id: HarnessId::from_seed(1),
3056 agent_id: Some(AgentId::new()),
3057 tool_calls: vec![tool_call],
3058 tool_definitions: tool_defs,
3059 locale: None,
3060 blueprint_id: None,
3061 network_access: None,
3062 parallel_tool_calls: None,
3063 }
3064 }
3065
3066 fn arg_echo_tool_def(side_effect: SideEffectClass) -> ToolDefinition {
3067 ToolDefinition::Builtin(BuiltinTool {
3068 name: "argument_echo".to_string(),
3069 display_name: None,
3070 description: "echo".to_string(),
3071 parameters: json!({"type": "object"}),
3072 policy: Default::default(),
3073 category: None,
3074 deferrable: Default::default(),
3075 hints: ToolHints::default().with_side_effect_class(side_effect),
3076 full_parameters: None,
3077 })
3078 }
3079
3080 #[tokio::test]
3082 async fn test_idempotency_first_execution_claims_and_settles() {
3083 let store = Arc::new(InMemoryDurableStore::default());
3084 let mut executor = ToolRegistry::with_defaults();
3085 executor.register(ArgumentEchoTool);
3086 let atom =
3087 ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3088
3089 let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
3090 let tc = ToolCall {
3091 id: "c1".to_string(),
3092 name: "argument_echo".to_string(),
3093 arguments: json!({"value": "hello"}),
3094 };
3095 let input = make_act_input_with_store(
3096 tc,
3097 vec![arg_echo_tool_def(SideEffectClass::AtMostOnce)],
3098 context,
3099 );
3100
3101 let result = atom.execute(input).await.unwrap();
3102 assert_eq!(result.success_count, 1);
3103 assert_eq!(result.error_count, 0);
3104
3105 let rows = store.rows.lock().unwrap();
3107 let row = rows.values().next().unwrap();
3108 assert_eq!(row.status, "settled");
3109 }
3110
3111 #[tokio::test]
3113 async fn test_idempotency_replay_already_settled() {
3114 use crate::tool_fingerprint::tool_call_fingerprint;
3115
3116 let store = Arc::new(InMemoryDurableStore::default());
3117 let tc = ToolCall {
3118 id: "c1".to_string(),
3119 name: "argument_echo".to_string(),
3120 arguments: json!({"value": "hello"}),
3121 };
3122 let fp = tool_call_fingerprint(&tc);
3123
3124 {
3126 let stored_result = serde_json::to_value(ToolResult {
3127 tool_call_id: "c1".to_string(),
3128 result: Some(json!({"value": "hello"})),
3129 images: None,
3130 error: None,
3131 connection_required: None,
3132 raw_output: None,
3133 })
3134 .unwrap();
3135 store.rows.lock().unwrap().insert(
3136 (
3137 "turn_00000000000000000000000000000000".to_string(),
3138 "c1".to_string(),
3139 ),
3140 StoreRow {
3141 status: "settled".to_string(),
3142 result_json: stored_result,
3143 args_fingerprint: fp,
3144 claim_token: Uuid::new_v4(),
3145 },
3146 );
3147 }
3148
3149 let mut executor = ToolRegistry::with_defaults();
3150 executor.register(ArgumentEchoTool);
3151 let atom =
3152 ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3153
3154 let context = ExecutionContext::new(
3155 SessionId::new(),
3156 TurnId::from_uuid(Uuid::nil()),
3157 MessageId::new(),
3158 );
3159 let input = make_act_input_with_store(
3160 tc,
3161 vec![arg_echo_tool_def(SideEffectClass::AtMostOnce)],
3162 context,
3163 );
3164
3165 let result = atom.execute(input).await.unwrap();
3166 assert_eq!(result.success_count, 1, "replay should count as success");
3167 assert_eq!(result.error_count, 0);
3168 }
3169
3170 #[tokio::test]
3172 async fn test_idempotency_at_most_once_stale_running_returns_interrupted() {
3173 use crate::tool_fingerprint::tool_call_fingerprint;
3174
3175 let store = Arc::new(InMemoryDurableStore::default());
3176 let tc = ToolCall {
3177 id: "c1".to_string(),
3178 name: "argument_echo".to_string(),
3179 arguments: json!({"value": "x"}),
3180 };
3181 let fp = tool_call_fingerprint(&tc);
3182
3183 store.rows.lock().unwrap().insert(
3185 (
3186 "turn_00000000000000000000000000000000".to_string(),
3187 "c1".to_string(),
3188 ),
3189 StoreRow {
3190 status: "running".to_string(),
3191 result_json: serde_json::Value::Null,
3192 args_fingerprint: fp,
3193 claim_token: Uuid::new_v4(),
3194 },
3195 );
3196
3197 let mut executor = ToolRegistry::with_defaults();
3198 executor.register(ArgumentEchoTool);
3199 let atom =
3200 ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3201
3202 let context = ExecutionContext::new(
3203 SessionId::new(),
3204 TurnId::from_uuid(Uuid::nil()),
3205 MessageId::new(),
3206 );
3207 let input = make_act_input_with_store(
3208 tc,
3209 vec![arg_echo_tool_def(SideEffectClass::AtMostOnce)],
3210 context,
3211 );
3212
3213 let result = atom.execute(input).await.unwrap();
3214 assert_eq!(
3215 result.error_count, 1,
3216 "AtMostOnce stale running should error"
3217 );
3218 let err = result.results[0].result.error.as_deref().unwrap_or("");
3219 assert!(
3220 err.contains("interrupted"),
3221 "error should mention interrupted: {err}"
3222 );
3223
3224 let rows = store.rows.lock().unwrap();
3226 let row = rows.values().next().unwrap();
3227 assert_eq!(row.status, "interrupted");
3228 }
3229
3230 #[tokio::test]
3232 async fn test_idempotency_idempotent_tool_stale_running_reexecutes() {
3233 use crate::tool_fingerprint::tool_call_fingerprint;
3234
3235 let store = Arc::new(InMemoryDurableStore::default());
3236 let tc = ToolCall {
3237 id: "c1".to_string(),
3238 name: "argument_echo".to_string(),
3239 arguments: json!({"value": "x"}),
3240 };
3241 let fp = tool_call_fingerprint(&tc);
3242
3243 store.rows.lock().unwrap().insert(
3244 (
3245 "turn_00000000000000000000000000000000".to_string(),
3246 "c1".to_string(),
3247 ),
3248 StoreRow {
3249 status: "running".to_string(),
3250 result_json: serde_json::Value::Null,
3251 args_fingerprint: fp,
3252 claim_token: Uuid::new_v4(),
3253 },
3254 );
3255
3256 let mut executor = ToolRegistry::with_defaults();
3257 executor.register(ArgumentEchoTool);
3258 let atom =
3259 ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3260
3261 let context = ExecutionContext::new(
3262 SessionId::new(),
3263 TurnId::from_uuid(Uuid::nil()),
3264 MessageId::new(),
3265 );
3266 let input = make_act_input_with_store(
3267 tc,
3268 vec![arg_echo_tool_def(SideEffectClass::Idempotent)],
3269 context,
3270 );
3271
3272 let result = atom.execute(input).await.unwrap();
3273 assert_eq!(
3274 result.success_count, 1,
3275 "Idempotent should re-execute successfully"
3276 );
3277 assert_eq!(result.error_count, 0);
3278 }
3279}