1use 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#[derive(Clone, Debug)]
30pub struct PendingToolCall {
31 pub id: String,
32 pub name: String,
33 pub input: Value,
34}
35
36pub 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 pub waiting_approval_preview: Option<String>,
50}
51
52pub struct ToolMetrics {
54 pub tool_calls: i64,
55 pub tool_errors: i64,
56}
57
58fn 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
75fn 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 if !readonly_indices.is_empty() {
113 waves.push(readonly_indices);
114 }
115
116 for idx in sideeffect_indices {
118 waves.push(vec![idx]);
119 }
120
121 for idx in exclusive_indices {
123 waves.push(vec![idx]);
124 }
125
126 waves
127}
128
129#[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 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 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 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 all_results.sort_by_key(|(idx, _)| *idx);
224 all_results.into_iter().map(|(_, block)| block).collect()
225}
226
227#[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#[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 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 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 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 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 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 let mut result = state
381 .tools
382 .execute_with_auth(name, executed_input.clone(), &ctx.tool_auth)
383 .await;
384
385 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 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 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 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 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 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 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
583async 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 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#[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 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 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 for prep in &prepared {
765 if let Some(block) = &prep.skipped {
766 results.push((prep.idx, block.clone()));
767 }
768 }
769
770 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 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
930fn 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
962fn 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 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 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 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}