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