Skip to main content

microclaw_engine/
tool_executor.rs

1//! Parallel tool execution within a single agent turn.
2//!
3//! When the LLM returns multiple tool_use blocks, this module partitions them into
4//! execution "waves" based on their concurrency class and runs safe subsets in parallel
5//! via `tokio::JoinSet`.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use serde_json::Value;
11use tracing::{info, warn};
12
13use crate::hooks::HookOutcome;
14use crate::internal::observability::traces::{kv, new_span_id, now_unix_nano, SpanData};
15use crate::internal::tool_runtime::runtime::{
16    parse_concurrency_class, tool_concurrency_class, ToolConcurrencyClass, ToolResult,
17};
18use crate::runtime::AppState;
19use crate::tools::ToolAuthContext;
20use microclaw_core::llm_types::ContentBlock;
21use opentelemetry_proto::tonic::trace::v1::Status;
22
23use crate::agent_engine::AgentEvent;
24use crate::internal::observability::traces::OtlpTraceExporter;
25use microclaw_core::runtime_event::{RuntimePlanStep, RuntimePlanStepStatus, RuntimeProcessKind};
26use tokio::sync::mpsc::UnboundedSender;
27
28/// A single pending tool call extracted from the LLM response.
29#[derive(Clone, Debug)]
30pub struct PendingToolCall {
31    pub id: String,
32    pub name: String,
33    pub input: Value,
34}
35
36/// Mutable context accumulated across tool executions within a turn.
37pub struct ToolBatchContext {
38    pub failed_tools: std::collections::BTreeSet<String>,
39    pub failed_tool_details: Vec<String>,
40    pub seen_failed_tool_details: std::collections::HashSet<String>,
41    pub consecutive_send_message_calls: usize,
42    pub skill_env_files: Vec<String>,
43    pub tool_auth: ToolAuthContext,
44    pub waiting_for_user_approval: bool,
45    pub waiting_approval_tool: Option<String>,
46    /// Human-readable preview of the operation requesting approval (e.g., the
47    /// exact bash command). Surfaced to the operator so they can confirm
48    /// without having to scroll back through tool-call JSON.
49    pub waiting_approval_preview: Option<String>,
50}
51
52/// Metrics counters for tool execution.
53pub struct ToolMetrics {
54    pub tool_calls: i64,
55    pub tool_errors: i64,
56}
57
58/// Resolve the effective concurrency class for a tool, respecting config overrides.
59fn resolve_concurrency_class(
60    name: &str,
61    overrides: &HashMap<String, String>,
62) -> ToolConcurrencyClass {
63    if let Some(override_str) = overrides.get(name) {
64        if let Some(class) = parse_concurrency_class(override_str) {
65            return class;
66        }
67        warn!(
68            "Invalid tool_concurrency_override for '{}': '{}'; using default",
69            name, override_str
70        );
71    }
72    tool_concurrency_class(name)
73}
74
75/// Partition tool calls into execution waves based on concurrency classes.
76///
77/// Rules:
78/// 1. All ReadOnly -> single wave, all parallel
79/// 2. Mixed ReadOnly + others -> wave 1 = ReadOnly parallel, then rest sequentially
80/// 3. Exclusive -> runs alone in its own wave
81fn partition_into_waves(
82    calls: &[PendingToolCall],
83    overrides: &HashMap<String, String>,
84) -> Vec<Vec<usize>> {
85    if calls.is_empty() {
86        return Vec::new();
87    }
88    if calls.len() == 1 {
89        return vec![vec![0]];
90    }
91
92    let classes: Vec<ToolConcurrencyClass> = calls
93        .iter()
94        .map(|c| resolve_concurrency_class(&c.name, overrides))
95        .collect();
96
97    let mut readonly_indices = Vec::new();
98    let mut sideeffect_indices = Vec::new();
99    let mut exclusive_indices = Vec::new();
100
101    for (i, class) in classes.iter().enumerate() {
102        match class {
103            ToolConcurrencyClass::ReadOnly => readonly_indices.push(i),
104            ToolConcurrencyClass::SideEffect => sideeffect_indices.push(i),
105            ToolConcurrencyClass::Exclusive => exclusive_indices.push(i),
106        }
107    }
108
109    let mut waves = Vec::new();
110
111    // Wave 1: all ReadOnly tools in parallel
112    if !readonly_indices.is_empty() {
113        waves.push(readonly_indices);
114    }
115
116    // Then: SideEffect tools, one per wave (sequential)
117    for idx in sideeffect_indices {
118        waves.push(vec![idx]);
119    }
120
121    // Then: Exclusive tools, one per wave
122    for idx in exclusive_indices {
123        waves.push(vec![idx]);
124    }
125
126    waves
127}
128
129/// Execute a batch of tool calls, optionally in parallel.
130///
131/// When `parallel_enabled` is false, all tools execute sequentially (existing behavior).
132/// When true, tools are partitioned into waves and ReadOnly tools within a wave run concurrently.
133#[expect(clippy::too_many_arguments)]
134pub async fn execute_tool_batch(
135    state: &AppState,
136    calls: &[PendingToolCall],
137    ctx: &mut ToolBatchContext,
138    metrics: &mut ToolMetrics,
139    event_tx: Option<&UnboundedSender<AgentEvent>>,
140    chat_id: i64,
141    iteration: usize,
142    caller_channel: &str,
143    explicit_user_approval: bool,
144    trace_id: &[u8],
145    parent_span_id: &[u8],
146) -> Vec<ContentBlock> {
147    let max_concurrency = state.config.parallel_tool_max_concurrency;
148
149    if calls.len() <= 1 {
150        // Sequential execution (original behavior)
151        return execute_sequentially(
152            state,
153            calls,
154            ctx,
155            metrics,
156            event_tx,
157            chat_id,
158            iteration,
159            caller_channel,
160            explicit_user_approval,
161            trace_id,
162            parent_span_id,
163        )
164        .await;
165    }
166
167    let waves = partition_into_waves(calls, &state.config.tool_concurrency_overrides);
168    let mut all_results: Vec<(usize, ContentBlock)> = Vec::with_capacity(calls.len());
169
170    for (wave_idx, wave) in waves.iter().enumerate() {
171        if let Some(tx) = event_tx {
172            let _ = tx.send(AgentEvent::ToolWaveStart {
173                wave: wave_idx + 1,
174                tool_count: wave.len(),
175            });
176        }
177
178        if wave.len() == 1 {
179            // Single tool in wave — execute inline (no spawn overhead)
180            let idx = wave[0];
181            let result = execute_single_tool(
182                state,
183                &calls[idx],
184                ctx,
185                metrics,
186                event_tx,
187                chat_id,
188                iteration,
189                caller_channel,
190                explicit_user_approval,
191                trace_id,
192                parent_span_id,
193            )
194            .await;
195            all_results.push((idx, result));
196        } else {
197            // Multiple tools — run in parallel with JoinSet
198            let results = execute_wave_parallel(
199                state,
200                calls,
201                wave,
202                ctx,
203                metrics,
204                event_tx,
205                chat_id,
206                iteration,
207                caller_channel,
208                explicit_user_approval,
209                trace_id,
210                parent_span_id,
211                max_concurrency,
212            )
213            .await;
214            all_results.extend(results);
215        }
216
217        if let Some(tx) = event_tx {
218            let _ = tx.send(AgentEvent::ToolWaveComplete { wave: wave_idx + 1 });
219        }
220    }
221
222    // Sort by original index to preserve order
223    all_results.sort_by_key(|(idx, _)| *idx);
224    all_results.into_iter().map(|(_, block)| block).collect()
225}
226
227/// Execute all tools sequentially (the original behavior).
228#[expect(clippy::too_many_arguments)]
229async fn execute_sequentially(
230    state: &AppState,
231    calls: &[PendingToolCall],
232    ctx: &mut ToolBatchContext,
233    metrics: &mut ToolMetrics,
234    event_tx: Option<&UnboundedSender<AgentEvent>>,
235    chat_id: i64,
236    iteration: usize,
237    caller_channel: &str,
238    explicit_user_approval: bool,
239    trace_id: &[u8],
240    parent_span_id: &[u8],
241) -> Vec<ContentBlock> {
242    let mut results = Vec::with_capacity(calls.len());
243    for call in calls {
244        let block = execute_single_tool(
245            state,
246            call,
247            ctx,
248            metrics,
249            event_tx,
250            chat_id,
251            iteration,
252            caller_channel,
253            explicit_user_approval,
254            trace_id,
255            parent_span_id,
256        )
257        .await;
258        results.push(block);
259    }
260    results
261}
262
263/// Execute a single tool call with all the hooks, tracing, and guardrail logic.
264#[expect(clippy::too_many_arguments)]
265async fn execute_single_tool(
266    state: &AppState,
267    call: &PendingToolCall,
268    ctx: &mut ToolBatchContext,
269    metrics: &mut ToolMetrics,
270    event_tx: Option<&UnboundedSender<AgentEvent>>,
271    chat_id: i64,
272    iteration: usize,
273    caller_channel: &str,
274    explicit_user_approval: bool,
275    trace_id: &[u8],
276    parent_span_id: &[u8],
277) -> ContentBlock {
278    let id = &call.id;
279    let name = &call.name;
280    let input = &call.input;
281
282    // Malformed tool name check
283    if name.trim().is_empty() {
284        warn!(
285            chat_id,
286            iteration,
287            tool_use_id = %id,
288            "Skipping malformed tool call with empty tool name"
289        );
290        return ContentBlock::ToolResult {
291            tool_use_id: id.clone(),
292            content: "Malformed tool call: missing tool name. Retry with a valid registered tool."
293                .to_string(),
294            is_error: Some(true),
295        };
296    }
297
298    // send_message consecutive call guardrail
299    if name != "send_message" {
300        ctx.consecutive_send_message_calls = 0;
301    } else if ctx.consecutive_send_message_calls >= 3 {
302        warn!(
303            chat_id,
304            iteration, "Guardrail: blocking repeated send_message loop"
305        );
306        let content = "send_message blocked: too many consecutive send_message calls in one request. Use normal assistant reply for final output instead of repeatedly calling send_message.".to_string();
307        ctx.failed_tools.insert(name.clone());
308        let detail = format!("send_message: {content}");
309        if ctx.seen_failed_tool_details.insert(detail.clone()) {
310            ctx.failed_tool_details.push(detail);
311        }
312        return ContentBlock::ToolResult {
313            tool_use_id: id.clone(),
314            content,
315            is_error: Some(true),
316        };
317    }
318
319    // Feishu send_message text restriction
320    let is_feishu_turn = caller_channel.starts_with("feishu") || caller_channel.starts_with("lark");
321    let send_message_has_attachment = input
322        .get("attachment_path")
323        .and_then(|v| v.as_str())
324        .map(|v| !v.trim().is_empty())
325        .unwrap_or(false);
326    if name == "send_message" && is_feishu_turn && !send_message_has_attachment {
327        return ContentBlock::ToolResult {
328            tool_use_id: id.clone(),
329            content: "send_message text replies are disabled for Feishu runtime turns; return the final assistant text directly so channel reaction/text delivery can be handled correctly. If you need to send a file, call send_message with attachment_path.".to_string(),
330            is_error: Some(true),
331        };
332    }
333
334    // Before-tool hook
335    let mut effective_input = input.clone();
336    if let Ok(hook_outcome) = state
337        .hooks
338        .run_before_tool(chat_id, caller_channel, iteration, name, &effective_input)
339        .await
340    {
341        match hook_outcome {
342            HookOutcome::Block { reason } => {
343                return ContentBlock::ToolResult {
344                    tool_use_id: id.clone(),
345                    content: if reason.trim().is_empty() {
346                        format!("tool '{}' blocked by policy hook", name)
347                    } else {
348                        reason
349                    },
350                    is_error: Some(true),
351                };
352            }
353            HookOutcome::Allow { patches } => {
354                for patch in patches {
355                    if let Some(v) = patch.get("tool_input") {
356                        effective_input = v.clone();
357                    }
358                }
359            }
360        }
361    }
362
363    // Emit ToolStart event
364    if let Some(tx) = event_tx {
365        let _ = tx.send(AgentEvent::ToolStart {
366            call_id: id.clone(),
367            name: name.clone(),
368            input: effective_input.clone(),
369        });
370    }
371    info!(chat_id, tool = %name, iteration, "Executing tool");
372
373    let started = std::time::Instant::now();
374    let mut executed_input = effective_input.clone();
375    let tool_span_id = new_span_id();
376    let tool_start = now_unix_nano();
377    metrics.tool_calls += 1;
378
379    // Execute tool
380    let mut result = state
381        .tools
382        .execute_with_auth(name, executed_input.clone(), &ctx.tool_auth)
383        .await;
384
385    // Trace span
386    if let Some(exp) = &state.trace_exporter {
387        emit_tool_span(
388            exp,
389            trace_id,
390            tool_span_id,
391            parent_span_id,
392            name,
393            &executed_input,
394            &result,
395            tool_start,
396            "tool_execution",
397        );
398    }
399
400    // Auto-retry on approval_required
401    if result.is_error && result.error_type.as_deref() == Some("approval_required") {
402        let can_retry_with_approval = if state.config.high_risk_tool_user_confirmation_required {
403            explicit_user_approval
404        } else {
405            true
406        };
407        if can_retry_with_approval {
408            executed_input = with_high_risk_approval_marker(&effective_input);
409            if state.config.high_risk_tool_user_confirmation_required {
410                info!("Retrying tool '{}' after explicit user approval", name);
411            } else {
412                info!("Auto-retrying tool '{}' after approval gate", name);
413            }
414            let retry_span_id = new_span_id();
415            let retry_start = now_unix_nano();
416            metrics.tool_calls += 1;
417
418            result = state
419                .tools
420                .execute_with_auth(name, executed_input.clone(), &ctx.tool_auth)
421                .await;
422
423            if let Some(exp) = &state.trace_exporter {
424                emit_tool_span(
425                    exp,
426                    trace_id,
427                    retry_span_id,
428                    parent_span_id,
429                    name,
430                    &executed_input,
431                    &result,
432                    retry_start,
433                    "tool_execution_retry",
434                );
435            }
436        } else if state.config.high_risk_tool_user_confirmation_required {
437            ctx.waiting_for_user_approval = true;
438            ctx.waiting_approval_tool = Some(name.clone());
439            ctx.waiting_approval_preview = approval_preview(name, &executed_input);
440        }
441    }
442
443    // activate_skill metadata handling
444    if name == "activate_skill" && !result.is_error {
445        if let Some(meta) = &result.metadata {
446            if let Some(path) = meta.get("skill_env_file").and_then(|v| v.as_str()) {
447                let path_str = path.to_string();
448                if !ctx.skill_env_files.contains(&path_str) {
449                    ctx.skill_env_files.push(path_str);
450                    ctx.tool_auth.env_files = ctx.skill_env_files.clone();
451                }
452                if let Ok(files_json) = serde_json::to_string(&ctx.skill_env_files) {
453                    let db = state.db.clone();
454                    let _ = crate::internal::storage::db::call_blocking(db, move |db| {
455                        db.save_session_skill_envs(chat_id, &files_json)
456                    })
457                    .await;
458                }
459            }
460        }
461    }
462
463    // After-tool hook
464    if let Ok(hook_outcome) = state
465        .hooks
466        .run_after_tool(
467            chat_id,
468            caller_channel,
469            iteration,
470            name,
471            &executed_input,
472            &result,
473        )
474        .await
475    {
476        match hook_outcome {
477            HookOutcome::Block { reason } => {
478                result.is_error = true;
479                if !reason.trim().is_empty() {
480                    result.content = reason;
481                }
482                if result.error_type.is_none() {
483                    result.error_type = Some("hook_blocked".to_string());
484                }
485            }
486            HookOutcome::Allow { patches } => {
487                for patch in patches {
488                    if let Some(v) = patch.get("content").and_then(|v| v.as_str()) {
489                        result.content = v.to_string();
490                    }
491                    if let Some(v) = patch.get("is_error").and_then(|v| v.as_bool()) {
492                        result.is_error = v;
493                    }
494                    if let Some(v) = patch
495                        .get("error_type")
496                        .and_then(|v| v.as_str())
497                        .map(str::to_string)
498                    {
499                        result.error_type = Some(v);
500                    }
501                    if let Some(v) = patch
502                        .get("status_code")
503                        .and_then(|v| v.as_i64())
504                        .map(|x| x as i32)
505                    {
506                        result.status_code = Some(v);
507                    }
508                }
509            }
510        }
511    }
512
513    // Error tracking
514    if result.is_error && result.error_type.as_deref() != Some("approval_required") {
515        let suppress = result
516            .error_type
517            .as_deref()
518            .map(|t| t == "feishu_reaction_protocol_text" || t == "feishu_send_message_disabled")
519            .unwrap_or(false);
520        if !suppress {
521            ctx.failed_tools.insert(name.clone());
522            let detail = format_failed_action_for_user(name, &executed_input, &result.content);
523            if ctx.seen_failed_tool_details.insert(detail.clone()) {
524                ctx.failed_tool_details.push(detail);
525            }
526        }
527        let preview = truncate_for_log(&result.content, 300);
528        warn!(
529            chat_id,
530            tool = %name,
531            iteration,
532            error_type = ?result.error_type,
533            "Tool execution failed: {}",
534            preview
535        );
536    }
537
538    // Emit ToolResult event
539    if let Some(tx) = event_tx {
540        let preview = truncate_for_log(&result.content, 160);
541        let duration_ms = result
542            .duration_ms
543            .unwrap_or_else(|| started.elapsed().as_millis());
544        let transport_duration_ms = u64::try_from(duration_ms).unwrap_or(u64::MAX);
545        let _ = tx.send(AgentEvent::ToolResult {
546            call_id: id.clone(),
547            name: name.clone(),
548            is_error: result.is_error,
549            preview,
550            duration_ms: transport_duration_ms,
551            status_code: result.status_code,
552            bytes: result.bytes,
553            error_type: result.error_type.clone(),
554        });
555        emit_file_diff_event(tx, &result);
556        emit_plan_update_event(tx, name, &executed_input, result.is_error);
557        emit_process_output_event(tx, id, name, &executed_input, &result, duration_ms);
558    }
559    if result.is_error {
560        metrics.tool_errors += 1;
561    }
562    if name == "send_message" {
563        ctx.consecutive_send_message_calls += 1;
564    }
565
566    // Spill oversized tool results to an artifact and truncate the inline copy
567    // so the message history stays cheap to replay. The agent can fetch the
568    // full body via `fetch_artifact`. `fetch_artifact` itself is exempted to
569    // avoid recursive truncation when the agent reads a slice.
570    let content = if name == "fetch_artifact" {
571        result.content
572    } else {
573        maybe_spill_to_artifact(state, chat_id, name, result.content).await
574    };
575
576    ContentBlock::ToolResult {
577        tool_use_id: id.clone(),
578        content,
579        is_error: if result.is_error { Some(true) } else { None },
580    }
581}
582
583/// Spill `content` to a `tool_result_artifacts` row when it exceeds the
584/// configured threshold; the returned string keeps the head + tail and a
585/// pointer the agent can pass to `fetch_artifact`. Falls back to the
586/// untouched content on any storage error so a transient DB failure never
587/// drops a tool result.
588async fn maybe_spill_to_artifact(
589    state: &AppState,
590    chat_id: i64,
591    tool_name: &str,
592    content: String,
593) -> String {
594    let threshold = state.config.tool_result_truncation_threshold_chars;
595    if threshold == 0 {
596        return content;
597    }
598    let total_chars = content.chars().count();
599    if total_chars <= threshold {
600        return content;
601    }
602
603    let head_chars = state.config.tool_result_truncation_head_chars;
604    let tail_chars = state.config.tool_result_truncation_tail_chars;
605    let ttl_hours = state.config.tool_result_artifact_ttl_hours;
606
607    // Guard against pathological config: head + tail must still leave room
608    // for the truncation marker, otherwise truncation is pointless.
609    if head_chars + tail_chars >= total_chars {
610        return content;
611    }
612
613    let artifact_id = format!("art_{}", uuid::Uuid::new_v4().simple());
614    let expires_at = (chrono::Utc::now() + chrono::Duration::hours(ttl_hours as i64)).to_rfc3339();
615
616    let db = state.db.clone();
617    let artifact_id_for_save = artifact_id.clone();
618    let tool_name_for_save = tool_name.to_string();
619    let content_for_save = content.clone();
620    let saved = crate::internal::storage::db::call_blocking(db, move |db| {
621        db.save_tool_artifact(
622            &artifact_id_for_save,
623            chat_id,
624            &tool_name_for_save,
625            &content_for_save,
626            &expires_at,
627        )
628    })
629    .await;
630
631    if let Err(e) = saved {
632        warn!(
633            chat_id,
634            tool = %tool_name,
635            "Failed to persist tool artifact; returning untruncated content: {}",
636            e
637        );
638        return content;
639    }
640
641    let head: String = content.chars().take(head_chars).collect();
642    let tail: String = content.chars().skip(total_chars - tail_chars).collect();
643    let hidden = total_chars - head_chars - tail_chars;
644    format!(
645        "{head}\n\n[--- {hidden} of {total_chars} characters truncated. \
646         Full result saved as artifact `{artifact_id}` for {ttl_hours}h. \
647         Call fetch_artifact(artifact_id=\"{artifact_id}\", offset=N, length=N) \
648         to read more. ---]\n\n{tail}"
649    )
650}
651
652/// Execute a wave of tools in parallel using futures::join_all.
653///
654/// Before-tool hooks run sequentially first, then tools execute concurrently,
655/// then after-tool hooks are handled per-tool. This preserves hook ordering.
656#[expect(clippy::too_many_arguments)]
657async fn execute_wave_parallel(
658    state: &AppState,
659    calls: &[PendingToolCall],
660    wave_indices: &[usize],
661    ctx: &mut ToolBatchContext,
662    metrics: &mut ToolMetrics,
663    event_tx: Option<&UnboundedSender<AgentEvent>>,
664    chat_id: i64,
665    iteration: usize,
666    caller_channel: &str,
667    _explicit_user_approval: bool,
668    _trace_id: &[u8],
669    _parent_span_id: &[u8],
670    _max_concurrency: usize,
671) -> Vec<(usize, ContentBlock)> {
672    info!(
673        chat_id,
674        wave_size = wave_indices.len(),
675        "Executing tool wave in parallel"
676    );
677
678    // Pre-process: run before_tool hooks sequentially and prepare inputs.
679    struct PreparedCall {
680        idx: usize,
681        name: String,
682        effective_input: Value,
683        skipped: Option<ContentBlock>,
684    }
685
686    let mut prepared = Vec::with_capacity(wave_indices.len());
687    for &idx in wave_indices {
688        let call = &calls[idx];
689        let name = &call.name;
690        let mut effective_input = call.input.clone();
691
692        if let Some(tx) = event_tx {
693            let _ = tx.send(AgentEvent::ToolStart {
694                call_id: call.id.clone(),
695                name: name.clone(),
696                input: effective_input.clone(),
697            });
698        }
699
700        let mut skipped = None;
701        if let Ok(hook_outcome) = state
702            .hooks
703            .run_before_tool(chat_id, caller_channel, iteration, name, &effective_input)
704            .await
705        {
706            match hook_outcome {
707                HookOutcome::Block { reason } => {
708                    skipped = Some(ContentBlock::ToolResult {
709                        tool_use_id: call.id.clone(),
710                        content: if reason.trim().is_empty() {
711                            format!("tool '{}' blocked by policy hook", name)
712                        } else {
713                            reason
714                        },
715                        is_error: Some(true),
716                    });
717                }
718                HookOutcome::Allow { patches } => {
719                    for patch in patches {
720                        if let Some(v) = patch.get("tool_input") {
721                            effective_input = v.clone();
722                        }
723                    }
724                }
725            }
726        }
727
728        prepared.push(PreparedCall {
729            idx,
730            name: name.clone(),
731            effective_input,
732            skipped,
733        });
734    }
735
736    // Execute non-skipped tools in parallel using futures::join_all.
737    // This borrows state rather than requiring 'static, which avoids the Clone issue.
738    let tool_auth = ctx.tool_auth.clone();
739    let futures: Vec<_> = prepared
740        .iter()
741        .filter(|p| p.skipped.is_none())
742        .map(|prep| {
743            let tool_auth = tool_auth.clone();
744            let name = prep.name.clone();
745            let input = prep.effective_input.clone();
746            let idx = prep.idx;
747            async move {
748                let started = std::time::Instant::now();
749                let result = state
750                    .tools
751                    .execute_with_auth(&name, input.clone(), &tool_auth)
752                    .await;
753                let duration = started.elapsed().as_millis();
754                (idx, name, input, result, duration)
755            }
756        })
757        .collect();
758
759    let parallel_results = futures_util::future::join_all(futures).await;
760
761    let mut results: Vec<(usize, ContentBlock)> = Vec::with_capacity(prepared.len());
762
763    // Add skipped results first
764    for prep in &prepared {
765        if let Some(block) = &prep.skipped {
766            results.push((prep.idx, block.clone()));
767        }
768    }
769
770    // Process parallel results
771    for (idx, name, input, result, _duration) in parallel_results {
772        let call_id = &calls[idx].id;
773        metrics.tool_calls += 1;
774        if result.is_error {
775            metrics.tool_errors += 1;
776            ctx.failed_tools.insert(name.clone());
777        }
778        if name == "send_message" {
779            ctx.consecutive_send_message_calls += 1;
780        }
781
782        // Emit ToolResult event
783        if let Some(tx) = event_tx {
784            let preview = truncate_for_log(&result.content, 160);
785            let duration_ms = result.duration_ms.unwrap_or(_duration);
786            let transport_duration_ms = u64::try_from(duration_ms).unwrap_or(u64::MAX);
787            let _ = tx.send(AgentEvent::ToolResult {
788                call_id: call_id.clone(),
789                name: name.clone(),
790                is_error: result.is_error,
791                preview,
792                duration_ms: transport_duration_ms,
793                status_code: result.status_code,
794                bytes: result.bytes,
795                error_type: result.error_type.clone(),
796            });
797            emit_file_diff_event(tx, &result);
798            emit_plan_update_event(tx, &name, &input, result.is_error);
799            emit_process_output_event(tx, call_id, &name, &input, &result, duration_ms);
800        }
801
802        results.push((
803            idx,
804            ContentBlock::ToolResult {
805                tool_use_id: call_id.clone(),
806                content: result.content,
807                is_error: if result.is_error { Some(true) } else { None },
808            },
809        ));
810    }
811
812    results
813}
814
815fn emit_plan_update_event(
816    tx: &UnboundedSender<AgentEvent>,
817    tool_name: &str,
818    input: &Value,
819    is_error: bool,
820) {
821    if tool_name != "todo_write" || is_error {
822        return;
823    }
824    let Some(todos) = input.get("todos").and_then(Value::as_array) else {
825        return;
826    };
827    let steps = todos
828        .iter()
829        .filter_map(|todo| {
830            let title = todo.get("task")?.as_str()?.trim();
831            if title.is_empty() {
832                return None;
833            }
834            let status = match todo.get("status")?.as_str()? {
835                "pending" => RuntimePlanStepStatus::Pending,
836                "in_progress" => RuntimePlanStepStatus::InProgress,
837                "completed" => RuntimePlanStepStatus::Completed,
838                _ => return None,
839            };
840            Some(RuntimePlanStep {
841                title: title.to_string(),
842                status,
843            })
844        })
845        .collect();
846    let _ = tx.send(AgentEvent::PlanUpdated { steps });
847}
848
849const MAX_PROCESS_OUTPUT_BYTES: usize = 16 * 1024;
850
851fn emit_process_output_event(
852    tx: &UnboundedSender<AgentEvent>,
853    call_id: &str,
854    tool_name: &str,
855    input: &Value,
856    result: &ToolResult,
857    duration_ms: u128,
858) {
859    if tool_name != "bash" {
860        return;
861    }
862    let Some(command) = input.get("command").and_then(Value::as_str) else {
863        return;
864    };
865    let command = microclaw_core::redact::redact_secrets(command);
866    let command = bounded_text(&command, 2_000).0;
867    let output = microclaw_core::redact::redact_secrets(&result.content);
868    let (output, truncated) = bounded_text(&output, MAX_PROCESS_OUTPUT_BYTES);
869    let kind = if is_verification_command(&command) {
870        RuntimeProcessKind::Verification
871    } else {
872        RuntimeProcessKind::Command
873    };
874    let _ = tx.send(AgentEvent::ProcessOutput {
875        call_id: call_id.to_string(),
876        command,
877        output,
878        exit_code: result.status_code,
879        duration_ms: u64::try_from(duration_ms).unwrap_or(u64::MAX),
880        truncated,
881        kind,
882    });
883}
884
885fn is_verification_command(command: &str) -> bool {
886    let normalized = command.to_ascii_lowercase();
887    [
888        "cargo test",
889        "cargo check",
890        "cargo clippy",
891        "npm test",
892        "npm run test",
893        "npm run build",
894        "pnpm test",
895        "pnpm build",
896        "yarn test",
897        "yarn build",
898        "pytest",
899        "go test",
900        "swift test",
901        "dotnet test",
902        "mvn test",
903        "gradle test",
904    ]
905    .iter()
906    .any(|marker| normalized.contains(marker))
907}
908
909fn bounded_text(text: &str, limit: usize) -> (String, bool) {
910    if text.len() <= limit {
911        return (text.to_string(), false);
912    }
913    let half = limit / 2;
914    let head_end = microclaw_core::text::floor_char_boundary(text, half);
915    let mut tail_start = text.len() - half;
916    while tail_start < text.len() && !text.is_char_boundary(tail_start) {
917        tail_start += 1;
918    }
919    (
920        format!(
921            "{}\n\n[… {} bytes omitted …]\n\n{}",
922            &text[..head_end],
923            tail_start.saturating_sub(head_end),
924            &text[tail_start..]
925        ),
926        true,
927    )
928}
929
930/// Forward a `file_diff` metadata payload (attached by edit_file/write_file)
931/// as an `AgentEvent::FileDiff` so channels and the web UI can render what
932/// changed. Successful edits only — error results never carry the payload.
933fn emit_file_diff_event(
934    tx: &tokio::sync::mpsc::UnboundedSender<AgentEvent>,
935    result: &crate::internal::tool_runtime::runtime::ToolResult,
936) {
937    let Some(fd) = result
938        .metadata
939        .as_ref()
940        .and_then(|m| m.get(microclaw_core::diff::FILE_DIFF_METADATA_KEY))
941    else {
942        return;
943    };
944    let (Some(path), Some(diff)) = (
945        fd.get("path").and_then(|v| v.as_str()),
946        fd.get("diff").and_then(|v| v.as_str()),
947    ) else {
948        return;
949    };
950    let _ = tx.send(AgentEvent::FileDiff {
951        path: path.to_string(),
952        diff: diff.to_string(),
953        added: fd.get("added").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
954        removed: fd.get("removed").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
955        truncated: fd
956            .get("truncated")
957            .and_then(|v| v.as_bool())
958            .unwrap_or(false),
959    });
960}
961
962/// Build a short, operator-readable preview of the action awaiting approval.
963/// For bash this is the actual command; for any other future high-risk tool
964/// we fall back to a truncated JSON dump so the operator can still see what
965/// is queued instead of just the tool name.
966fn approval_preview(name: &str, input: &Value) -> Option<String> {
967    if name == "bash" {
968        if let Some(cmd) = input
969            .get("command")
970            .or_else(|| input.get("cmd"))
971            .and_then(|v| v.as_str())
972        {
973            let trimmed = cmd.trim();
974            if trimmed.is_empty() {
975                return None;
976            }
977            return Some(truncate_for_log(trimmed, 400));
978        }
979    }
980    let dump = input.to_string();
981    if dump == "{}" || dump == "null" {
982        None
983    } else {
984        Some(truncate_for_log(&dump, 400))
985    }
986}
987
988fn with_high_risk_approval_marker(input: &Value) -> Value {
989    let mut approved_input = input.clone();
990    if let Some(obj) = approved_input.as_object_mut() {
991        obj.insert(
992            "__microclaw_high_risk_approved".to_string(),
993            Value::Bool(true),
994        );
995        return approved_input;
996    }
997    serde_json::json!({
998        "__microclaw_high_risk_approved": true,
999        "__microclaw_original_input": input,
1000    })
1001}
1002
1003fn format_failed_action_for_user(tool_name: &str, input: &Value, result_content: &str) -> String {
1004    let error_summary = truncate_for_log(result_content, 140);
1005    if tool_name == "bash" {
1006        if let Some(command) = input
1007            .get("command")
1008            .or_else(|| input.get("cmd"))
1009            .and_then(|v| v.as_str())
1010        {
1011            let command_summary = truncate_for_log(command, 140);
1012            return format!("bash `{command_summary}` failed: {error_summary}");
1013        }
1014    }
1015    let input_summary = truncate_for_log(&input.to_string(), 100);
1016    format!("{tool_name} input `{input_summary}` failed: {error_summary}")
1017}
1018
1019fn truncate_for_log(text: &str, max_chars: usize) -> String {
1020    let count = text.chars().count();
1021    if count <= max_chars {
1022        text.to_string()
1023    } else {
1024        let clipped = text.chars().take(max_chars).collect::<String>();
1025        format!("{clipped}...")
1026    }
1027}
1028
1029#[expect(clippy::too_many_arguments)]
1030fn emit_tool_span(
1031    exp: &Arc<OtlpTraceExporter>,
1032    trace_id: &[u8],
1033    span_id: Vec<u8>,
1034    parent_span_id: &[u8],
1035    name: &str,
1036    input: &Value,
1037    result: &ToolResult,
1038    start_time: u64,
1039    span_name: &str,
1040) {
1041    let mut attrs = vec![kv("tool.name", name), kv("input", &input.to_string())];
1042    if result.is_error {
1043        attrs.push(kv(
1044            "error.type",
1045            result.error_type.as_deref().unwrap_or("unknown"),
1046        ));
1047        attrs.push(kv("output", &result.content));
1048    } else {
1049        attrs.push(kv("output", &truncate_for_log(&result.content, 1000)));
1050    }
1051    exp.send_span(SpanData {
1052        trace_id: trace_id.to_vec(),
1053        span_id,
1054        parent_span_id: parent_span_id.to_vec(),
1055        name: span_name.to_string(),
1056        start_time_unix_nano: start_time,
1057        end_time_unix_nano: now_unix_nano(),
1058        attributes: attrs,
1059        status: if result.is_error {
1060            Some(Status {
1061                message: result.content.clone(),
1062                code: 2,
1063            })
1064        } else {
1065            Some(Status {
1066                message: "".to_string(),
1067                code: 1,
1068            })
1069        },
1070        kind: 1,
1071    });
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077
1078    #[test]
1079    fn test_partition_single_readonly() {
1080        let calls = vec![PendingToolCall {
1081            id: "1".into(),
1082            name: "read_file".into(),
1083            input: Value::Null,
1084        }];
1085        let waves = partition_into_waves(&calls, &HashMap::new());
1086        assert_eq!(waves, vec![vec![0]]);
1087    }
1088
1089    #[test]
1090    fn test_partition_all_readonly_parallel() {
1091        let calls = vec![
1092            PendingToolCall {
1093                id: "1".into(),
1094                name: "read_file".into(),
1095                input: Value::Null,
1096            },
1097            PendingToolCall {
1098                id: "2".into(),
1099                name: "glob".into(),
1100                input: Value::Null,
1101            },
1102            PendingToolCall {
1103                id: "3".into(),
1104                name: "web_search".into(),
1105                input: Value::Null,
1106            },
1107        ];
1108        let waves = partition_into_waves(&calls, &HashMap::new());
1109        assert_eq!(waves, vec![vec![0, 1, 2]]);
1110    }
1111
1112    #[test]
1113    fn test_partition_mixed_readonly_sideeffect() {
1114        let calls = vec![
1115            PendingToolCall {
1116                id: "1".into(),
1117                name: "read_file".into(),
1118                input: Value::Null,
1119            },
1120            PendingToolCall {
1121                id: "2".into(),
1122                name: "write_file".into(),
1123                input: Value::Null,
1124            },
1125            PendingToolCall {
1126                id: "3".into(),
1127                name: "glob".into(),
1128                input: Value::Null,
1129            },
1130        ];
1131        let waves = partition_into_waves(&calls, &HashMap::new());
1132        // Wave 1: readonly [0, 2], Wave 2: sideeffect [1]
1133        assert_eq!(waves, vec![vec![0, 2], vec![1]]);
1134    }
1135
1136    #[test]
1137    fn test_partition_exclusive_alone() {
1138        let calls = vec![
1139            PendingToolCall {
1140                id: "1".into(),
1141                name: "read_file".into(),
1142                input: Value::Null,
1143            },
1144            PendingToolCall {
1145                id: "2".into(),
1146                name: "bash".into(),
1147                input: Value::Null,
1148            },
1149            PendingToolCall {
1150                id: "3".into(),
1151                name: "glob".into(),
1152                input: Value::Null,
1153            },
1154        ];
1155        let waves = partition_into_waves(&calls, &HashMap::new());
1156        // Wave 1: readonly [0, 2], Wave 2: exclusive [1]
1157        assert_eq!(waves, vec![vec![0, 2], vec![1]]);
1158    }
1159
1160    #[test]
1161    fn test_partition_config_override() {
1162        let mut overrides = HashMap::new();
1163        overrides.insert("mcp_custom_search".to_string(), "read_only".to_string());
1164
1165        let calls = vec![
1166            PendingToolCall {
1167                id: "1".into(),
1168                name: "read_file".into(),
1169                input: Value::Null,
1170            },
1171            PendingToolCall {
1172                id: "2".into(),
1173                name: "mcp_custom_search".into(),
1174                input: Value::Null,
1175            },
1176        ];
1177        let waves = partition_into_waves(&calls, &overrides);
1178        // Both should be in single parallel wave (mcp_custom_search overridden to ReadOnly)
1179        assert_eq!(waves, vec![vec![0, 1]]);
1180    }
1181
1182    #[test]
1183    fn test_partition_empty() {
1184        let calls: Vec<PendingToolCall> = vec![];
1185        let waves = partition_into_waves(&calls, &HashMap::new());
1186        assert!(waves.is_empty());
1187    }
1188
1189    #[test]
1190    fn successful_todo_write_emits_structured_plan() {
1191        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1192        emit_plan_update_event(
1193            &tx,
1194            "todo_write",
1195            &serde_json::json!({
1196                "chat_id": 42,
1197                "todos": [
1198                    {"task": "Inspect", "status": "completed"},
1199                    {"task": "Implement", "status": "in_progress"},
1200                    {"task": "Verify", "status": "pending"}
1201                ]
1202            }),
1203            false,
1204        );
1205
1206        assert_eq!(
1207            rx.try_recv().unwrap(),
1208            AgentEvent::PlanUpdated {
1209                steps: vec![
1210                    RuntimePlanStep {
1211                        title: "Inspect".into(),
1212                        status: RuntimePlanStepStatus::Completed,
1213                    },
1214                    RuntimePlanStep {
1215                        title: "Implement".into(),
1216                        status: RuntimePlanStepStatus::InProgress,
1217                    },
1218                    RuntimePlanStep {
1219                        title: "Verify".into(),
1220                        status: RuntimePlanStepStatus::Pending,
1221                    },
1222                ],
1223            }
1224        );
1225    }
1226
1227    #[test]
1228    fn failed_todo_write_does_not_publish_a_plan() {
1229        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1230        emit_plan_update_event(
1231            &tx,
1232            "todo_write",
1233            &serde_json::json!({"todos": [{"task": "Nope", "status": "pending"}]}),
1234            true,
1235        );
1236        assert!(rx.try_recv().is_err());
1237    }
1238
1239    #[test]
1240    fn bash_result_emits_bounded_redacted_verification_evidence() {
1241        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1242        let secret = "sk-proj-abcdefghijklmnopqrstuvwxyz0123456789";
1243        let output = format!(
1244            "{secret}\n{}\nTAIL",
1245            "x".repeat(MAX_PROCESS_OUTPUT_BYTES + 100)
1246        );
1247        let result = ToolResult::success(output).with_status_code(0);
1248
1249        emit_process_output_event(
1250            &tx,
1251            "call-check",
1252            "bash",
1253            &serde_json::json!({"command": "cargo test -p microclaw-core"}),
1254            &result,
1255            321,
1256        );
1257
1258        let event = rx.try_recv().unwrap();
1259        let AgentEvent::ProcessOutput {
1260            call_id,
1261            output,
1262            exit_code,
1263            duration_ms,
1264            truncated,
1265            kind,
1266            ..
1267        } = event
1268        else {
1269            panic!("expected process output event");
1270        };
1271        assert_eq!(call_id, "call-check");
1272        assert_eq!(exit_code, Some(0));
1273        assert_eq!(duration_ms, 321);
1274        assert!(truncated);
1275        assert_eq!(kind, RuntimeProcessKind::Verification);
1276        assert!(!output.contains(secret));
1277        assert!(output.to_ascii_lowercase().contains("redacted"));
1278        assert!(output.ends_with("TAIL"));
1279    }
1280
1281    #[test]
1282    fn non_process_tool_does_not_emit_process_output() {
1283        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1284        emit_process_output_event(
1285            &tx,
1286            "call-read",
1287            "read_file",
1288            &serde_json::json!({"path": "README.md"}),
1289            &ToolResult::success("contents".into()),
1290            1,
1291        );
1292        assert!(rx.try_recv().is_err());
1293    }
1294}