Skip to main content

everruns_engine/execution/
act.rs

1//! ActAtom - Atom for scheduled tool execution
2//!
3//! This atom handles:
4//! 1. Emitting act.started event
5//! 2. Executing the batch of tool calls via the [`tool_scheduler`] (with
6//!    tool.started/completed events). Calls run concurrently by default, but
7//!    calls that share a [`crate::tool_types::ToolHints::concurrency_class`] are
8//!    serialized to avoid mutation races, total concurrency is capped, and
9//!    `cpu_bound` tools are offloaded to their own task.
10//! 3. Handling errors, timeouts, and cancellations as "normal" results
11//! 4. Emitting act.completed event
12//! 5. Returning all tool results (success, error, timeout, or cancelled)
13//!
14//! Tool results are emitted as `tool.completed` events and returned in ActResult.
15//! Messages are derived from events - no separate message storage is needed.
16//!
17//! Note: OTel instrumentation is handled via the event-listener pattern.
18//! tool.started/completed events are emitted by this atom, and OtelEventListener
19//! creates the appropriate gen-ai spans from those events.
20//!
21//! NOTES from Python spec:
22//! - Tool calls run concurrently by default; the scheduler serializes only
23//!   conflicting (same-concurrency-class) calls. See [`tool_scheduler`].
24//! - Error from tool call is not an error for the whole Act, error from tool is "normal" result
25//! - Tool invocations should be timeouted, timeout is also "normal" result from tool
26//! - Exit of act should have all tool calls finished (successfully or with error/timeout)
27//! - Act and each tool call should emit start/end events
28//! - Act and each tool call should be cancellable, and this is also "normal" result
29
30use 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
64/// A Tokio task handle that aborts its task if the parent future is dropped
65/// before the task completes. Tokio detaches a bare [`tokio::task::JoinHandle`]
66/// on drop, but tool execution must not outlive Act cancellation.
67struct 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// ============================================================================
94// Input and Output Types
95// ============================================================================
96
97/// Input for ActAtom
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ActInput {
100    /// Organization ID for scoped data access.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub org_id: Option<i64>,
103    /// Atom execution context
104    pub context: ExecutionContext,
105    /// Harness ID (needed for scheduling follow-up reason activity)
106    pub harness_id: HarnessId,
107    /// Agent ID (needed for scheduling follow-up reason activity, optional)
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub agent_id: Option<AgentId>,
110    /// Tool calls to execute
111    pub tool_calls: Vec<ToolCall>,
112    /// Available tool definitions for resolution
113    pub tool_definitions: Vec<ToolDefinition>,
114    /// Resolved locale for backend-authored tool narration and labels.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub locale: Option<String>,
117    /// Blueprint ID for blueprint-backed sessions. When set, act_activity
118    /// loads tools from the blueprint instead of from agent/harness capabilities.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub blueprint_id: Option<String>,
121    /// Merged network access list (harness ∩ agent ∩ session) for URL filtering.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub network_access: Option<crate::network_access::NetworkAccessList>,
124    /// Mirrors the request's `parallel_tool_calls`. `Some(false)` forces the
125    /// act scheduler to execute this batch strictly sequentially; `None` or
126    /// `Some(true)` uses the default class-aware concurrent schedule.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub parallel_tool_calls: Option<bool>,
129}
130
131/// Result of a single tool call execution
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct ToolCallResult {
134    /// The original tool call
135    pub tool_call: ToolCall,
136    /// The result of the tool call
137    pub result: ToolResult,
138    /// Whether the execution was successful
139    pub success: bool,
140    /// Status: "success", "error", "timeout", or "cancelled"
141    pub status: String,
142    /// If set, the tool requires a user connection for this provider before it can execute.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub connection_required: Option<String>,
145    /// Determinism violation message. When Some, ActAtom::execute returns Err to fail the
146    /// durable workflow fast rather than continuing with a corrupted replay.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub determinism_fatal: Option<String>,
149}
150
151/// Result of the ActAtom
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ActResult {
154    /// Results for all tool calls
155    pub results: Vec<ToolCallResult>,
156    /// Whether all tool calls completed (regardless of success/failure)
157    pub completed: bool,
158    /// Number of successful tool calls
159    pub success_count: u32,
160    /// Number of failed tool calls
161    pub error_count: u32,
162    /// When true, the act emitted client-side tool calls (connection setup,
163    /// client-side tools, etc.) and the worker should pause until tool results
164    /// arrive. Workers check this single flag — they never need to know *why*
165    /// the act paused.
166    #[serde(default)]
167    pub waiting_for_tool_results: bool,
168    /// True when execution stopped before tool execution because a dependency was archived or deleted.
169    #[serde(default, skip_serializing_if = "is_false")]
170    pub blocked: bool,
171    /// Client-side tool calls that were NOT executed by ActAtom but need to be
172    /// sent to the client. Populated by ActAtom's partitioning logic, consumed
173    /// by ClientSideToolHook.
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub client_tool_calls: Vec<ToolCall>,
176    /// Tool definitions for the client-side tool calls (for narration/display).
177    #[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
185// ============================================================================
186// ActAtom
187// ============================================================================
188
189/// Atom that executes a batch of tool calls via the [`tool_scheduler`]
190///
191/// This atom:
192/// 1. Emits act.started event
193/// 2. Schedules all tool calls (emitting tool.started/completed for each):
194///    concurrent by default, serialized within a concurrency class, capped, and
195///    with `cpu_bound` tools offloaded to their own task
196/// 3. Handles errors, timeouts, and cancellations gracefully
197/// 4. Emits act.completed event
198/// 5. Returns comprehensive results for all tools
199///
200/// Tool results are emitted as events and returned in ActResult.
201/// Messages are derived from events by the message store.
202pub struct ActAtom<T, E>
203where
204    T: ToolExecutor,
205    E: PhaseEffectSink,
206{
207    // Held as `Arc` so individual `cpu_bound` tool calls can be offloaded to
208    // their own task (`tokio::spawn`) without borrowing `self` for `'static`.
209    tool_executor: Arc<T>,
210    event_emitter: PhaseEffectEmitter<E>,
211    /// Runtime-owned service snapshot cloned into every per-call ToolContext.
212    context_services: crate::tool_context::ToolContextServices,
213    /// Optional per-org outbound tool-call rate limiter (TM-TOOL-009).
214    /// When present, each tool call increments the org counter; calls that
215    /// exceed the per-org window return a tool error rather than a hard failure.
216    outbound_tool_rate_limiter: Option<Arc<dyn crate::tool_execution::OutboundToolRateLimiter>>,
217    /// Per-tool-call idempotency store (EVE-530). When present, each tool call
218    /// is claimed before dispatch and settled after completion so that reclaiming
219    /// workers can skip already-settled calls and avoid double side-effects for
220    /// `AtMostOnce` tools.
221    durable_tool_result_store: Option<Arc<dyn DurableToolResultStore>>,
222    /// Post-act hooks that run after tool execution completes.
223    /// Hooks inspect the result and may emit events (e.g. tool.call_requested).
224    hooks: Vec<Box<dyn PostActHook>>,
225    /// Post-tool-exec hooks (capability-contributed): run after each individual
226    /// tool execution. Capabilities register these via `post_tool_exec_hooks()`.
227    post_tool_hooks: Vec<Arc<dyn act_hooks::PostToolExecHook>>,
228    /// Pre-tool-use hooks (capability-contributed): run before each individual
229    /// tool execution. Capabilities wire these in via the user-hooks
230    /// adapter chain (see `crate::hook_adapter`). Hooks can mutate the
231    /// `ToolCall` (returning `Continue`) or refuse execution
232    /// (returning `Block`).
233    pre_tool_hooks: Vec<Arc<dyn act_hooks::PreToolUseHook>>,
234    /// Tool-call hooks (capability-contributed): inspect model-authored tool
235    /// calls for UI narration and transform calls before actual execution.
236    tool_call_hooks: Vec<Arc<dyn crate::capabilities::ToolCallHook>>,
237    /// Final post-tool-exec hooks (infrastructure): run after capability hooks.
238    /// Always registered, cannot be removed by capabilities (EVE-225).
239    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    /// Create a new ActAtom with default hooks (ConnectionSetup + ClientSideTool).
248    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    /// Create a new ActAtom with a file store for context-aware tools
264    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    /// Replace the complete runtime-owned service snapshot used for every
287    /// per-call [`ToolContext`]. Production hosts should prefer this over
288    /// assembling individual services on the atom.
289    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    /// Add a custom post-act hook.
298    pub fn with_hook(mut self, hook: Box<dyn PostActHook>) -> Self {
299        self.hooks.push(hook);
300        self
301    }
302
303    /// Add a runtime-owned final post-tool hook. Hosts use this for portable
304    /// policies that must run after capability hooks but before the hard output
305    /// limit.
306    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    /// Default hooks: ConnectionSetup (synthetic setup_connection calls)
313    /// and ClientSideTool (emit tool.call_requested for client-side tools).
314    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    /// Default final post-tool-exec hooks (infrastructure, always-on).
322    /// These run after all capability-contributed hooks and cannot be removed.
323    fn default_final_hooks() -> Vec<Arc<dyn act_hooks::PostToolExecHook>> {
324        vec![Arc::new(act_hooks::OutputHardLimitHook)]
325    }
326
327    /// Set the session storage store on this atom
328    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    /// Set the image artifact store on this atom
337    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    /// Set the provider credential store on this atom
346    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    /// Set the utility LLM service on this atom.
355    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    /// Set the scoped-MCP tool invoker on this atom (guardrails `mcp` check).
361    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    /// Set the outbound egress service on this atom.
367    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    /// Set the user connection resolver on this atom
373    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    /// Set session store for context-aware tools.
382    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    /// Set agent store for context-aware tools.
388    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    /// Set session schedule store for scheduling tools.
394    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    /// Set platform store for org-level management tools.
403    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    /// Set leased resource store for lifecycle-managed provider resources.
412    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    /// Set session resource registry.
421    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    /// Add a session task registry passed to tool contexts.
430    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    /// Set the active built-in tool registry for meta-tools like `spawn_background`.
447    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    /// Add capability-contributed post-tool-exec hooks.
453    /// Callers should pass hooks from the *active* capabilities for this session,
454    /// not from the full platform registry.
455    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    /// Add capability-contributed pre-tool-use hooks. Pre-hooks fire before
464    /// each tool call and can mutate or block it; see
465    /// `act_hooks::PreToolUseHook` and `knowledge/runtime-resources/user-hooks.md`.
466    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    /// Set org ID for org-scoped operations.
480    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    /// Set the merged network access list for URL filtering in tools.
486    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    /// Set the budget checker for the check_budget tool.
495    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    /// Set the internal payment authority for paid capability tools.
504    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    /// Set the authority used to authorize detached peer-session creation.
513    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    /// Set the per-org outbound tool-call rate limiter (TM-TOOL-009).
522    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    /// Set the durable per-tool-call idempotency store (EVE-530).
531    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    /// Set the durable subagent spawn handle store (EVE-535).
540    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    /// Set the resolved subagent nesting policy for tool contexts.
549    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    /// Set the live reasoning-effort handle (EVE-595). When set, each tool's
558    /// `ToolContext` receives a clone so a tool can change the reasoning effort
559    /// mid-turn for subsequent LLM steps in the same turn.
560    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    /// Stable phase name used by logs and durable activity adapters.
575    pub fn name(&self) -> &'static str {
576        "act"
577    }
578
579    /// Execute one scheduled tool-call batch through injected contracts.
580    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            .. // agent_id/org_id not needed here, just passed through workflow
589        } = input;
590
591        // Partition tool calls: server-side tools get executed, client-side tools
592        // are stored on ActResult for the ClientSideToolHook to emit.
593        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) // unknown tools go to server (will error there)
600            });
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 only client-side tools (no server-side), skip tool execution entirely.
637        // Just run hooks to emit tool.call_requested.
638        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        // Replace tool_calls with only server-side tools for execution
662        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        // Generate OTel-style span IDs for hierarchical tracing
673        // trace_id: groups all events in this turn
674        // span_id: unique identifier for this act span (shared by started/completed)
675        // parent_span_id: links to turn as parent
676        //
677        // NOTE: TurnId::to_string() returns prefixed format (e.g., "turn_abc123")
678        // matching the format used by turn.started/completed events in Braintrust.
679        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(); // Parent is the turn
682
683        // Create event context from atom context with span info
684        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        // Track act phase timing for Braintrust observability
691        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        // Build tool name to definition map
701        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        // Emit act.started event (with display names from tool definitions)
742        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        // Decide the execution schedule from per-tool metadata. Calls that
759        // share a concurrency class (mutations to the same shared resource) run
760        // sequentially in arrival order; everything else runs concurrently,
761        // bounded by a global cap. `parallel_tool_calls == Some(false)` forces a
762        // fully sequential schedule. Each tool event references the act span as
763        // its parent regardless of scheduling.
764        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        // Count successes and errors
795        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        // Calculate act phase duration
799        let act_duration_ms = act_start.elapsed().as_millis() as u64;
800
801        // Emit act.completed event (same span as act.started, parent is turn)
802        let completed_context = EventContext::from_execution_context(&context).with_span(
803            trace_id.clone(),
804            act_span_id.clone(), // Same span_id as started
805            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        // Fail the durable workflow fast on any determinism violation (EVE-530).
855        // All tool.completed events have already been emitted above for affected calls.
856        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        // Run post-act hooks (connection setup, client-side tool emission, etc.)
874        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        // Capability hooks only reach tools a capability lists in `tools()`.
909        // Tools assembled outside any capability — the unified `spawn_agent`
910        // dispatcher built from delegation targets, registry-augmented and
911        // proxied tools — still own narration, so ask the executing tool
912        // directly before falling back to the generic display-name phrasing.
913        if let Some(narration) = self
914            .context_services
915            .tool_registry
916            .as_ref()
917            .and_then(|registry| registry.get(&tool_call.name))
918            .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
919        {
920            return narration;
921        }
922        render_tool_narration_with_locale(tool_def, tool_call, phase, locale)
923    }
924
925    fn render_group_headline(
926        &self,
927        execution_context: &ExecutionContext,
928        tool_calls: &[ToolCall],
929        tool_map: &std::collections::HashMap<&str, &ToolDefinition>,
930        phase: ToolNarrationPhase,
931        locale: Option<&str>,
932    ) -> Option<String> {
933        if tool_calls.is_empty() {
934            return None;
935        }
936        if let [tool_call] = tool_calls {
937            return Some(self.render_tool_narration(
938                execution_context,
939                tool_map.get(tool_call.name.as_str()).copied(),
940                tool_call,
941                phase,
942                locale,
943            ));
944        }
945
946        let actions = tool_calls
947            .iter()
948            .map(|tool_call| {
949                let tool_def = tool_map.get(tool_call.name.as_str()).copied();
950                let narration = self.render_tool_narration(
951                    execution_context,
952                    tool_def,
953                    tool_call,
954                    phase,
955                    locale,
956                );
957                let repeated_narration = self.render_tool_narration(
958                    execution_context,
959                    tool_def,
960                    &tool_call_for_group_summary(tool_call),
961                    phase,
962                    locale,
963                );
964                GroupHeadlineAction::new(tool_call, narration, repeated_narration)
965            })
966            .collect::<Vec<_>>();
967
968        Some(summarize_group_actions(&actions, locale))
969    }
970
971    /// Mirror the file-store wrapping applied during tool execution so
972    /// path-bearing narration uses the same mount resolver and workspace key.
973    fn wrap_file_store_for_narration(
974        &self,
975        execution_context: &ExecutionContext,
976    ) -> Option<Arc<dyn SessionFileSystem>> {
977        let store = self.context_services.file_store.as_ref()?.clone();
978        let store = if let Some(workspace_id) = execution_context.workspace_id {
979            crate::session_files::WorkspaceScopedFileSystem::wrap(store, workspace_id)
980        } else {
981            store
982        };
983        Some(crate::mount_fs::MountFs::wrap_if_needed(store))
984    }
985
986    fn transform_tool_call_for_execution(&self, tool_call: ToolCall) -> ToolCall {
987        self.tool_call_hooks
988            .iter()
989            .fold(tool_call, |tool_call, hook| {
990                hook.transform_for_execution(tool_call)
991            })
992    }
993
994    /// Execute a single tool call
995    ///
996    /// Note: OTel instrumentation is handled via event listeners.
997    /// tool.started/completed events are emitted, and OtelEventListener
998    /// creates gen-ai spans from those events.
999    #[allow(clippy::too_many_arguments)]
1000    async fn execute_single_tool(
1001        &self,
1002        context: &ExecutionContext,
1003        tool_call: ToolCall,
1004        tool_def: Option<&ToolDefinition>,
1005        trace_id: &str,
1006        act_span_id: &str,
1007        locale: Option<&str>,
1008        network_access: Option<&crate::network_access::NetworkAccessList>,
1009        visible_tool_names: Arc<HashSet<String>>,
1010    ) -> ToolCallResult {
1011        tracing::debug!(
1012            session_id = %context.session_id,
1013            turn_id = %context.turn_id,
1014            tool_name = %tool_call.name,
1015            tool_call_id = %tool_call.id,
1016            "ActAtom: executing tool"
1017        );
1018
1019        // Generate a unique span_id for this tool call (child of act span)
1020        let tool_span_id = Uuid::now_v7().to_string();
1021
1022        // Create event context from atom context (with act span as parent)
1023        let event_context = EventContext::from_execution_context(context).with_span(
1024            trace_id.to_string(),
1025            tool_span_id.clone(),
1026            Some(act_span_id.to_string()),
1027        );
1028
1029        // Track tool call timing for Braintrust observability
1030        let tool_start = Instant::now();
1031        let tool_call_fingerprint = tool_call_fingerprint(&tool_call);
1032
1033        // Resolve display name from tool definition
1034        let display_name = crate::localization::localized_tool_display_name(
1035            &tool_call.name,
1036            tool_def.and_then(|d| d.display_name()),
1037            locale,
1038        );
1039        let capability_attribution = tool_def.and_then(|def| {
1040            def.capability_attribution()
1041                .map(|(id, name)| (id.to_string(), name.map(str::to_string)))
1042        });
1043
1044        // THREAT[TM-TOOL-009]: enforce the injected per-org outbound tool-call limit.
1045        // Checked before tool.started so a denied call emits no events and leaves
1046        // no unmatched started/completed pair in UI or telemetry.
1047        if let (Some(limiter), Some(ref org_id)) = (
1048            &self.outbound_tool_rate_limiter,
1049            self.context_services.org_id,
1050        ) && !limiter.check_org(org_id).await
1051        {
1052            tracing::warn!(
1053                session_id = %context.session_id,
1054                tool_name = %tool_call.name,
1055                "ActAtom: outbound tool rate limit exceeded for org"
1056            );
1057            return ToolCallResult {
1058                tool_call: tool_call.clone(),
1059                result: ToolResult {
1060                    tool_call_id: tool_call.id.clone(),
1061                    result: None,
1062                    images: None,
1063                    error: Some(
1064                        "Outbound tool rate limit exceeded for this organization; back off and retry later.".to_string(),
1065                    ),
1066                    connection_required: None,
1067                    raw_output: None,
1068                },
1069                success: false,
1070                status: "error".to_string(),
1071                connection_required: None,
1072                determinism_fatal: None,
1073            };
1074        }
1075
1076        // Per-tool-call idempotency (EVE-530): claim before dispatch, replay if
1077        // already settled, refuse AtMostOnce re-execution on stale running claims.
1078        let claim_token = if let Some(ref store) = self.durable_tool_result_store {
1079            let turn_id = context.turn_id.to_string();
1080            match store
1081                .try_claim_tool_call(
1082                    &turn_id,
1083                    &tool_call.id,
1084                    &tool_call.name,
1085                    &tool_call_fingerprint,
1086                )
1087                .await
1088            {
1089                Ok(ToolCallClaimResult::Claimed { claim_token }) => Some(claim_token),
1090
1091                Ok(ToolCallClaimResult::AlreadySettled {
1092                    result_json,
1093                    args_fingerprint: stored_fp,
1094                }) => {
1095                    // Determinism guard: stored args fingerprint must match current call.
1096                    if stored_fp != tool_call_fingerprint {
1097                        let err_msg = format!(
1098                            "determinism violation: tool '{}' replay args fingerprint \
1099                             does not match prior execution (stored={stored_fp}, \
1100                             current={})",
1101                            tool_call.name, tool_call_fingerprint
1102                        );
1103                        tracing::error!(
1104                            session_id = %context.session_id,
1105                            turn_id = %context.turn_id,
1106                            tool_call_id = %tool_call.id,
1107                            stored_fp = %stored_fp,
1108                            current_fp = %tool_call_fingerprint,
1109                            "ActAtom: determinism violation — replay args fingerprint mismatch"
1110                        );
1111                        let result_fp =
1112                            tool_result_fingerprint(&tool_call.name, &ToolResult::error(&err_msg));
1113                        let _ = self
1114                            .event_emitter
1115                            .emit(EventRequest::new(
1116                                context.session_id,
1117                                event_context,
1118                                ToolCompletedData::failure(
1119                                    tool_call.id.clone(),
1120                                    tool_call.name.clone(),
1121                                    "error".to_string(),
1122                                    err_msg.clone(),
1123                                    None,
1124                                )
1125                                .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1126                                .with_display_name(display_name.clone()),
1127                            ))
1128                            .await;
1129                        return ToolCallResult {
1130                            tool_call: tool_call.clone(),
1131                            result: ToolResult {
1132                                tool_call_id: tool_call.id.clone(),
1133                                result: None,
1134                                images: None,
1135                                error: Some(err_msg.clone()),
1136                                connection_required: None,
1137                                raw_output: None,
1138                            },
1139                            success: false,
1140                            status: "error".to_string(),
1141                            connection_required: None,
1142                            determinism_fatal: Some(err_msg),
1143                        };
1144                    }
1145                    tracing::debug!(
1146                        session_id = %context.session_id,
1147                        turn_id = %context.turn_id,
1148                        tool_call_id = %tool_call.id,
1149                        "ActAtom: replaying already-settled tool call"
1150                    );
1151                    // Emit a replayed tool.completed without re-emitting tool.started.
1152                    let replayed_result: ToolResult = serde_json::from_value(result_json.clone())
1153                        .unwrap_or(ToolResult {
1154                            tool_call_id: tool_call.id.clone(),
1155                            result: Some(result_json),
1156                            images: None,
1157                            error: None,
1158                            connection_required: None,
1159                            raw_output: None,
1160                        });
1161                    let success = replayed_result.error.is_none();
1162                    let status = if success { "success" } else { "error" };
1163                    let result_fp = tool_result_fingerprint(&tool_call.name, &replayed_result);
1164                    let completed_data = if success {
1165                        // Reconstruct content: text + images (preserves image-producing tools on replay)
1166                        let mut content = replayed_result
1167                            .result
1168                            .as_ref()
1169                            .map(|r| vec![ContentPart::tool_result_text(r)])
1170                            .unwrap_or_default();
1171                        if let Some(ref images) = replayed_result.images {
1172                            for img in images {
1173                                content.push(ContentPart::Image(
1174                                    crate::message::ImageContentPart::from_base64(
1175                                        &img.base64,
1176                                        &img.media_type,
1177                                    ),
1178                                ));
1179                            }
1180                        }
1181                        ToolCompletedData::success(
1182                            tool_call.id.clone(),
1183                            tool_call.name.clone(),
1184                            content,
1185                            None,
1186                        )
1187                        .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1188                        .with_display_name(display_name.clone())
1189                    } else {
1190                        ToolCompletedData::failure(
1191                            tool_call.id.clone(),
1192                            tool_call.name.clone(),
1193                            status.to_string(),
1194                            replayed_result.error.clone().unwrap_or_default(),
1195                            None,
1196                        )
1197                        .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1198                        .with_display_name(display_name.clone())
1199                    };
1200                    let _ = self
1201                        .event_emitter
1202                        .emit(EventRequest::new(
1203                            context.session_id,
1204                            event_context,
1205                            completed_data,
1206                        ))
1207                        .await;
1208                    let conn_req = replayed_result.connection_required.clone();
1209                    return ToolCallResult {
1210                        tool_call,
1211                        result: replayed_result,
1212                        success,
1213                        status: status.to_string(),
1214                        connection_required: conn_req,
1215                        determinism_fatal: None,
1216                    };
1217                }
1218
1219                Ok(ToolCallClaimResult::AlreadyRunning {
1220                    args_fingerprint: stored_fp,
1221                }) => {
1222                    // Determinism guard: even in the running state, a fingerprint mismatch
1223                    // means the workflow is replaying with different args — fail loudly.
1224                    if stored_fp != tool_call_fingerprint {
1225                        let err_msg = format!(
1226                            "determinism violation: tool '{}' args fingerprint changed \
1227                             while prior claim is still running (stored={stored_fp}, \
1228                             current={tool_call_fingerprint})",
1229                            tool_call.name
1230                        );
1231                        tracing::error!(
1232                            session_id = %context.session_id,
1233                            turn_id = %context.turn_id,
1234                            tool_call_id = %tool_call.id,
1235                            stored = %stored_fp,
1236                            current = %tool_call_fingerprint,
1237                            "ActAtom: determinism violation — running claim fingerprint mismatch"
1238                        );
1239                        let result_fp =
1240                            tool_result_fingerprint(&tool_call.name, &ToolResult::error(&err_msg));
1241                        let _ = self
1242                            .event_emitter
1243                            .emit(EventRequest::new(
1244                                context.session_id,
1245                                event_context,
1246                                ToolCompletedData::failure(
1247                                    tool_call.id.clone(),
1248                                    tool_call.name.clone(),
1249                                    "error".to_string(),
1250                                    err_msg.clone(),
1251                                    None,
1252                                )
1253                                .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1254                                .with_display_name(display_name.clone()),
1255                            ))
1256                            .await;
1257                        return ToolCallResult {
1258                            tool_call: tool_call.clone(),
1259                            result: ToolResult {
1260                                tool_call_id: tool_call.id.clone(),
1261                                result: None,
1262                                images: None,
1263                                error: Some(err_msg.clone()),
1264                                connection_required: None,
1265                                raw_output: None,
1266                            },
1267                            success: false,
1268                            status: "error".to_string(),
1269                            connection_required: None,
1270                            determinism_fatal: Some(err_msg),
1271                        };
1272                    }
1273
1274                    let sec = tool_def
1275                        .map(|d| d.side_effect_class())
1276                        .unwrap_or(SideEffectClass::AtMostOnce);
1277                    match sec {
1278                        SideEffectClass::Pure | SideEffectClass::Idempotent => {
1279                            // Safe to re-execute; proceed as normal (no claim token).
1280                            tracing::debug!(
1281                                session_id = %context.session_id,
1282                                tool_call_id = %tool_call.id,
1283                                "ActAtom: stale running claim for idempotent tool, re-executing"
1284                            );
1285                            None
1286                        }
1287                        SideEffectClass::AtMostOnce => {
1288                            tracing::warn!(
1289                                session_id = %context.session_id,
1290                                turn_id = %context.turn_id,
1291                                tool_call_id = %tool_call.id,
1292                                "ActAtom: AtMostOnce tool has stale running claim; returning interrupted result"
1293                            );
1294                            // Settle the stale claim as interrupted, then return an error.
1295                            let _ = store
1296                                .settle_tool_call(
1297                                    &turn_id,
1298                                    &tool_call.id,
1299                                    serde_json::Value::Null,
1300                                    "interrupted",
1301                                    Uuid::nil(), // sentinel — bypass token check for interrupt
1302                                )
1303                                .await;
1304                            let err_msg = format!(
1305                                "tool '{}' was interrupted mid-execution during a prior \
1306                                 worker failure; result is uncertain and was not re-run \
1307                                 (AtMostOnce safety)",
1308                                tool_call.name
1309                            );
1310                            let result_fp = tool_result_fingerprint(
1311                                &tool_call.name,
1312                                &ToolResult::error(&err_msg),
1313                            );
1314                            let _ = self
1315                                .event_emitter
1316                                .emit(EventRequest::new(
1317                                    context.session_id,
1318                                    event_context,
1319                                    ToolCompletedData::failure(
1320                                        tool_call.id.clone(),
1321                                        tool_call.name.clone(),
1322                                        "interrupted".to_string(),
1323                                        err_msg.clone(),
1324                                        None,
1325                                    )
1326                                    .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1327                                    .with_display_name(display_name.clone()),
1328                                ))
1329                                .await;
1330                            return ToolCallResult {
1331                                tool_call: tool_call.clone(),
1332                                result: ToolResult {
1333                                    tool_call_id: tool_call.id.clone(),
1334                                    result: None,
1335                                    images: None,
1336                                    error: Some(err_msg),
1337                                    connection_required: None,
1338                                    raw_output: None,
1339                                },
1340                                success: false,
1341                                status: "error".to_string(),
1342                                connection_required: None,
1343                                determinism_fatal: None,
1344                            };
1345                        }
1346                    }
1347                }
1348
1349                Ok(ToolCallClaimResult::DeterminismViolation {
1350                    stored_fingerprint,
1351                    current_fingerprint,
1352                }) => {
1353                    let err_msg = format!(
1354                        "determinism violation: tool '{}' args fingerprint changed \
1355                         on replay (stored={stored_fingerprint}, \
1356                         current={current_fingerprint})",
1357                        tool_call.name
1358                    );
1359                    tracing::error!(
1360                        session_id = %context.session_id,
1361                        turn_id = %context.turn_id,
1362                        tool_call_id = %tool_call.id,
1363                        stored = %stored_fingerprint,
1364                        current = %current_fingerprint,
1365                        "ActAtom: determinism violation on claim"
1366                    );
1367                    let result_fp =
1368                        tool_result_fingerprint(&tool_call.name, &ToolResult::error(&err_msg));
1369                    let _ = self
1370                        .event_emitter
1371                        .emit(EventRequest::new(
1372                            context.session_id,
1373                            event_context,
1374                            ToolCompletedData::failure(
1375                                tool_call.id.clone(),
1376                                tool_call.name.clone(),
1377                                "error".to_string(),
1378                                err_msg.clone(),
1379                                None,
1380                            )
1381                            .with_fingerprints(tool_call_fingerprint.clone(), result_fp)
1382                            .with_display_name(display_name.clone()),
1383                        ))
1384                        .await;
1385                    return ToolCallResult {
1386                        tool_call: tool_call.clone(),
1387                        result: ToolResult {
1388                            tool_call_id: tool_call.id.clone(),
1389                            result: None,
1390                            images: None,
1391                            error: Some(err_msg.clone()),
1392                            connection_required: None,
1393                            raw_output: None,
1394                        },
1395                        success: false,
1396                        status: "error".to_string(),
1397                        connection_required: None,
1398                        determinism_fatal: Some(err_msg),
1399                    };
1400                }
1401
1402                Err(e) => {
1403                    tracing::warn!(
1404                        session_id = %context.session_id,
1405                        tool_call_id = %tool_call.id,
1406                        error = %e,
1407                        "ActAtom: durable claim failed; proceeding without idempotency"
1408                    );
1409                    None
1410                }
1411            }
1412        } else {
1413            None
1414        };
1415
1416        // Emit tool.started event (child of act.started)
1417        if let Err(e) = self
1418            .event_emitter
1419            .emit(EventRequest::new(
1420                context.session_id,
1421                event_context.clone(),
1422                ToolStartedData {
1423                    tool_call: tool_call.clone(),
1424                    tool_call_fingerprint: Some(tool_call_fingerprint.clone()),
1425                    display_name: display_name.clone(),
1426                    narration: Some(self.render_tool_narration(
1427                        context,
1428                        tool_def,
1429                        &tool_call,
1430                        ToolNarrationPhase::Started,
1431                        locale,
1432                    )),
1433                },
1434            ))
1435            .await
1436        {
1437            tracing::warn!(
1438                session_id = %context.session_id,
1439                tool_call_id = %tool_call.id,
1440                error = %e,
1441                "ActAtom: failed to emit tool.started event"
1442            );
1443        }
1444
1445        // If tool definition not found, return error result
1446        let Some(tool_def) = tool_def else {
1447            let error_msg = format!("Tool definition not found: {}", tool_call.name);
1448            let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1449
1450            // Emit tool.completed event for error (child of act.started)
1451            if let Err(e) = self
1452                .event_emitter
1453                .emit(EventRequest::new(
1454                    context.session_id,
1455                    event_context,
1456                    ToolCompletedData::failure(
1457                        tool_call.id.clone(),
1458                        tool_call.name.clone(),
1459                        "error".to_string(),
1460                        error_msg.clone(),
1461                        Some(tool_duration_ms),
1462                    )
1463                    .with_fingerprints(
1464                        tool_call_fingerprint.clone(),
1465                        tool_error_fingerprint(&tool_call.name, "error", &error_msg),
1466                    )
1467                    .with_narration(Some(self.render_tool_narration(
1468                        context,
1469                        None,
1470                        &tool_call,
1471                        ToolNarrationPhase::Failed,
1472                        locale,
1473                    ))),
1474                ))
1475                .await
1476            {
1477                tracing::warn!(
1478                    session_id = %context.session_id,
1479                    tool_call_id = %tool_call.id,
1480                    error = %e,
1481                    "ActAtom: failed to emit tool.completed event"
1482                );
1483            }
1484
1485            return ToolCallResult {
1486                tool_call: tool_call.clone(),
1487                result: ToolResult {
1488                    tool_call_id: tool_call.id.clone(),
1489                    result: None,
1490                    images: None,
1491                    error: Some(error_msg),
1492                    connection_required: None,
1493                    raw_output: None,
1494                },
1495                success: false,
1496                status: "error".to_string(),
1497                connection_required: None,
1498                determinism_fatal: None,
1499            };
1500        };
1501
1502        // Execute the tool (always with context so tools can emit progress events)
1503        let mut tool_context =
1504            ToolContext::from_services(context.session_id, &self.context_services);
1505        // Key file I/O by the attached workspace when known: pin the file store
1506        // to the workspace so shared-workspace sessions address the workspace's
1507        // files, not the session's own keyspace. For the default 1:1 case this
1508        // is a transparent pass-through.
1509        if let Some(workspace_id) = context.workspace_id {
1510            tool_context.workspace_id = workspace_id;
1511            if let Some(store) = tool_context.file_store.take() {
1512                tool_context.file_store = Some(
1513                    crate::session_files::WorkspaceScopedFileSystem::wrap(store, workspace_id),
1514                );
1515            }
1516        }
1517        // Resolve model paths through the mount resolver (EVE-660): `/workspace`
1518        // is a mount + cwd, not a per-store prefix. Applied over the
1519        // workspace-keyed store so resolution sits above re-keying.
1520        if let Some(store) = tool_context.file_store.take() {
1521            tool_context.file_store = Some(crate::mount_fs::MountFs::wrap_if_needed(store));
1522        }
1523        tool_context.visible_tool_names = Some(visible_tool_names.clone());
1524        // Input network_access (per-session, merged from harness+agent+session) takes precedence
1525        tool_context.network_access = network_access
1526            .cloned()
1527            .or_else(|| self.context_services.network_access.clone());
1528        // Provide event emitter + context so tools can emit tool.progress events
1529        if tool_context.event_emitter.is_none() {
1530            tool_context.event_emitter =
1531                Some(Arc::new(self.event_emitter.clone()) as Arc<dyn EventEmitter>);
1532        }
1533        tool_context.event_context = Some(event_context.clone());
1534        tool_context.tool_call_id = Some(tool_call.id.clone());
1535
1536        // Cooperative cancellation for this call. The guard fires when this
1537        // future is dropped — which is what a cancelled turn looks like from
1538        // here — and also on normal return, so the contract a tool sees is
1539        // simply "this call is over". Work the tool leaves running (a child
1540        // process, a detached watcher) can hold a clone and die with the call
1541        // instead of outliving it; dropping the future alone cannot tell it
1542        // anything, because a dropped future is never polled again.
1543        let call_cancellation = tokio_util::sync::CancellationToken::new();
1544        tool_context.cancellation = Some(call_cancellation.clone());
1545        let _cancel_on_call_end = call_cancellation.drop_guard();
1546
1547        let execution_tool_call = self.transform_tool_call_for_execution(tool_call.clone());
1548
1549        // Run pre-tool-use hooks (capability-contributed). They can mutate
1550        // the tool call or block execution entirely. First Block wins; the
1551        // tool is not invoked, and the synthetic error result flows through
1552        // the same completion/event path as a tool failure.
1553        let (execution_tool_call, pre_block_reason) = if self.pre_tool_hooks.is_empty() {
1554            (execution_tool_call, None)
1555        } else {
1556            match act_hooks::run_pre_tool_use_hooks(
1557                &self.pre_tool_hooks,
1558                execution_tool_call.clone(),
1559                tool_def,
1560                &tool_context,
1561            )
1562            .await
1563            {
1564                act_hooks::PreToolUseDecision::Continue(updated) => (updated, None),
1565                act_hooks::PreToolUseDecision::Block {
1566                    tool_call: blocked,
1567                    reason,
1568                    ..
1569                } => (blocked, Some(reason)),
1570            }
1571        };
1572
1573        let result = if let Some(reason) = pre_block_reason {
1574            tracing::warn!(
1575                session_id = %context.session_id,
1576                tool_call_id = %execution_tool_call.id,
1577                tool_name = %execution_tool_call.name,
1578                reason = %reason,
1579                "ActAtom: pre_tool_use hook blocked execution"
1580            );
1581            Ok(crate::tool_types::ToolResult {
1582                tool_call_id: execution_tool_call.id.clone(),
1583                result: None,
1584                images: None,
1585                error: Some(format!("blocked by pre_tool_use hook: {reason}")),
1586                connection_required: None,
1587                raw_output: None,
1588            })
1589        } else if tool_def.is_cpu_bound() {
1590            // CPU-bound / non-yielding in-process tools (e.g. the bash
1591            // interpreter) get their own task so a long synchronous burst
1592            // cannot starve the cooperative polling of I/O-bound tools running
1593            // alongside them in this act batch. On the multi-thread runtime the
1594            // spawned task can also progress on another worker thread.
1595            let executor = self.tool_executor.clone();
1596            let call = execution_tool_call.clone();
1597            let def = tool_def.clone();
1598            let ctx = tool_context.clone();
1599            match AbortOnDropJoinHandle::new(tokio::spawn(async move {
1600                executor.execute_with_context(&call, &def, &ctx).await
1601            }))
1602            .await
1603            {
1604                Ok(result) => result,
1605                Err(join_err) => Err(crate::error::AgentLoopError::tool(format!(
1606                    "tool task failed to complete: {join_err}"
1607                ))),
1608            }
1609        } else {
1610            self.tool_executor
1611                .execute_with_context(&execution_tool_call, tool_def, &tool_context)
1612                .await
1613        };
1614
1615        match result {
1616            Ok(mut tool_result) => {
1617                // Run post-tool-exec hooks (capability then final/infrastructure)
1618                act_hooks::run_post_tool_exec_hooks(
1619                    &self.post_tool_hooks,
1620                    &self.final_post_tool_hooks,
1621                    &execution_tool_call,
1622                    tool_def,
1623                    &mut tool_result,
1624                    &tool_context,
1625                )
1626                .await;
1627
1628                let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1629                let success = tool_result.error.is_none();
1630                let status = if success { "success" } else { "error" };
1631
1632                // Emit tool.completed event
1633                let completed_data = if success {
1634                    let result_fingerprint = tool_result_fingerprint(&tool_call.name, &tool_result);
1635                    // Convert result to ContentPart (text + optional images)
1636                    let mut result_content = tool_result
1637                        .result
1638                        .as_ref()
1639                        .map(|r| vec![ContentPart::tool_result_text(r)])
1640                        .unwrap_or_default();
1641                    // Append images as native Image content parts
1642                    if let Some(ref images) = tool_result.images {
1643                        for img in images {
1644                            result_content.push(ContentPart::Image(
1645                                crate::message::ImageContentPart::from_base64(
1646                                    &img.base64,
1647                                    &img.media_type,
1648                                ),
1649                            ));
1650                        }
1651                    }
1652                    ToolCompletedData::success(
1653                        tool_call.id.clone(),
1654                        tool_call.name.clone(),
1655                        result_content,
1656                        Some(tool_duration_ms),
1657                    )
1658                    .with_fingerprints(tool_call_fingerprint.clone(), result_fingerprint)
1659                    .with_display_name(display_name.clone())
1660                    .with_capability_attribution(
1661                        capability_attribution.as_ref().map(|(id, _)| id.clone()),
1662                        capability_attribution
1663                            .as_ref()
1664                            .and_then(|(_, name)| name.clone()),
1665                    )
1666                    .with_narration(Some(self.render_tool_narration(
1667                        context,
1668                        Some(tool_def),
1669                        &tool_call,
1670                        ToolNarrationPhase::Completed,
1671                        locale,
1672                    )))
1673                } else {
1674                    let result_fingerprint = tool_result_fingerprint(&tool_call.name, &tool_result);
1675                    ToolCompletedData::failure(
1676                        tool_call.id.clone(),
1677                        tool_call.name.clone(),
1678                        status.to_string(),
1679                        tool_result.error.clone().unwrap_or_default(),
1680                        Some(tool_duration_ms),
1681                    )
1682                    .with_fingerprints(tool_call_fingerprint.clone(), result_fingerprint)
1683                    .with_display_name(display_name.clone())
1684                    .with_capability_attribution(
1685                        capability_attribution.as_ref().map(|(id, _)| id.clone()),
1686                        capability_attribution
1687                            .as_ref()
1688                            .and_then(|(_, name)| name.clone()),
1689                    )
1690                    .with_narration(Some(self.render_tool_narration(
1691                        context,
1692                        Some(tool_def),
1693                        &tool_call,
1694                        ToolNarrationPhase::Failed,
1695                        locale,
1696                    )))
1697                };
1698
1699                if let Err(e) = self
1700                    .event_emitter
1701                    .emit(EventRequest::new(
1702                        context.session_id,
1703                        event_context.clone(),
1704                        completed_data,
1705                    ))
1706                    .await
1707                {
1708                    tracing::warn!(
1709                        session_id = %context.session_id,
1710                        tool_call_id = %tool_call.id,
1711                        error = %e,
1712                        "ActAtom: failed to emit tool.completed event"
1713                    );
1714                }
1715
1716                tracing::debug!(
1717                    session_id = %context.session_id,
1718                    tool_name = %tool_call.name,
1719                    tool_call_id = %tool_call.id,
1720                    success = %success,
1721                    "ActAtom: tool execution completed"
1722                );
1723
1724                // Settle the durable claim (EVE-530).
1725                if let (Some(store), Some(token)) = (&self.durable_tool_result_store, claim_token) {
1726                    let result_snapshot =
1727                        serde_json::to_value(&tool_result).unwrap_or(serde_json::Value::Null);
1728                    match store
1729                        .settle_tool_call(
1730                            &context.turn_id.to_string(),
1731                            &tool_call.id,
1732                            result_snapshot,
1733                            "settled",
1734                            token,
1735                        )
1736                        .await
1737                    {
1738                        Ok(false) => {
1739                            tracing::warn!(
1740                                session_id = %context.session_id,
1741                                tool_call_id = %tool_call.id,
1742                                "ActAtom: settle ownership check failed (task reclaimed)"
1743                            );
1744                        }
1745                        Err(e) => {
1746                            tracing::warn!(
1747                                session_id = %context.session_id,
1748                                tool_call_id = %tool_call.id,
1749                                error = %e,
1750                                "ActAtom: settle_tool_call failed"
1751                            );
1752                        }
1753                        Ok(true) => {}
1754                    }
1755                }
1756
1757                let conn_req = tool_result.connection_required.clone();
1758                ToolCallResult {
1759                    tool_call,
1760                    result: tool_result,
1761                    success,
1762                    status: status.to_string(),
1763                    connection_required: conn_req,
1764                    determinism_fatal: None,
1765                }
1766            }
1767            Err(e) => {
1768                let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1769                let error_msg = e.to_string();
1770
1771                // Emit tool.completed event for error
1772                if let Err(emit_err) = self
1773                    .event_emitter
1774                    .emit(EventRequest::new(
1775                        context.session_id,
1776                        event_context,
1777                        ToolCompletedData::failure(
1778                            tool_call.id.clone(),
1779                            tool_call.name.clone(),
1780                            "error".to_string(),
1781                            error_msg.clone(),
1782                            Some(tool_duration_ms),
1783                        )
1784                        .with_fingerprints(
1785                            tool_call_fingerprint.clone(),
1786                            tool_error_fingerprint(&tool_call.name, "error", &error_msg),
1787                        )
1788                        .with_display_name(display_name.clone())
1789                        .with_capability_attribution(
1790                            capability_attribution.as_ref().map(|(id, _)| id.clone()),
1791                            capability_attribution
1792                                .as_ref()
1793                                .and_then(|(_, name)| name.clone()),
1794                        )
1795                        .with_narration(Some(self.render_tool_narration(
1796                            context,
1797                            Some(tool_def),
1798                            &tool_call,
1799                            ToolNarrationPhase::Failed,
1800                            locale,
1801                        ))),
1802                    ))
1803                    .await
1804                {
1805                    tracing::warn!(
1806                        session_id = %context.session_id,
1807                        tool_call_id = %tool_call.id,
1808                        error = %emit_err,
1809                        "ActAtom: failed to emit tool.completed event"
1810                    );
1811                }
1812
1813                tracing::warn!(
1814                    session_id = %context.session_id,
1815                    tool_name = %tool_call.name,
1816                    tool_call_id = %tool_call.id,
1817                    error = %e,
1818                    "ActAtom: tool execution failed"
1819                );
1820
1821                ToolCallResult {
1822                    tool_call: tool_call.clone(),
1823                    result: ToolResult {
1824                        tool_call_id: tool_call.id.clone(),
1825                        result: None,
1826                        images: None,
1827                        error: Some(error_msg),
1828                        connection_required: None,
1829                        raw_output: None,
1830                    },
1831                    success: false,
1832                    status: "error".to_string(),
1833                    connection_required: None,
1834                    determinism_fatal: None,
1835                }
1836            }
1837        }
1838    }
1839}
1840
1841// ============================================================================
1842// Tests
1843// ============================================================================
1844
1845#[cfg(test)]
1846mod tests {
1847    use super::*;
1848    use crate::test_fixtures::NoopEventEmitter;
1849    use crate::tools::ToolRegistry;
1850    use crate::typed_id::{AgentId, HarnessId, MessageId, SessionId, TurnId};
1851    use async_trait::async_trait;
1852    use everruns_core::{Capability, DisabledUtilityLlmService, Tool, ToolExecutionResult};
1853    use everruns_provider::{BuiltinTool, ClientSideTool};
1854    use serde_json::json;
1855
1856    struct ArgumentEchoTool;
1857
1858    struct NarratingGrepTool;
1859
1860    struct HumanIntentFixtureHook;
1861
1862    impl crate::capabilities::ToolCallHook for HumanIntentFixtureHook {
1863        fn narration(
1864            &self,
1865            _tool_def: Option<&ToolDefinition>,
1866            tool_call: &ToolCall,
1867            _phase: crate::tool_narration::ToolNarrationPhase,
1868            _locale: Option<&str>,
1869            _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1870        ) -> Option<String> {
1871            crate::tool_types::human_intent(&tool_call.arguments).map(str::to_string)
1872        }
1873
1874        fn transform_for_execution(&self, mut tool_call: ToolCall) -> ToolCall {
1875            tool_call.arguments = tool_call.execution_arguments();
1876            tool_call
1877        }
1878    }
1879
1880    #[async_trait]
1881    impl crate::tools::Tool for NarratingGrepTool {
1882        fn name(&self) -> &str {
1883            "grep_files"
1884        }
1885
1886        fn description(&self) -> &str {
1887            "Search files"
1888        }
1889
1890        fn parameters_schema(&self) -> serde_json::Value {
1891            json!({"type": "object"})
1892        }
1893
1894        async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1895            ToolExecutionResult::success(json!({}))
1896        }
1897
1898        fn narrate(
1899            &self,
1900            tool_call: &ToolCall,
1901            phase: crate::tool_narration::ToolNarrationPhase,
1902            locale: Option<&str>,
1903            _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1904        ) -> Option<String> {
1905            Some(crate::tool_narration::narrate_grep_files(
1906                &tool_call.arguments,
1907                phase,
1908                locale,
1909            ))
1910        }
1911    }
1912
1913    struct NarratingCapability;
1914
1915    #[async_trait]
1916    impl Capability for NarratingCapability {
1917        fn id(&self) -> &str {
1918            "narrating_test"
1919        }
1920
1921        fn name(&self) -> &str {
1922            "Narrating test"
1923        }
1924
1925        fn description(&self) -> &str {
1926            "Test-only narration capability"
1927        }
1928
1929        fn tools(&self) -> Vec<Box<dyn Tool>> {
1930            vec![Box::new(NarratingGrepTool)]
1931        }
1932    }
1933
1934    #[async_trait]
1935    impl crate::tools::Tool for ArgumentEchoTool {
1936        fn name(&self) -> &str {
1937            "argument_echo"
1938        }
1939
1940        fn description(&self) -> &str {
1941            "returns the execution arguments"
1942        }
1943
1944        fn parameters_schema(&self) -> serde_json::Value {
1945            json!({
1946                "type": "object",
1947                "properties": {
1948                    "value": { "type": "string" }
1949                }
1950            })
1951        }
1952
1953        async fn execute(&self, arguments: serde_json::Value) -> ToolExecutionResult {
1954            ToolExecutionResult::success(arguments)
1955        }
1956    }
1957
1958    #[test]
1959    fn grouped_headline_uses_tool_owned_narration_for_repeated_actions() {
1960        use crate::capabilities::{Capability, CapabilityNarrationHook};
1961
1962        let capability: Arc<dyn Capability> = Arc::new(NarratingCapability);
1963        let tool_definitions = capability
1964            .tools()
1965            .into_iter()
1966            .map(|tool| tool.to_definition())
1967            .collect::<Vec<_>>();
1968        let tool_map = tool_definitions
1969            .iter()
1970            .map(|tool_def| (tool_def.name(), tool_def))
1971            .collect::<std::collections::HashMap<_, _>>();
1972        let atom = ActAtom::new(ToolRegistry::new(), NoopEventEmitter)
1973            .with_tool_call_hooks(vec![Arc::new(CapabilityNarrationHook(capability))]);
1974        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
1975        let tool_calls = vec![
1976            ToolCall {
1977                id: "grep-1".to_string(),
1978                name: "grep_files".to_string(),
1979                arguments: json!({ "pattern": "full_name" }),
1980            },
1981            ToolCall {
1982                id: "grep-2".to_string(),
1983                name: "grep_files".to_string(),
1984                arguments: json!({ "pattern": "login" }),
1985            },
1986        ];
1987
1988        assert_eq!(
1989            atom.render_group_headline(
1990                &context,
1991                &tool_calls,
1992                &tool_map,
1993                ToolNarrationPhase::Started,
1994                None,
1995            )
1996            .as_deref(),
1997            Some("Searching files twice")
1998        );
1999        assert_eq!(
2000            atom.render_group_headline(
2001                &context,
2002                &tool_calls,
2003                &tool_map,
2004                ToolNarrationPhase::Completed,
2005                None,
2006            )
2007            .as_deref(),
2008            Some("Searched files twice")
2009        );
2010    }
2011
2012    /// Tools assembled outside any capability (the unified `spawn_agent`
2013    /// dispatcher, registry augmentations, MCP proxies) have no
2014    /// `CapabilityNarrationHook`, so the act atom must consult the registry
2015    /// before the generic "Running {display_name}" fallback.
2016    #[test]
2017    fn registry_owned_tool_narrates_itself_without_a_capability_hook() {
2018        let mut registry = ToolRegistry::new();
2019        registry.register_boxed(Box::new(NarratingGrepTool));
2020        let atom = ActAtom::new(ToolRegistry::new(), NoopEventEmitter)
2021            .with_tool_registry(Arc::new(registry));
2022        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2023        let tool_call = ToolCall {
2024            id: "grep-1".to_string(),
2025            name: "grep_files".to_string(),
2026            arguments: json!({ "pattern": "full_name" }),
2027        };
2028
2029        assert_eq!(
2030            atom.render_tool_narration(
2031                &context,
2032                None,
2033                &tool_call,
2034                ToolNarrationPhase::Started,
2035                None,
2036            ),
2037            "Searching files for full_name"
2038        );
2039    }
2040
2041    struct UtilityLlmContextProbeTool;
2042
2043    #[async_trait]
2044    impl crate::tools::Tool for UtilityLlmContextProbeTool {
2045        fn name(&self) -> &str {
2046            "utility_llm_context_probe"
2047        }
2048
2049        fn description(&self) -> &str {
2050            "checks whether the utility LLM service is present in tool context"
2051        }
2052
2053        fn parameters_schema(&self) -> serde_json::Value {
2054            json!({
2055                "type": "object",
2056                "properties": {}
2057            })
2058        }
2059
2060        async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2061            ToolExecutionResult::tool_error("context required")
2062        }
2063
2064        async fn execute_with_context(
2065            &self,
2066            _arguments: serde_json::Value,
2067            context: &crate::tool_context::ToolContext,
2068        ) -> ToolExecutionResult {
2069            ToolExecutionResult::success(json!({
2070                "utility_llm_service": context.utility_llm_service.is_some(),
2071                "configured": context
2072                    .utility_llm_service
2073                    .as_ref()
2074                    .is_some_and(|service| service.is_configured()),
2075            }))
2076        }
2077
2078        fn requires_context(&self) -> bool {
2079            true
2080        }
2081    }
2082
2083    /// Shared scheduling observations recorded by `RecordingTool`.
2084    #[derive(Default)]
2085    struct SchedObservations {
2086        /// Currently-executing count per concurrency class.
2087        class_inflight: std::collections::HashMap<String, usize>,
2088        /// Peak concurrent executions observed per class.
2089        class_max: std::collections::HashMap<String, usize>,
2090        /// Currently-executing count across all tools.
2091        global_inflight: usize,
2092        /// Peak concurrent executions across all tools.
2093        global_max: usize,
2094    }
2095
2096    /// Tool that records start/end so a test can observe how the act scheduler
2097    /// ran a batch (intra-class serialization, cross-class parallelism).
2098    struct RecordingTool {
2099        name: String,
2100        class: Option<String>,
2101        obs: Arc<std::sync::Mutex<SchedObservations>>,
2102    }
2103
2104    #[async_trait]
2105    impl crate::tools::Tool for RecordingTool {
2106        fn name(&self) -> &str {
2107            &self.name
2108        }
2109        fn description(&self) -> &str {
2110            "records scheduling order"
2111        }
2112        fn parameters_schema(&self) -> serde_json::Value {
2113            json!({ "type": "object", "properties": {} })
2114        }
2115        async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2116            // Enter: bump counters in a short critical section (no await held).
2117            {
2118                let mut obs = self.obs.lock().unwrap();
2119                obs.global_inflight += 1;
2120                let g = obs.global_inflight;
2121                if g > obs.global_max {
2122                    obs.global_max = g;
2123                }
2124                if let Some(class) = &self.class {
2125                    let n = obs.class_inflight.entry(class.clone()).or_default();
2126                    *n += 1;
2127                    let cur = *n;
2128                    let m = obs.class_max.entry(class.clone()).or_default();
2129                    if cur > *m {
2130                        *m = cur;
2131                    }
2132                }
2133            }
2134            // Hold the slot long enough that any concurrency is observable.
2135            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2136            // Exit.
2137            {
2138                let mut obs = self.obs.lock().unwrap();
2139                obs.global_inflight -= 1;
2140                if let Some(class) = &self.class
2141                    && let Some(n) = obs.class_inflight.get_mut(class)
2142                {
2143                    *n -= 1;
2144                }
2145            }
2146            ToolExecutionResult::success(json!({ "tool": self.name }))
2147        }
2148    }
2149
2150    struct CancellationProbeTool {
2151        started: Arc<tokio::sync::Notify>,
2152        dropped_tx: Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
2153    }
2154
2155    impl CancellationProbeTool {
2156        fn new(
2157            started: Arc<tokio::sync::Notify>,
2158            dropped_tx: tokio::sync::oneshot::Sender<()>,
2159        ) -> Self {
2160            Self {
2161                started,
2162                dropped_tx: Arc::new(std::sync::Mutex::new(Some(dropped_tx))),
2163            }
2164        }
2165    }
2166
2167    #[async_trait]
2168    impl crate::tools::Tool for CancellationProbeTool {
2169        fn name(&self) -> &str {
2170            "cancellation_probe"
2171        }
2172
2173        fn description(&self) -> &str {
2174            "waits until cancelled"
2175        }
2176
2177        fn parameters_schema(&self) -> serde_json::Value {
2178            json!({ "type": "object", "properties": {} })
2179        }
2180
2181        async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2182            struct DropSignal {
2183                tx: Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
2184            }
2185
2186            impl Drop for DropSignal {
2187                fn drop(&mut self) {
2188                    if let Ok(mut guard) = self.tx.lock()
2189                        && let Some(tx) = guard.take()
2190                    {
2191                        let _ = tx.send(());
2192                    }
2193                }
2194            }
2195
2196            let _drop_signal = DropSignal {
2197                tx: self.dropped_tx.clone(),
2198            };
2199            self.started.notify_one();
2200            std::future::pending::<()>().await;
2201            unreachable!("pending cancellation probe should only finish by cancellation")
2202        }
2203    }
2204
2205    /// Build a server-side tool definition carrying scheduling hints.
2206    fn recording_tool_def(name: &str, class: Option<&str>, cpu_bound: bool) -> ToolDefinition {
2207        let mut hints = crate::tool_types::ToolHints::default();
2208        if let Some(class) = class {
2209            hints = hints.with_concurrency_class(class);
2210        }
2211        if cpu_bound {
2212            hints = hints.with_cpu_bound(true);
2213        }
2214        ToolDefinition::Builtin(BuiltinTool {
2215            name: name.to_string(),
2216            display_name: None,
2217            description: "records scheduling order".to_string(),
2218            parameters: json!({ "type": "object", "properties": {} }),
2219            policy: Default::default(),
2220            category: None,
2221            deferrable: Default::default(),
2222            hints,
2223            full_parameters: None,
2224        })
2225    }
2226
2227    #[tokio::test]
2228    async fn test_act_atom_empty_tool_calls() {
2229        let executor = ToolRegistry::with_defaults();
2230        let event_emitter = NoopEventEmitter;
2231        let atom = ActAtom::new(executor, event_emitter);
2232
2233        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2234        let input = ActInput {
2235            org_id: Some(1),
2236            context,
2237            harness_id: HarnessId::from_seed(1),
2238            agent_id: Some(AgentId::new()),
2239            tool_calls: vec![],
2240            tool_definitions: vec![],
2241            locale: None,
2242            blueprint_id: None,
2243            network_access: None,
2244            parallel_tool_calls: None,
2245        };
2246
2247        let result = atom.execute(input).await.unwrap();
2248
2249        assert!(result.completed);
2250        assert!(result.results.is_empty());
2251        assert_eq!(result.success_count, 0);
2252        assert_eq!(result.error_count, 0);
2253    }
2254
2255    #[tokio::test]
2256    async fn test_act_atom_threads_utility_llm_service_to_tool_context() {
2257        let mut executor = ToolRegistry::with_defaults();
2258        executor.register(UtilityLlmContextProbeTool);
2259        let event_emitter = NoopEventEmitter;
2260        let atom = ActAtom::new(executor, event_emitter)
2261            .with_utility_llm_service(Arc::new(DisabledUtilityLlmService));
2262
2263        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2264        let input = ActInput {
2265            org_id: Some(1),
2266            context,
2267            harness_id: HarnessId::from_seed(1),
2268            agent_id: Some(AgentId::new()),
2269            tool_calls: vec![ToolCall {
2270                id: "call_1".to_string(),
2271                name: "utility_llm_context_probe".to_string(),
2272                arguments: json!({}),
2273            }],
2274            tool_definitions: vec![ToolDefinition::Builtin(BuiltinTool {
2275                name: "utility_llm_context_probe".to_string(),
2276                display_name: None,
2277                description: "checks context".to_string(),
2278                parameters: json!({
2279                    "type": "object",
2280                    "properties": {}
2281                }),
2282                policy: Default::default(),
2283                category: None,
2284                deferrable: Default::default(),
2285                hints: crate::tool_types::ToolHints::default(),
2286                full_parameters: None,
2287            })],
2288            locale: None,
2289            blueprint_id: None,
2290            network_access: None,
2291            parallel_tool_calls: None,
2292        };
2293
2294        let result = atom.execute(input).await.unwrap();
2295
2296        assert_eq!(result.success_count, 1);
2297        let payload = result.results[0].result.result.as_ref().unwrap();
2298        assert_eq!(payload["utility_llm_service"], true);
2299        assert_eq!(payload["configured"], false);
2300    }
2301
2302    /// End-to-end ActAtom scheduling: a single batch with two same-class tools
2303    /// (one of them `cpu_bound`, exercising the spawn path) plus an independent
2304    /// tool. Asserts the scheduler serializes within the class, parallelizes
2305    /// across classes, runs every tool, and preserves call order in results.
2306    #[tokio::test]
2307    async fn test_act_atom_schedules_batch_by_concurrency_class() {
2308        let obs = Arc::new(std::sync::Mutex::new(SchedObservations::default()));
2309
2310        let mut executor = ToolRegistry::new();
2311        executor.register(RecordingTool {
2312            name: "writer_a".to_string(),
2313            class: Some("ws".to_string()),
2314            obs: obs.clone(),
2315        });
2316        executor.register(RecordingTool {
2317            name: "writer_b".to_string(),
2318            class: Some("ws".to_string()),
2319            obs: obs.clone(),
2320        });
2321        executor.register(RecordingTool {
2322            name: "reader".to_string(),
2323            class: None,
2324            obs: obs.clone(),
2325        });
2326
2327        let atom = ActAtom::new(executor, NoopEventEmitter);
2328        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2329
2330        // Call order: writer_a, reader, writer_b. writer_a and writer_b share
2331        // class "ws" (writer_b is cpu_bound → executed on its own task).
2332        let input = ActInput {
2333            org_id: Some(1),
2334            context,
2335            harness_id: HarnessId::from_seed(1),
2336            agent_id: Some(AgentId::new()),
2337            tool_calls: vec![
2338                ToolCall {
2339                    id: "call_a".to_string(),
2340                    name: "writer_a".to_string(),
2341                    arguments: json!({}),
2342                },
2343                ToolCall {
2344                    id: "call_r".to_string(),
2345                    name: "reader".to_string(),
2346                    arguments: json!({}),
2347                },
2348                ToolCall {
2349                    id: "call_b".to_string(),
2350                    name: "writer_b".to_string(),
2351                    arguments: json!({}),
2352                },
2353            ],
2354            tool_definitions: vec![
2355                recording_tool_def("writer_a", Some("ws"), false),
2356                recording_tool_def("reader", None, false),
2357                recording_tool_def("writer_b", Some("ws"), true),
2358            ],
2359            locale: None,
2360            blueprint_id: None,
2361            network_access: None,
2362            parallel_tool_calls: None,
2363        };
2364
2365        let result = atom.execute(input).await.unwrap();
2366
2367        // Every tool ran and succeeded.
2368        assert_eq!(result.success_count, 3, "all three tools should succeed");
2369        // Results are returned in the model's original call order.
2370        let names: Vec<&str> = result
2371            .results
2372            .iter()
2373            .map(|r| r.tool_call.name.as_str())
2374            .collect();
2375        assert_eq!(names, vec!["writer_a", "reader", "writer_b"]);
2376
2377        let obs = obs.lock().unwrap();
2378        // Same-class tools never overlapped (serialized) — even though one is
2379        // cpu_bound and runs on its own task.
2380        assert_eq!(
2381            obs.class_max.get("ws").copied(),
2382            Some(1),
2383            "same-class tools must serialize"
2384        );
2385        // The independent tool overlapped with the class group: peak global
2386        // concurrency exceeded 1, proving cross-class parallelism.
2387        assert!(
2388            obs.global_max >= 2,
2389            "independent tool should run concurrently with the class group (global_max={})",
2390            obs.global_max
2391        );
2392    }
2393
2394    /// A tool that leaves work running past its own future: it hands the
2395    /// call's cancellation token to a detached task and returns immediately.
2396    /// That task is the thing a dropped future cannot reach.
2397    struct DetachedWorkTool {
2398        cancelled_tx: Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
2399    }
2400
2401    impl DetachedWorkTool {
2402        fn new(cancelled_tx: tokio::sync::oneshot::Sender<()>) -> Self {
2403            Self {
2404                cancelled_tx: Arc::new(std::sync::Mutex::new(Some(cancelled_tx))),
2405            }
2406        }
2407    }
2408
2409    #[async_trait]
2410    impl crate::tools::Tool for DetachedWorkTool {
2411        fn name(&self) -> &str {
2412            "detached_work"
2413        }
2414
2415        fn description(&self) -> &str {
2416            "spawns work that outlives the call unless cancelled"
2417        }
2418
2419        fn parameters_schema(&self) -> serde_json::Value {
2420            json!({ "type": "object", "properties": {} })
2421        }
2422
2423        fn requires_context(&self) -> bool {
2424            true
2425        }
2426
2427        async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2428            ToolExecutionResult::tool_error("requires context")
2429        }
2430
2431        async fn execute_with_context(
2432            &self,
2433            _arguments: serde_json::Value,
2434            context: &crate::tool_context::ToolContext,
2435        ) -> ToolExecutionResult {
2436            let token = context
2437                .cancellation
2438                .clone()
2439                .expect("act must supply a cancellation token");
2440            assert!(!token.is_cancelled(), "token is live during the call");
2441            let tx = self.cancelled_tx.clone();
2442            tokio::spawn(async move {
2443                token.cancelled().await;
2444                if let Ok(mut guard) = tx.lock()
2445                    && let Some(tx) = guard.take()
2446                {
2447                    let _ = tx.send(());
2448                }
2449            });
2450            ToolExecutionResult::success(json!({ "spawned": true }))
2451        }
2452    }
2453
2454    /// Work a tool leaves running must learn that its call ended. Dropping the
2455    /// act future cannot tell it — a dropped future is never polled again — so
2456    /// the token on `ToolContext` is the only signal that reaches it.
2457    #[tokio::test]
2458    async fn test_act_atom_cancels_detached_tool_work_when_the_call_ends() {
2459        let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel();
2460
2461        let mut executor = ToolRegistry::new();
2462        executor.register(DetachedWorkTool::new(cancelled_tx));
2463
2464        let atom = ActAtom::new(executor, NoopEventEmitter);
2465        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2466        let input = ActInput {
2467            org_id: Some(1),
2468            context,
2469            harness_id: HarnessId::from_seed(1),
2470            agent_id: Some(AgentId::new()),
2471            tool_calls: vec![ToolCall {
2472                id: "call_1".to_string(),
2473                name: "detached_work".to_string(),
2474                arguments: json!({}),
2475            }],
2476            tool_definitions: vec![recording_tool_def("detached_work", None, false)],
2477            locale: None,
2478            blueprint_id: None,
2479            network_access: None,
2480            parallel_tool_calls: None,
2481        };
2482
2483        atom.execute(input).await.expect("act should succeed");
2484
2485        tokio::time::timeout(std::time::Duration::from_secs(1), cancelled_rx)
2486            .await
2487            .expect("detached work should be cancelled once the call ends")
2488            .expect("cancellation signal should be sent");
2489    }
2490
2491    #[tokio::test]
2492    async fn test_act_atom_cancels_detached_tool_work_when_the_turn_is_cancelled() {
2493        let started = Arc::new(tokio::sync::Notify::new());
2494        let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
2495
2496        let mut executor = ToolRegistry::new();
2497        executor.register(CancellationProbeTool::new(started.clone(), dropped_tx));
2498
2499        let atom = ActAtom::new(executor, NoopEventEmitter);
2500        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2501        let input = ActInput {
2502            org_id: Some(1),
2503            context,
2504            harness_id: HarnessId::from_seed(1),
2505            agent_id: Some(AgentId::new()),
2506            tool_calls: vec![ToolCall {
2507                id: "call_1".to_string(),
2508                name: "cancellation_probe".to_string(),
2509                arguments: json!({}),
2510            }],
2511            tool_definitions: vec![recording_tool_def("cancellation_probe", None, true)],
2512            locale: None,
2513            blueprint_id: None,
2514            network_access: None,
2515            parallel_tool_calls: None,
2516        };
2517
2518        let act_task = tokio::spawn(async move { atom.execute(input).await });
2519        started.notified().await;
2520        act_task.abort();
2521        assert!(act_task.await.unwrap_err().is_cancelled());
2522
2523        // The existing abort path still holds: the tool future itself is dropped.
2524        tokio::time::timeout(std::time::Duration::from_secs(1), dropped_rx)
2525            .await
2526            .expect("tool future should be dropped when the turn is cancelled")
2527            .expect("drop signal should be sent");
2528    }
2529
2530    #[tokio::test]
2531    async fn test_act_atom_aborts_cpu_bound_tool_task_on_cancellation() {
2532        let started = Arc::new(tokio::sync::Notify::new());
2533        let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
2534
2535        let mut executor = ToolRegistry::new();
2536        executor.register(CancellationProbeTool::new(started.clone(), dropped_tx));
2537
2538        let atom = ActAtom::new(executor, NoopEventEmitter);
2539        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2540        let input = ActInput {
2541            org_id: Some(1),
2542            context,
2543            harness_id: HarnessId::from_seed(1),
2544            agent_id: Some(AgentId::new()),
2545            tool_calls: vec![ToolCall {
2546                id: "call_1".to_string(),
2547                name: "cancellation_probe".to_string(),
2548                arguments: json!({}),
2549            }],
2550            tool_definitions: vec![recording_tool_def("cancellation_probe", None, true)],
2551            locale: None,
2552            blueprint_id: None,
2553            network_access: None,
2554            parallel_tool_calls: None,
2555        };
2556
2557        let act_task = tokio::spawn(async move { atom.execute(input).await });
2558        started.notified().await;
2559        act_task.abort();
2560        assert!(act_task.await.unwrap_err().is_cancelled());
2561
2562        tokio::time::timeout(std::time::Duration::from_secs(1), dropped_rx)
2563            .await
2564            .expect("cpu-bound tool task should be aborted when ActAtom is cancelled")
2565            .expect("drop signal should be sent by cancelled tool future");
2566    }
2567
2568    /// With `parallel_tool_calls = Some(false)`, the whole batch runs strictly
2569    /// sequentially regardless of class — peak concurrency must be 1.
2570    #[tokio::test]
2571    async fn test_act_atom_parallel_tool_calls_false_serializes_everything() {
2572        let obs = Arc::new(std::sync::Mutex::new(SchedObservations::default()));
2573        let mut executor = ToolRegistry::new();
2574        for name in ["t0", "t1", "t2"] {
2575            executor.register(RecordingTool {
2576                name: name.to_string(),
2577                class: None,
2578                obs: obs.clone(),
2579            });
2580        }
2581        let atom = ActAtom::new(executor, NoopEventEmitter);
2582        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2583        let input = ActInput {
2584            org_id: Some(1),
2585            context,
2586            harness_id: HarnessId::from_seed(1),
2587            agent_id: Some(AgentId::new()),
2588            tool_calls: vec![
2589                ToolCall {
2590                    id: "c0".to_string(),
2591                    name: "t0".to_string(),
2592                    arguments: json!({}),
2593                },
2594                ToolCall {
2595                    id: "c1".to_string(),
2596                    name: "t1".to_string(),
2597                    arguments: json!({}),
2598                },
2599                ToolCall {
2600                    id: "c2".to_string(),
2601                    name: "t2".to_string(),
2602                    arguments: json!({}),
2603                },
2604            ],
2605            tool_definitions: vec![
2606                recording_tool_def("t0", None, false),
2607                recording_tool_def("t1", None, false),
2608                recording_tool_def("t2", None, false),
2609            ],
2610            locale: None,
2611            blueprint_id: None,
2612            network_access: None,
2613            parallel_tool_calls: Some(false),
2614        };
2615
2616        let result = atom.execute(input).await.unwrap();
2617        assert_eq!(result.success_count, 3);
2618        assert_eq!(
2619            obs.lock().unwrap().global_max,
2620            1,
2621            "parallel_tool_calls=false must serialize the whole batch"
2622        );
2623    }
2624
2625    #[tokio::test]
2626    async fn test_act_atom_tool_not_found() {
2627        let executor = ToolRegistry::with_defaults();
2628        let event_emitter = NoopEventEmitter;
2629        let atom = ActAtom::new(executor, event_emitter);
2630
2631        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2632        let input = ActInput {
2633            org_id: Some(1),
2634            context,
2635            harness_id: HarnessId::from_seed(1),
2636            agent_id: Some(AgentId::new()),
2637            tool_calls: vec![ToolCall {
2638                id: "call_1".to_string(),
2639                name: "nonexistent_tool".to_string(),
2640                arguments: json!({}),
2641            }],
2642            tool_definitions: vec![],
2643            locale: None,
2644            blueprint_id: None,
2645            network_access: None,
2646            parallel_tool_calls: None,
2647        };
2648
2649        let result = atom.execute(input).await.unwrap();
2650
2651        assert!(result.completed);
2652        assert_eq!(result.results.len(), 1);
2653        assert!(!result.results[0].success);
2654        assert_eq!(result.results[0].status, "error");
2655        assert!(
2656            result.results[0]
2657                .result
2658                .error
2659                .as_ref()
2660                .unwrap()
2661                .contains("not found")
2662        );
2663    }
2664
2665    #[tokio::test]
2666    async fn test_act_atom_uses_tool_call_hooks_for_execution_arguments() {
2667        let mut executor = ToolRegistry::new();
2668        executor.register(ArgumentEchoTool);
2669        let tool_def = executor.get("argument_echo").unwrap().to_definition();
2670        let emitter = crate::test_fixtures::TestEventEmitter::new();
2671        let atom = ActAtom::new(executor, emitter.clone())
2672            .with_tool_call_hooks(vec![std::sync::Arc::new(HumanIntentFixtureHook)]);
2673
2674        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2675        let input = ActInput {
2676            org_id: Some(1),
2677            context,
2678            harness_id: HarnessId::from_seed(1),
2679            agent_id: Some(AgentId::new()),
2680            tool_calls: vec![ToolCall {
2681                id: "call_1".to_string(),
2682                name: "argument_echo".to_string(),
2683                arguments: json!({
2684                    "value": "visible",
2685                    "human_intent": "Echoing test arguments"
2686                }),
2687            }],
2688            tool_definitions: vec![tool_def],
2689            locale: None,
2690            blueprint_id: None,
2691            network_access: None,
2692            parallel_tool_calls: None,
2693        };
2694
2695        let result = atom.execute(input).await.unwrap();
2696
2697        assert!(result.results[0].success);
2698        assert_eq!(
2699            result.results[0].result.result,
2700            Some(json!({ "value": "visible" }))
2701        );
2702
2703        let events = emitter.events().await;
2704        assert_eq!(
2705            events
2706                .iter()
2707                .map(|event| event.event_type.as_str())
2708                .collect::<Vec<_>>(),
2709            vec![
2710                "act.started",
2711                "tool.started",
2712                "tool.completed",
2713                "act.completed",
2714            ],
2715            "all hosts must observe the engine-owned phase order",
2716        );
2717        let act_started = events
2718            .iter()
2719            .find(|event| event.event_type == "act.started")
2720            .expect("act.started event");
2721        let crate::events::EventData::ActStarted(data) = &act_started.data else {
2722            panic!("expected act.started data");
2723        };
2724        assert_eq!(data.headline.as_deref(), Some("Echoing test arguments"));
2725        assert_eq!(
2726            data.tool_calls[0].narration.as_deref(),
2727            Some("Echoing test arguments")
2728        );
2729
2730        let tool_started = events
2731            .iter()
2732            .find(|event| event.event_type == "tool.started")
2733            .expect("tool.started event");
2734        let crate::events::EventData::ToolStarted(data) = &tool_started.data else {
2735            panic!("expected tool.started data");
2736        };
2737        let started_fingerprint = data
2738            .tool_call_fingerprint
2739            .as_ref()
2740            .expect("tool.started call fingerprint");
2741        assert_eq!(data.narration.as_deref(), Some("Echoing test arguments"));
2742
2743        let tool_completed = events
2744            .iter()
2745            .find(|event| event.event_type == "tool.completed")
2746            .expect("tool.completed event");
2747        let crate::events::EventData::ToolCompleted(data) = &tool_completed.data else {
2748            panic!("expected tool.completed data");
2749        };
2750        assert_eq!(
2751            data.tool_call_fingerprint.as_ref(),
2752            Some(started_fingerprint)
2753        );
2754        assert!(data.tool_result_fingerprint.is_some());
2755        assert_eq!(data.narration.as_deref(), Some("Echoing test arguments"));
2756    }
2757
2758    #[tokio::test]
2759    async fn test_act_atom_strips_human_intent_from_client_tool_calls() {
2760        let executor = ToolRegistry::new();
2761        let emitter = crate::test_fixtures::TestEventEmitter::new();
2762        let atom = ActAtom::new(executor, emitter)
2763            .with_tool_call_hooks(vec![std::sync::Arc::new(HumanIntentFixtureHook)]);
2764
2765        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2766        let input = ActInput {
2767            org_id: Some(1),
2768            context,
2769            harness_id: HarnessId::from_seed(1),
2770            agent_id: Some(AgentId::new()),
2771            tool_calls: vec![ToolCall {
2772                id: "call_client".to_string(),
2773                name: "browser_click".to_string(),
2774                arguments: json!({
2775                    "selector": "#btn",
2776                    "human_intent": "Clicking approve"
2777                }),
2778            }],
2779            tool_definitions: vec![ToolDefinition::ClientSide(ClientSideTool {
2780                name: "browser_click".to_string(),
2781                display_name: None,
2782                description: "Click button".to_string(),
2783                parameters: json!({
2784                    "type": "object",
2785                    "properties": {
2786                        "selector": {"type": "string"}
2787                    },
2788                    "required": ["selector"]
2789                }),
2790                category: None,
2791                deferrable: Default::default(),
2792                hints: crate::tool_types::ToolHints::default(),
2793                full_parameters: None,
2794            })],
2795            locale: None,
2796            blueprint_id: None,
2797            network_access: None,
2798            parallel_tool_calls: None,
2799        };
2800
2801        let result = atom.execute(input).await.unwrap();
2802
2803        assert_eq!(result.client_tool_calls.len(), 1);
2804        assert_eq!(
2805            result.client_tool_calls[0].arguments,
2806            json!({ "selector": "#btn" })
2807        );
2808    }
2809
2810    #[test]
2811    fn test_act_result_connection_required_serialization() {
2812        let result = ActResult {
2813            results: vec![ToolCallResult {
2814                tool_call: ToolCall {
2815                    id: "call_1".to_string(),
2816                    name: "daytona_create_sandbox".to_string(),
2817                    arguments: json!({}),
2818                },
2819                result: ToolResult {
2820                    tool_call_id: "call_1".to_string(),
2821                    result: Some(json!({"connection_required": "daytona"})),
2822                    images: None,
2823                    error: None,
2824                    connection_required: Some("daytona".to_string()),
2825                    raw_output: None,
2826                },
2827                success: false,
2828                status: "success".to_string(),
2829                connection_required: Some("daytona".to_string()),
2830                determinism_fatal: None,
2831            }],
2832            completed: true,
2833            success_count: 0,
2834            error_count: 0,
2835            waiting_for_tool_results: true,
2836            blocked: false,
2837            client_tool_calls: vec![],
2838            client_tool_definitions: vec![],
2839        };
2840
2841        let json_str = serde_json::to_string(&result).unwrap();
2842        let parsed: ActResult = serde_json::from_str(&json_str).unwrap();
2843
2844        assert!(parsed.waiting_for_tool_results);
2845        assert_eq!(
2846            parsed.results[0].connection_required,
2847            Some("daytona".to_string())
2848        );
2849    }
2850
2851    #[test]
2852    fn test_act_result_backward_compat_deserialization() {
2853        // Old JSON without new fields still deserializes
2854        let json_str = r#"{
2855            "results": [],
2856            "completed": true,
2857            "success_count": 0,
2858            "error_count": 0
2859        }"#;
2860        let parsed: ActResult = serde_json::from_str(json_str).unwrap();
2861
2862        assert!(!parsed.waiting_for_tool_results);
2863        assert!(parsed.client_tool_calls.is_empty());
2864    }
2865
2866    /// Verify that a denying `OutboundToolRateLimiter` short-circuits tool execution
2867    /// and returns a rate-limit error result rather than calling the actual tool.
2868    #[tokio::test]
2869    async fn test_outbound_tool_rate_limiter_blocks_execution() {
2870        use crate::typed_id::OrgId;
2871
2872        struct DenyAll;
2873        #[async_trait]
2874        impl crate::tool_execution::OutboundToolRateLimiter for DenyAll {
2875            async fn check_org(&self, _org_id: &OrgId) -> bool {
2876                false
2877            }
2878        }
2879
2880        let mut executor = ToolRegistry::with_defaults();
2881        executor.register(ArgumentEchoTool);
2882        let atom = ActAtom::new(executor, NoopEventEmitter)
2883            .with_org_id(OrgId::from_seed(1))
2884            .with_outbound_tool_rate_limiter(Arc::new(DenyAll));
2885
2886        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2887        let input = ActInput {
2888            org_id: Some(1),
2889            context,
2890            harness_id: HarnessId::from_seed(1),
2891            agent_id: Some(AgentId::new()),
2892            tool_calls: vec![ToolCall {
2893                id: "call_1".to_string(),
2894                name: "argument_echo".to_string(),
2895                arguments: json!({"value": "should_not_reach"}),
2896            }],
2897            tool_definitions: vec![ToolDefinition::Builtin(BuiltinTool {
2898                name: "argument_echo".to_string(),
2899                display_name: None,
2900                description: "echo".to_string(),
2901                parameters: json!({"type": "object"}),
2902                policy: Default::default(),
2903                category: None,
2904                deferrable: Default::default(),
2905                hints: crate::tool_types::ToolHints::default(),
2906                full_parameters: None,
2907            })],
2908            locale: None,
2909            blueprint_id: None,
2910            network_access: None,
2911            parallel_tool_calls: None,
2912        };
2913
2914        let result = atom.execute(input).await.unwrap();
2915
2916        assert_eq!(result.success_count, 0);
2917        assert_eq!(result.error_count, 1);
2918        let tool_result = &result.results[0];
2919        assert!(!tool_result.success);
2920        assert_eq!(tool_result.status, "error");
2921        assert!(
2922            tool_result
2923                .result
2924                .error
2925                .as_deref()
2926                .unwrap_or("")
2927                .contains("rate limit exceeded")
2928        );
2929        assert!(tool_result.result.result.is_none());
2930    }
2931
2932    /// Verify that an allowing `OutboundToolRateLimiter` does not block execution.
2933    #[tokio::test]
2934    async fn test_outbound_tool_rate_limiter_allows_execution() {
2935        use crate::typed_id::OrgId;
2936
2937        struct AllowAll;
2938        #[async_trait]
2939        impl crate::tool_execution::OutboundToolRateLimiter for AllowAll {
2940            async fn check_org(&self, _org_id: &OrgId) -> bool {
2941                true
2942            }
2943        }
2944
2945        let mut executor = ToolRegistry::with_defaults();
2946        executor.register(ArgumentEchoTool);
2947        let atom = ActAtom::new(executor, NoopEventEmitter)
2948            .with_org_id(OrgId::from_seed(1))
2949            .with_outbound_tool_rate_limiter(Arc::new(AllowAll));
2950
2951        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
2952        let input = ActInput {
2953            org_id: Some(1),
2954            context,
2955            harness_id: HarnessId::from_seed(1),
2956            agent_id: Some(AgentId::new()),
2957            tool_calls: vec![ToolCall {
2958                id: "call_1".to_string(),
2959                name: "argument_echo".to_string(),
2960                arguments: json!({"value": "hello"}),
2961            }],
2962            tool_definitions: vec![ToolDefinition::Builtin(BuiltinTool {
2963                name: "argument_echo".to_string(),
2964                display_name: None,
2965                description: "echo".to_string(),
2966                parameters: json!({"type": "object"}),
2967                policy: Default::default(),
2968                category: None,
2969                deferrable: Default::default(),
2970                hints: crate::tool_types::ToolHints::default(),
2971                full_parameters: None,
2972            })],
2973            locale: None,
2974            blueprint_id: None,
2975            network_access: None,
2976            parallel_tool_calls: None,
2977        };
2978
2979        let result = atom.execute(input).await.unwrap();
2980
2981        assert_eq!(result.success_count, 1);
2982        assert_eq!(result.error_count, 0);
2983    }
2984
2985    // -----------------------------------------------------------------------
2986    // DurableToolResultStore idempotency tests (EVE-530)
2987    // -----------------------------------------------------------------------
2988
2989    use crate::tool_types::{SideEffectClass, ToolHints};
2990    use crate::{durability::DurableToolResultStore, durability::ToolCallClaimResult};
2991    use std::collections::HashMap;
2992    use std::sync::Mutex;
2993
2994    #[derive(Default)]
2995    struct InMemoryDurableStore {
2996        rows: Mutex<HashMap<(String, String), StoreRow>>,
2997    }
2998
2999    #[derive(Clone)]
3000    struct StoreRow {
3001        status: String,
3002        result_json: serde_json::Value,
3003        args_fingerprint: String,
3004        #[allow(dead_code)]
3005        claim_token: Uuid,
3006    }
3007
3008    #[async_trait]
3009    impl DurableToolResultStore for InMemoryDurableStore {
3010        async fn try_claim_tool_call(
3011            &self,
3012            turn_id: &str,
3013            tool_call_id: &str,
3014            _tool_name: &str,
3015            args_fingerprint: &str,
3016        ) -> crate::error::Result<ToolCallClaimResult> {
3017            let key = (turn_id.to_string(), tool_call_id.to_string());
3018            let mut rows = self.rows.lock().unwrap();
3019            if let Some(row) = rows.get(&key) {
3020                match row.status.as_str() {
3021                    "settled" => {
3022                        if row.args_fingerprint != args_fingerprint {
3023                            return Ok(ToolCallClaimResult::DeterminismViolation {
3024                                stored_fingerprint: row.args_fingerprint.clone(),
3025                                current_fingerprint: args_fingerprint.to_string(),
3026                            });
3027                        }
3028                        return Ok(ToolCallClaimResult::AlreadySettled {
3029                            result_json: row.result_json.clone(),
3030                            args_fingerprint: row.args_fingerprint.clone(),
3031                        });
3032                    }
3033                    _ => {
3034                        return Ok(ToolCallClaimResult::AlreadyRunning {
3035                            args_fingerprint: row.args_fingerprint.clone(),
3036                        });
3037                    }
3038                }
3039            }
3040            let token = Uuid::new_v4();
3041            rows.insert(
3042                key,
3043                StoreRow {
3044                    status: "running".to_string(),
3045                    result_json: serde_json::Value::Null,
3046                    args_fingerprint: args_fingerprint.to_string(),
3047                    claim_token: token,
3048                },
3049            );
3050            Ok(ToolCallClaimResult::Claimed { claim_token: token })
3051        }
3052
3053        async fn settle_tool_call(
3054            &self,
3055            turn_id: &str,
3056            tool_call_id: &str,
3057            result_json: serde_json::Value,
3058            status: &str,
3059            _claim_token: Uuid,
3060        ) -> crate::error::Result<bool> {
3061            let key = (turn_id.to_string(), tool_call_id.to_string());
3062            let mut rows = self.rows.lock().unwrap();
3063            if let Some(row) = rows.get_mut(&key) {
3064                row.status = status.to_string();
3065                row.result_json = result_json;
3066                return Ok(true);
3067            }
3068            Ok(false)
3069        }
3070
3071        async fn get_tool_call_status(
3072            &self,
3073            turn_id: &str,
3074            tool_call_id: &str,
3075        ) -> crate::error::Result<Option<crate::durability::DurableToolCallStatus>> {
3076            let key = (turn_id.to_string(), tool_call_id.to_string());
3077            let rows = self.rows.lock().unwrap();
3078            Ok(rows.get(&key).map(|row| match row.status.as_str() {
3079                "settled" => crate::durability::DurableToolCallStatus::Settled {
3080                    result_json: row.result_json.clone(),
3081                },
3082                "interrupted" => crate::durability::DurableToolCallStatus::Interrupted {
3083                    result_json: Some(row.result_json.clone()),
3084                },
3085                _ => crate::durability::DurableToolCallStatus::Running,
3086            }))
3087        }
3088    }
3089
3090    fn make_act_input_with_store(
3091        tool_call: ToolCall,
3092        tool_defs: Vec<ToolDefinition>,
3093        context: ExecutionContext,
3094    ) -> ActInput {
3095        ActInput {
3096            org_id: None,
3097            context,
3098            harness_id: HarnessId::from_seed(1),
3099            agent_id: Some(AgentId::new()),
3100            tool_calls: vec![tool_call],
3101            tool_definitions: tool_defs,
3102            locale: None,
3103            blueprint_id: None,
3104            network_access: None,
3105            parallel_tool_calls: None,
3106        }
3107    }
3108
3109    fn arg_echo_tool_def(side_effect: SideEffectClass) -> ToolDefinition {
3110        ToolDefinition::Builtin(BuiltinTool {
3111            name: "argument_echo".to_string(),
3112            display_name: None,
3113            description: "echo".to_string(),
3114            parameters: json!({"type": "object"}),
3115            policy: Default::default(),
3116            category: None,
3117            deferrable: Default::default(),
3118            hints: ToolHints::default().with_side_effect_class(side_effect),
3119            full_parameters: None,
3120        })
3121    }
3122
3123    /// First execution succeeds normally and the result is settled in the store.
3124    #[tokio::test]
3125    async fn test_idempotency_first_execution_claims_and_settles() {
3126        let store = Arc::new(InMemoryDurableStore::default());
3127        let mut executor = ToolRegistry::with_defaults();
3128        executor.register(ArgumentEchoTool);
3129        let atom =
3130            ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3131
3132        let context = ExecutionContext::new(SessionId::new(), TurnId::new(), MessageId::new());
3133        let tc = ToolCall {
3134            id: "c1".to_string(),
3135            name: "argument_echo".to_string(),
3136            arguments: json!({"value": "hello"}),
3137        };
3138        let input = make_act_input_with_store(
3139            tc,
3140            vec![arg_echo_tool_def(SideEffectClass::AtMostOnce)],
3141            context,
3142        );
3143
3144        let result = atom.execute(input).await.unwrap();
3145        assert_eq!(result.success_count, 1);
3146        assert_eq!(result.error_count, 0);
3147
3148        // Row should be settled now.
3149        let rows = store.rows.lock().unwrap();
3150        let row = rows.values().next().unwrap();
3151        assert_eq!(row.status, "settled");
3152    }
3153
3154    /// Second execution replays the stored result without re-running the tool.
3155    #[tokio::test]
3156    async fn test_idempotency_replay_already_settled() {
3157        use crate::tool_fingerprint::tool_call_fingerprint;
3158
3159        let store = Arc::new(InMemoryDurableStore::default());
3160        let tc = ToolCall {
3161            id: "c1".to_string(),
3162            name: "argument_echo".to_string(),
3163            arguments: json!({"value": "hello"}),
3164        };
3165        let fp = tool_call_fingerprint(&tc);
3166
3167        // Pre-populate as settled.
3168        {
3169            let stored_result = serde_json::to_value(ToolResult {
3170                tool_call_id: "c1".to_string(),
3171                result: Some(json!({"value": "hello"})),
3172                images: None,
3173                error: None,
3174                connection_required: None,
3175                raw_output: None,
3176            })
3177            .unwrap();
3178            store.rows.lock().unwrap().insert(
3179                (
3180                    "turn_00000000000000000000000000000000".to_string(),
3181                    "c1".to_string(),
3182                ),
3183                StoreRow {
3184                    status: "settled".to_string(),
3185                    result_json: stored_result,
3186                    args_fingerprint: fp,
3187                    claim_token: Uuid::new_v4(),
3188                },
3189            );
3190        }
3191
3192        let mut executor = ToolRegistry::with_defaults();
3193        executor.register(ArgumentEchoTool);
3194        let atom =
3195            ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3196
3197        let context = ExecutionContext::new(
3198            SessionId::new(),
3199            TurnId::from_uuid(Uuid::nil()),
3200            MessageId::new(),
3201        );
3202        let input = make_act_input_with_store(
3203            tc,
3204            vec![arg_echo_tool_def(SideEffectClass::AtMostOnce)],
3205            context,
3206        );
3207
3208        let result = atom.execute(input).await.unwrap();
3209        assert_eq!(result.success_count, 1, "replay should count as success");
3210        assert_eq!(result.error_count, 0);
3211    }
3212
3213    /// AtMostOnce tool with a stale running claim returns an interrupted error.
3214    #[tokio::test]
3215    async fn test_idempotency_at_most_once_stale_running_returns_interrupted() {
3216        use crate::tool_fingerprint::tool_call_fingerprint;
3217
3218        let store = Arc::new(InMemoryDurableStore::default());
3219        let tc = ToolCall {
3220            id: "c1".to_string(),
3221            name: "argument_echo".to_string(),
3222            arguments: json!({"value": "x"}),
3223        };
3224        let fp = tool_call_fingerprint(&tc);
3225
3226        // Pre-populate as running (stale from dead worker).
3227        store.rows.lock().unwrap().insert(
3228            (
3229                "turn_00000000000000000000000000000000".to_string(),
3230                "c1".to_string(),
3231            ),
3232            StoreRow {
3233                status: "running".to_string(),
3234                result_json: serde_json::Value::Null,
3235                args_fingerprint: fp,
3236                claim_token: Uuid::new_v4(),
3237            },
3238        );
3239
3240        let mut executor = ToolRegistry::with_defaults();
3241        executor.register(ArgumentEchoTool);
3242        let atom =
3243            ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3244
3245        let context = ExecutionContext::new(
3246            SessionId::new(),
3247            TurnId::from_uuid(Uuid::nil()),
3248            MessageId::new(),
3249        );
3250        let input = make_act_input_with_store(
3251            tc,
3252            vec![arg_echo_tool_def(SideEffectClass::AtMostOnce)],
3253            context,
3254        );
3255
3256        let result = atom.execute(input).await.unwrap();
3257        assert_eq!(
3258            result.error_count, 1,
3259            "AtMostOnce stale running should error"
3260        );
3261        let err = result.results[0].result.error.as_deref().unwrap_or("");
3262        assert!(
3263            err.contains("interrupted"),
3264            "error should mention interrupted: {err}"
3265        );
3266
3267        // Row should be settled as interrupted.
3268        let rows = store.rows.lock().unwrap();
3269        let row = rows.values().next().unwrap();
3270        assert_eq!(row.status, "interrupted");
3271    }
3272
3273    /// Pure/Idempotent tool with a stale running claim proceeds to execution normally.
3274    #[tokio::test]
3275    async fn test_idempotency_idempotent_tool_stale_running_reexecutes() {
3276        use crate::tool_fingerprint::tool_call_fingerprint;
3277
3278        let store = Arc::new(InMemoryDurableStore::default());
3279        let tc = ToolCall {
3280            id: "c1".to_string(),
3281            name: "argument_echo".to_string(),
3282            arguments: json!({"value": "x"}),
3283        };
3284        let fp = tool_call_fingerprint(&tc);
3285
3286        store.rows.lock().unwrap().insert(
3287            (
3288                "turn_00000000000000000000000000000000".to_string(),
3289                "c1".to_string(),
3290            ),
3291            StoreRow {
3292                status: "running".to_string(),
3293                result_json: serde_json::Value::Null,
3294                args_fingerprint: fp,
3295                claim_token: Uuid::new_v4(),
3296            },
3297        );
3298
3299        let mut executor = ToolRegistry::with_defaults();
3300        executor.register(ArgumentEchoTool);
3301        let atom =
3302            ActAtom::new(executor, NoopEventEmitter).with_durable_tool_result_store(store.clone());
3303
3304        let context = ExecutionContext::new(
3305            SessionId::new(),
3306            TurnId::from_uuid(Uuid::nil()),
3307            MessageId::new(),
3308        );
3309        let input = make_act_input_with_store(
3310            tc,
3311            vec![arg_echo_tool_def(SideEffectClass::Idempotent)],
3312            context,
3313        );
3314
3315        let result = atom.execute(input).await.unwrap();
3316        assert_eq!(
3317            result.success_count, 1,
3318            "Idempotent should re-execute successfully"
3319        );
3320        assert_eq!(result.error_count, 0);
3321    }
3322}