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