Skip to main content

zeroclaw_runtime/agent/
tool_execution.rs

1//! Tool execution helpers extracted from `loop_`.
2//!
3//! Contains the functions responsible for invoking tools (single, parallel,
4//! sequential) and the decision logic for choosing between parallel and
5//! sequential execution.
6
7use anyhow::Result;
8use std::time::{Duration, Instant};
9use tokio_util::sync::CancellationToken;
10
11use crate::approval::ApprovalManager;
12use crate::observability::{Observer, ObserverEvent};
13use crate::tools::Tool;
14
15// Items that still live in `loop_` — import via the parent module.
16use super::loop_::{ParsedToolCall, ToolLoopCancelled, scrub_credentials};
17
18// ── Helpers ──────────────────────────────────────────────────────────────
19
20/// Look up a tool by name in a slice of boxed `dyn Tool` values.
21pub fn find_tool<'a>(tools: &'a [Box<dyn Tool>], name: &str) -> Option<&'a dyn Tool> {
22    tools.iter().find(|t| t.name() == name).map(|t| t.as_ref())
23}
24
25// ── Outcome ──────────────────────────────────────────────────────────────
26
27pub struct ToolExecutionOutcome {
28    pub output: String,
29    pub success: bool,
30    pub error_reason: Option<String>,
31    pub duration: Duration,
32    /// Cryptographic HMAC receipt proving this tool actually executed.
33    /// Present only when tool receipts are enabled in config.
34    pub receipt: Option<String>,
35}
36
37// ── Single tool execution ────────────────────────────────────────────────
38
39pub async fn execute_one_tool(
40    call_name: &str,
41    call_arguments: serde_json::Value,
42    tool_call_id: Option<&str>,
43    tools_registry: &[Box<dyn Tool>],
44    activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
45    observer: &dyn Observer,
46    cancellation_token: Option<&CancellationToken>,
47    receipt_generator: Option<&super::tool_receipts::ReceiptGenerator>,
48) -> Result<ToolExecutionOutcome> {
49    // Serialize arguments once and carry the full JSON into both observer
50    // events. Previously the start event received a 300-char summary and the
51    // completion event received no arguments at all, which made tool spans
52    // opaque in OTel backends (see upstream issue #5980 — "Otel Traces Should
53    // Include More Details About Why A Tool Call Failed"). Size is bounded
54    // downstream by the tracing exporter, so we don't need to clip here.
55    let full_args = call_arguments.to_string();
56    let tool_call_id_owned = tool_call_id.map(str::to_string);
57    observer.record_event(&ObserverEvent::ToolCallStart {
58        tool: call_name.to_string(),
59        tool_call_id: tool_call_id_owned.clone(),
60        arguments: Some(full_args.clone()),
61    });
62    let start = Instant::now();
63
64    let static_tool = find_tool(tools_registry, call_name);
65    let activated_arc = if static_tool.is_none() {
66        activated_tools.and_then(|at| at.lock().unwrap().get_resolved(call_name))
67    } else {
68        None
69    };
70    let Some(tool) = static_tool.or(activated_arc.as_deref()) else {
71        let reason = format!("Unknown tool: {call_name}");
72        let duration = start.elapsed();
73        let scrubbed_reason = scrub_credentials(&reason);
74        observer.record_event(&ObserverEvent::ToolCall {
75            tool: call_name.to_string(),
76            tool_call_id: tool_call_id_owned.clone(),
77            duration,
78            success: false,
79            arguments: Some(full_args.clone()),
80            result: Some(scrubbed_reason.clone()),
81        });
82        return Ok(ToolExecutionOutcome {
83            output: reason,
84            success: false,
85            error_reason: Some(scrubbed_reason),
86            duration,
87            receipt: None,
88        });
89    };
90
91    use ::zeroclaw_log::Instrument;
92    let tool_span = ::zeroclaw_log::info_span!(
93        target: "zeroclaw_log_internal_scope",
94        "zeroclaw_scope",
95        tool = %call_name,
96    );
97
98    // Auto tool I/O propagation: emit Start with full input, run the
99    // tool, then emit Complete or Fail with full output. Per-tool
100    // execute() impls add zero logging.
101    let _start_guard = tool_span.clone().entered();
102    ::zeroclaw_log::record!(
103        INFO,
104        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Invoke)
105            .with_category(::zeroclaw_log::EventCategory::Tool)
106            .with_attrs(::serde_json::json!({"input": call_arguments})),
107        "tool invocation start"
108    );
109    drop(_start_guard);
110
111    let tool_future = tool
112        .execute(call_arguments.clone())
113        .instrument(tool_span.clone());
114    let tool_result = if let Some(token) = cancellation_token {
115        tokio::select! {
116            () = token.cancelled() => return Err(ToolLoopCancelled.into()),
117            result = tool_future => result,
118        }
119    } else {
120        tool_future.await
121    };
122
123    let _result_guard = tool_span.entered();
124    match tool_result {
125        Ok(r) => {
126            let duration = start.elapsed();
127            if r.success {
128                ::zeroclaw_log::record!(
129                    INFO,
130                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Complete)
131                        .with_category(::zeroclaw_log::EventCategory::Tool)
132                        .with_outcome(::zeroclaw_log::EventOutcome::Success)
133                        .with_duration(duration.as_millis() as u64)
134                        .with_attrs(::serde_json::json!({"output": r.output})),
135                    "tool invocation complete"
136                );
137            } else {
138                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail).with_category(::zeroclaw_log::EventCategory::Tool).with_outcome(::zeroclaw_log::EventOutcome::Failure).with_duration(duration.as_millis() as u64).with_attrs(::serde_json::json!({"error": r.error.clone().unwrap_or_default(), "output": r.output})), "tool invocation failed");
139            }
140            if r.success {
141                let normalized_output = if r.output.is_empty() {
142                    "(no output)"
143                } else {
144                    &r.output
145                };
146                let output = scrub_credentials(normalized_output);
147                let receipt = receipt_generator.map(|receipt_gen| {
148                    receipt_gen.generate_now(call_name, &call_arguments, &output)
149                });
150                observer.record_event(&ObserverEvent::ToolCall {
151                    tool: call_name.to_string(),
152                    tool_call_id: tool_call_id_owned.clone(),
153                    duration,
154                    success: true,
155                    arguments: Some(full_args.clone()),
156                    result: Some(output.clone()),
157                });
158                Ok(ToolExecutionOutcome {
159                    output,
160                    success: true,
161                    error_reason: None,
162                    duration,
163                    receipt,
164                })
165            } else {
166                let reason = r.error.unwrap_or(r.output);
167                let scrubbed_reason = scrub_credentials(&reason);
168                observer.record_event(&ObserverEvent::ToolCall {
169                    tool: call_name.to_string(),
170                    tool_call_id: tool_call_id_owned.clone(),
171                    duration,
172                    success: false,
173                    arguments: Some(full_args.clone()),
174                    result: Some(scrubbed_reason.clone()),
175                });
176                Ok(ToolExecutionOutcome {
177                    output: format!("Error: {reason}"),
178                    success: false,
179                    error_reason: Some(scrubbed_reason),
180                    duration,
181                    receipt: None,
182                })
183            }
184        }
185        Err(e) => {
186            let duration = start.elapsed();
187            ::zeroclaw_log::record!(
188                ERROR,
189                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
190                    .with_category(::zeroclaw_log::EventCategory::Tool)
191                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
192                    .with_duration(duration.as_millis() as u64)
193                    .with_attrs(::serde_json::json!({"error": format!("{e:?}")})),
194                "tool invocation errored"
195            );
196            let reason = format!("Error executing {call_name}: {e}");
197            let scrubbed_reason = scrub_credentials(&reason);
198            observer.record_event(&ObserverEvent::ToolCall {
199                tool: call_name.to_string(),
200                tool_call_id: tool_call_id_owned.clone(),
201                duration,
202                success: false,
203                arguments: Some(full_args.clone()),
204                result: Some(scrubbed_reason.clone()),
205            });
206            Ok(ToolExecutionOutcome {
207                output: reason,
208                success: false,
209                error_reason: Some(scrubbed_reason),
210                duration,
211                receipt: None,
212            })
213        }
214    }
215}
216
217// ── Parallel / sequential decision ───────────────────────────────────────
218
219pub fn should_execute_tools_in_parallel(
220    tool_calls: &[ParsedToolCall],
221    approval: Option<&ApprovalManager>,
222) -> bool {
223    if tool_calls.len() <= 1 {
224        return false;
225    }
226
227    // tool_search activates deferred MCP tools into ActivatedToolSet.
228    // Running tool_search in parallel with the tools it activates causes a
229    // race condition where the tool lookup happens before activation completes.
230    // Force sequential execution whenever tool_search is in the batch.
231    if tool_calls.iter().any(|call| call.name == "tool_search") {
232        return false;
233    }
234
235    if let Some(mgr) = approval
236        && tool_calls.iter().any(|call| mgr.needs_approval(&call.name))
237    {
238        // Approval-gated calls must keep sequential handling so the caller can
239        // enforce CLI prompt/deny policy consistently.
240        return false;
241    }
242
243    true
244}
245
246// ── Parallel execution ───────────────────────────────────────────────────
247
248pub async fn execute_tools_parallel(
249    tool_calls: &[ParsedToolCall],
250    tools_registry: &[Box<dyn Tool>],
251    activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
252    observer: &dyn Observer,
253    cancellation_token: Option<&CancellationToken>,
254    receipt_generator: Option<&super::tool_receipts::ReceiptGenerator>,
255) -> Result<Vec<ToolExecutionOutcome>> {
256    let futures: Vec<_> = tool_calls
257        .iter()
258        .map(|call| {
259            execute_one_tool(
260                &call.name,
261                call.arguments.clone(),
262                call.tool_call_id.as_deref(),
263                tools_registry,
264                activated_tools,
265                observer,
266                cancellation_token,
267                receipt_generator,
268            )
269        })
270        .collect();
271
272    let results = futures_util::future::join_all(futures).await;
273    results.into_iter().collect()
274}
275
276// ── Sequential execution ─────────────────────────────────────────────────
277
278pub async fn execute_tools_sequential(
279    tool_calls: &[ParsedToolCall],
280    tools_registry: &[Box<dyn Tool>],
281    activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
282    observer: &dyn Observer,
283    cancellation_token: Option<&CancellationToken>,
284    receipt_generator: Option<&super::tool_receipts::ReceiptGenerator>,
285) -> Result<Vec<ToolExecutionOutcome>> {
286    let mut outcomes = Vec::with_capacity(tool_calls.len());
287
288    for call in tool_calls {
289        outcomes.push(
290            execute_one_tool(
291                &call.name,
292                call.arguments.clone(),
293                call.tool_call_id.as_deref(),
294                tools_registry,
295                activated_tools,
296                observer,
297                cancellation_token,
298                receipt_generator,
299            )
300            .await?,
301        );
302    }
303
304    Ok(outcomes)
305}