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