返回 CodeWhale
tool_execution.rs
根目录 / crates / tui / src / core / engine / tool_execution.rs
1 //! Low-level tool execution helpers for the engine turn loop.
2 //!
3 //! This module keeps the mechanics of MCP dispatch, execution locking, and
4 //! parallel-tool fanout out of `engine.rs`; the turn loop still owns planning,
5 //! approval, and how tool results are written back into session state.
6
7 use std::{
8 fs::OpenOptions,
9 io::Write,
10 path::{Path, PathBuf},
11 sync::Arc,
12 time::Duration,
13 };
14
15 use super::*;
16
17 const TOOL_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
18
19 fn inherited_interactive_shell_refusal(tool_name: &str, interactive: bool) -> Option<ToolError> {
20 if !interactive || !matches!(tool_name, "bash" | "Bash" | "exec_shell") {
21 return None;
22 }
23 crate::tools::shell::inherited_interactive_terminal_refusal()
24 .map(|message| ToolError::execution_failed(message.to_string()))
25 }
26
27 /// Emits delayed, best-effort liveness pulses for one running tool.
28 ///
29 /// Keep the ticker in its own task instead of embedding `tokio::time::Interval`
30 /// in the already-large engine turn future. Besides keeping the turn future
31 /// compact, this leaves pre-execution MCP discovery and approval scheduling
32 /// untouched. Dropping the guard cancels and aborts the ticker synchronously.
33 struct ToolHeartbeatGuard {
34 cancel: tokio_util::sync::CancellationToken,
35 task: tokio::task::JoinHandle<()>,
36 }
37
38 impl ToolHeartbeatGuard {
39 fn start(tx_event: mpsc::Sender<Event>, interval: Duration) -> Self {
40 let cancel = tokio_util::sync::CancellationToken::new();
41 let task_cancel = cancel.clone();
42 let task = tokio::spawn(async move {
43 let mut ticker = tokio::time::interval(interval);
44 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
45 // Tokio intervals tick immediately once. Consume that tick so fast
46 // tools do not produce a pulse and the first heartbeat is delayed.
47 ticker.tick().await;
48
49 loop {
50 tokio::select! {
51 biased;
52
53 () = task_cancel.cancelled() => break,
54 _ = ticker.tick() => {
55 match tx_event.try_send(Event::ToolCallHeartbeat) {
56 Ok(()) | Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
57 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break,
58 }
59 }
60 }
61 }
62 });
63 Self { cancel, task }
64 }
65 }
66
67 impl Drop for ToolHeartbeatGuard {
68 fn drop(&mut self) {
69 self.cancel.cancel();
70 self.task.abort();
71 }
72 }
73
74 /// RAII guard that pauses the TUI's terminal-state ownership for the duration
75 /// of an interactive tool, then restores it on drop.
76 ///
77 /// Background: interactive tools (anything that needs the raw TTY — external
78 /// editor, `exec_shell` with stdin, etc.) need the TUI to leave alt-screen,
79 /// disable raw mode, and release mouse capture so the child sees a normal
80 /// terminal. The TUI listens for `Event::PauseEvents` / `Event::ResumeEvents`
81 /// and runs `pause_terminal` / `resume_terminal` in response.
82 ///
83 /// Earlier code sent `PauseEvents` before tool execution and `ResumeEvents`
84 /// after. That worked on the happy path, but if the tool's future was dropped
85 /// — Ctrl+C cancellation, sub-agent abort, parent task cancelled while the
86 /// tool was awaiting — the second `await` never reached and `ResumeEvents`
87 /// was never sent. It also let interactive children start before the UI had
88 /// actually left alt-screen/raw mode. Both failures strand the TUI in a
89 /// regular shell scrollback: the parent shell scrollbar takes over, mouse
90 /// wheel scrolls the host terminal instead of the transcript, and the TUI
91 /// renders at the bottom of cooked-mode output.
92 ///
93 /// `Drop` runs synchronously and can't await, so we first use `try_send` on a
94 /// **clone of the event channel** to push `ResumeEvents` non-blockingly. If the
95 /// channel is full we enqueue the resume on the active Tokio runtime instead of
96 /// dropping it; otherwise a burst of engine events can strand the UI in the
97 /// paused terminal state.
98 pub(super) struct InteractiveTerminalGuard {
99 tx: Option<mpsc::Sender<Event>>,
100 }
101
102 impl InteractiveTerminalGuard {
103 /// Send `PauseEvents` and arm the guard. If `interactive` is false the
104 /// guard is a no-op — `Drop` will skip the resume.
105 pub(super) async fn engage(
106 tx: mpsc::Sender<Event>,
107 interactive: bool,
108 ) -> Result<Self, ToolError> {
109 if !interactive {
110 return Ok(Self { tx: None });
111 }
112 // Arm before the send/ack awaits. Cancellation can drop this future
113 // at either await point; the guard must still queue ResumeEvents if a
114 // PauseEvents raced into the UI first.
115 let guard = Self {
116 tx: Some(tx.clone()),
117 };
118 let ack = Arc::new(tokio::sync::Notify::new());
119 match tx
120 .send(Event::PauseEvents {
121 ack: Some(ack.clone()),
122 })
123 .await
124 {
125 Ok(()) => {
126 if tokio::time::timeout(Duration::from_millis(750), ack.notified())
127 .await
128 .is_err()
129 {
130 // `guard` drops on return and queues the matching resume.
131 return Err(ToolError::execution_failed(
132 "Terminal handoff was not acknowledged; interactive tool was not launched.",
133 ));
134 }
135 }
136 Err(err) => {
137 tracing::debug!(
138 target: "engine.tool_execution",
139 ?err,
140 "InteractiveTerminalGuard: event channel closed before PauseEvents"
141 );
142 return Err(ToolError::execution_failed(
143 "Terminal handoff channel closed; interactive tool was not launched.",
144 ));
145 }
146 }
147 Ok(guard)
148 }
149 }
150
151 impl Drop for InteractiveTerminalGuard {
152 fn drop(&mut self) {
153 if let Some(tx) = self.tx.take() {
154 match tx.try_send(Event::ResumeEvents) {
155 Ok(()) => {}
156 Err(tokio::sync::mpsc::error::TrySendError::Full(event)) => {
157 match tokio::runtime::Handle::try_current() {
158 Ok(handle) => {
159 handle.spawn(async move {
160 if let Err(err) = tx.send(event).await {
161 tracing::warn!(
162 target: "engine.tool_execution",
163 ?err,
164 "InteractiveTerminalGuard: async send(ResumeEvents) failed; \
165 terminal may stay in paused state until the next \
166 pause/resume cycle"
167 );
168 }
169 });
170 }
171 Err(err) => {
172 tracing::warn!(
173 target: "engine.tool_execution",
174 ?err,
175 "InteractiveTerminalGuard: event channel full and no Tokio runtime \
176 available to queue ResumeEvents; terminal may stay paused until \
177 the next pause/resume cycle"
178 );
179 }
180 }
181 }
182 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
183 tracing::debug!(
184 target: "engine.tool_execution",
185 "InteractiveTerminalGuard: event channel closed before ResumeEvents"
186 );
187 }
188 }
189 }
190 }
191 }
192
193 pub(crate) fn emit_tool_audit(event: serde_json::Value) {
194 let Some(path) = std::env::var_os("CODEWHALE_TOOL_AUDIT_LOG")
195 .or_else(|| std::env::var_os("DEEPSEEK_TOOL_AUDIT_LOG"))
196 else {
197 return;
198 };
199 emit_tool_audit_to_path(&PathBuf::from(path), event);
200 }
201
202 fn emit_tool_audit_to_path(path: &Path, event: serde_json::Value) {
203 let line = match serde_json::to_string(&event) {
204 Ok(line) => line,
205 Err(e) => {
206 tracing::error!("Failed to serialize tool audit event: {e}");
207 return;
208 }
209 };
210 if let Some(parent) = path.parent()
211 && let Err(e) = std::fs::create_dir_all(parent)
212 {
213 tracing::error!(
214 "Failed to create audit log directory {}: {e}",
215 parent.display()
216 );
217 return;
218 }
219 match OpenOptions::new().create(true).append(true).open(path) {
220 Ok(mut file) => {
221 if let Err(e) = writeln!(file, "{line}") {
222 tracing::error!("Failed to write to audit log {}: {e}", path.display());
223 }
224 }
225 Err(e) => {
226 tracing::error!("Failed to open audit log {}: {e}", path.display());
227 }
228 }
229 }
230
231 impl Engine {
232 pub(super) async fn execute_mcp_tool_with_pool(
233 pool: Arc<AsyncMutex<McpPool>>,
234 tx_event: &mpsc::Sender<Event>,
235 name: &str,
236 input: serde_json::Value,
237 disallowed_tools: &[String],
238 ) -> Result<RichToolResult, ToolError> {
239 McpPool::authorize_call(disallowed_tools, name, &input)
240 .map_err(|error| ToolError::not_available(error.to_string()))?;
241 // A synthetic `mcp_<server>_authenticate` call runs the shared OAuth
242 // login flow with the pool lock released during the browser wait, so
243 // parallel MCP tools and the `/mcp` manager keep working while the
244 // user signs in. On success it changes the callable MCP surface (the
245 // server's real tools replace the synthetic one); flag that so the
246 // turn loop merges the refreshed catalog before the next model
247 // request instead of leaving the model with names it cannot legally
248 // call yet.
249 let auth_target = pool.lock().await.authenticate_tool_target(name);
250 if let Some(server) = auth_target {
251 let mut result = crate::mcp::authenticate_tool_via_pool(&pool, &server, |url| {
252 // The model cannot relay the URL until the call returns, and
253 // the call returns only after the sign-in completes — so the
254 // user must see it now. This status is the only copy of the
255 // URL: `try_send` drops it on a full channel, so the send
256 // waits for room on its own task instead of failing the
257 // login.
258 let server = server.clone();
259 let url = url.to_string();
260 let tx = tx_event.clone();
261 tokio::spawn(async move {
262 let _ = tx
263 .send(Event::status(format!(
264 "◆ auth required: sign in to MCP server '{server}' in your browser — {url}"
265 )))
266 .await;
267 });
268 })
269 .await
270 .map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?;
271 McpPool::filter_authenticate_result(&mut result, disallowed_tools);
272 let mut rich = crate::tools::registry::mcp_result_to_bounded_rich_tool_result(result);
273 if rich.result.success {
274 rich.result.metadata = Some(serde_json::json!({ "mcp_catalog_changed": true }));
275 }
276 return Ok(rich);
277 }
278 let needs_auth_generation_before = pool.lock().await.needs_auth_generation();
279 let result = pool
280 .lock()
281 .await
282 .call_tool_with_disallowed(name, input, disallowed_tools)
283 .await;
284 match result {
285 Ok(result) => {
286 Ok(crate::tools::registry::mcp_result_to_bounded_rich_tool_result(result))
287 }
288 Err(error) => {
289 // A credential the server stopped accepting mid-session
290 // flips it into the typed needs-auth state and drops its
291 // connection — the callable surface changed. For THAT
292 // transition only, return the failure as a result (not an
293 // Err) carrying `mcp_catalog_changed`, so the turn loop
294 // replaces the pool's catalog slice: dead tools leave, the
295 // synthetic login tool arrives, and the error's own hint
296 // stays model-readable. Every other failure keeps the Err
297 // contract.
298 let auth_surface_changed = {
299 let pool = pool.lock().await;
300 pool.needs_auth_generation() != needs_auth_generation_before
301 };
302 if !auth_surface_changed {
303 return Err(ToolError::execution_failed(format!(
304 "MCP tool failed: {error}"
305 )));
306 }
307 let tool_result =
308 crate::tools::spec::ToolResult::error(format!("MCP tool failed: {error}"))
309 .with_metadata(serde_json::json!({ "mcp_catalog_changed": true }));
310 Ok(RichToolResult::plain(tool_result))
311 }
312 }
313 }
314
315 pub(super) async fn execute_parallel_tool(
316 &mut self,
317 input: serde_json::Value,
318 tool_registry: Option<&crate::tools::ToolRegistry>,
319 tool_exec_lock: Arc<RwLock<()>>,
320 context_override: Option<crate::tools::ToolContext>,
321 ) -> Result<RichToolResult, ToolError> {
322 let calls = parse_parallel_tool_calls(&input)?;
323 let mcp_pool = if calls.iter().any(|(tool, _)| McpPool::is_mcp_tool(tool)) {
324 Some(self.ensure_mcp_pool().await?)
325 } else {
326 None
327 };
328 let Some(registry) = tool_registry else {
329 return Err(ToolError::not_available(
330 "tool registry unavailable for multi_tool_use.parallel",
331 ));
332 };
333
334 let result_count = calls.len();
335 let mut tasks = FuturesUnordered::new();
336 let shell_permits = Arc::new(tokio::sync::Semaphore::new(MAX_PARALLEL_SHELL_EXEC));
337 for (index, (tool_name, tool_input)) in calls.into_iter().enumerate() {
338 if tool_name == MULTI_TOOL_PARALLEL_NAME {
339 return Err(ToolError::invalid_input(
340 "multi_tool_use.parallel cannot call itself",
341 ));
342 }
343 if McpPool::is_mcp_tool(&tool_name) {
344 if !mcp_tool_is_parallel_safe(&tool_name) {
345 return Err(ToolError::invalid_input(format!(
346 "Tool '{tool_name}' is an MCP tool and cannot run in parallel. \
347 Allowed MCP tools: list_mcp_resources, list_mcp_resource_templates, \
348 mcp_read_resource, read_mcp_resource, mcp_get_prompt."
349 )));
350 }
351 } else {
352 let Some(spec) = registry.get(&tool_name) else {
353 return Err(ToolError::not_available(format!(
354 "tool '{tool_name}' is not registered"
355 )));
356 };
357 if !spec.is_read_only_for(&tool_input) {
358 return Err(ToolError::invalid_input(format!(
359 "Tool '{tool_name}' is not read-only and cannot run in parallel"
360 )));
361 }
362 if spec.approval_requirement_for(&tool_input) != ApprovalRequirement::Auto {
363 return Err(ToolError::invalid_input(format!(
364 "Tool '{tool_name}' requires approval and cannot run in parallel"
365 )));
366 }
367 if !spec.supports_parallel_for(&tool_input) {
368 return Err(ToolError::invalid_input(format!(
369 "Tool '{tool_name}' does not support parallel execution"
370 )));
371 }
372 }
373
374 let registry_ref = registry;
375 let lock = tool_exec_lock.clone();
376 let tx_event = self.tx_event.clone();
377 let mcp_pool = mcp_pool.clone();
378 let shell_permits = shell_permits.clone();
379 let workspace = self.session.workspace.clone();
380 let context_override = context_override.clone();
381 let cancel_token = self.cancel_token.clone();
382 tasks.push(async move {
383 let _shell_permit = if matches!(tool_name.as_str(), "bash" | "Bash" | "exec_shell")
384 {
385 shell_permits.acquire_owned().await.ok()
386 } else {
387 None
388 };
389 let result = Engine::execute_tool_with_lock(
390 lock,
391 true,
392 false,
393 tx_event,
394 Some(cancel_token),
395 tool_name.clone(),
396 tool_input.clone(),
397 workspace,
398 Some(registry_ref),
399 mcp_pool,
400 context_override,
401 )
402 .await;
403 (index, tool_name, result)
404 });
405 }
406
407 let mut results: Vec<Option<ParallelToolResultEntry>> = Vec::with_capacity(result_count);
408 results.resize_with(result_count, || None);
409 let mut content_blocks = Vec::new();
410 while let Some((index, tool_name, result)) = tasks.next().await {
411 let entry = match result {
412 Ok(output) => {
413 let RichToolResult {
414 result: output,
415 content_blocks: output_blocks,
416 } = output;
417 content_blocks.extend(output_blocks);
418 let mut error = None;
419 if !output.success {
420 error = Some(output.content.clone());
421 }
422 ParallelToolResultEntry {
423 tool_name,
424 success: output.success,
425 content: output.content,
426 error,
427 }
428 }
429 Err(err) => {
430 let message = format!("{err}");
431 ParallelToolResultEntry {
432 tool_name,
433 success: false,
434 content: format!("Error: {message}"),
435 error: Some(message),
436 }
437 }
438 };
439 results[index] = Some(entry);
440 }
441 let results = results.into_iter().flatten().collect();
442
443 let result = ToolResult::json(&ParallelToolResult { results })
444 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
445 Ok(crate::image_attach::bound_rich_tool_result(
446 RichToolResult::with_content_blocks(result, content_blocks),
447 ))
448 }
449
450 #[allow(clippy::too_many_arguments)]
451 pub(super) async fn execute_tool_with_lock(
452 lock: Arc<RwLock<()>>,
453 supports_parallel: bool,
454 interactive: bool,
455 tx_event: mpsc::Sender<Event>,
456 cancel_token: Option<CancellationToken>,
457 tool_name: String,
458 tool_input: serde_json::Value,
459 workspace: PathBuf,
460 registry: Option<&crate::tools::ToolRegistry>,
461 mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
462 context_override: Option<crate::tools::ToolContext>,
463 ) -> Result<RichToolResult, ToolError> {
464 if cancel_token
465 .as_ref()
466 .is_some_and(CancellationToken::is_cancelled)
467 {
468 return Err(ToolError::permission_denied(
469 "Turn stopped by user. Tool call blocked.",
470 ));
471 }
472 // Unix inherited-terminal shell calls are impossible without a full
473 // POSIX job-control lease. Refuse them before the terminal guard so a
474 // known-invalid call cannot flash host scrollback or drain input.
475 if let Some(error) = inherited_interactive_shell_refusal(&tool_name, interactive) {
476 return Err(error);
477 }
478 // This guard starts before lock acquisition, so contention as well as
479 // registry/MCP/interpreter execution remains visibly live.
480 let _heartbeat = ToolHeartbeatGuard::start(tx_event.clone(), TOOL_HEARTBEAT_INTERVAL);
481 let started_at = std::time::Instant::now();
482 let dispatch = if McpPool::is_mcp_tool(&tool_name) {
483 "mcp"
484 } else if matches!(
485 tool_name.as_str(),
486 CODE_EXECUTION_TOOL_NAME | JS_EXECUTION_TOOL_NAME | EXECUTE_TOOLS_TOOL_NAME
487 ) {
488 "interpreter"
489 } else if registry.is_some() {
490 "registry"
491 } else {
492 "missing"
493 };
494 let input_bytes = serde_json::to_string(&tool_input)
495 .map(|s| s.len())
496 .unwrap_or(0);
497 tracing::debug!(
498 target: "engine.tool_execution",
499 tool = %tool_name,
500 dispatch,
501 interactive,
502 supports_parallel,
503 input_bytes,
504 "tool.exec.start",
505 );
506
507 let _guard = if supports_parallel {
508 ToolExecGuard::Read(lock.read().await)
509 } else {
510 ToolExecGuard::Write(lock.write().await)
511 };
512
513 // RAII pause/resume: ensures `Event::ResumeEvents` always fires on
514 // drop, even if the tool future is cancelled mid-await. See
515 // `InteractiveTerminalGuard` doc-comment for the regression this
516 // closes (parent terminal scrollback hijacking the TUI after a
517 // cancelled interactive tool).
518 let _terminal = InteractiveTerminalGuard::engage(tx_event.clone(), interactive).await?;
519
520 if cancel_token
521 .as_ref()
522 .is_some_and(CancellationToken::is_cancelled)
523 {
524 return Err(ToolError::permission_denied(
525 "Turn stopped by user. Tool call blocked.",
526 ));
527 }
528
529 if let Some(context) = context_override
530 .as_ref()
531 .or_else(|| registry.map(|registry| registry.context()))
532 {
533 super::tool_catalog::enforce_tool_denial(context, &tool_name, &tool_input)?;
534 }
535
536 let tool_authority = context_override
537 .as_ref()
538 .and_then(|context| context.tool_authority.as_ref())
539 .or_else(|| registry.and_then(|registry| registry.context().tool_authority.as_ref()));
540 if let Some(authority) = tool_authority {
541 if McpPool::is_mcp_tool(&tool_name)
542 && !super::dispatch::mcp_tool_is_read_only(&tool_name)
543 {
544 return Err(ToolError::permission_denied(format!(
545 "worker '{}' cannot run mutating MCP tool {tool_name}: it has no authorized file target",
546 authority.owner
547 )));
548 }
549 if matches!(
550 tool_name.as_str(),
551 CODE_EXECUTION_TOOL_NAME | JS_EXECUTION_TOOL_NAME | EXECUTE_TOOLS_TOOL_NAME
552 ) {
553 return Err(ToolError::permission_denied(format!(
554 "worker '{}' cannot run {tool_name}: arbitrary code execution is outside its machine-readable authority envelope",
555 authority.owner
556 )));
557 }
558 }
559
560 let outcome: Result<RichToolResult, ToolError> = if McpPool::is_mcp_tool(&tool_name) {
561 if let Some(pool) = mcp_pool {
562 let disallowed_tools = context_override
563 .as_ref()
564 .or_else(|| registry.map(|registry| registry.context()))
565 .map(|context| context.disallowed_tools.as_slice())
566 .unwrap_or_default();
567 Engine::execute_mcp_tool_with_pool(
568 pool,
569 &tx_event,
570 &tool_name,
571 tool_input,
572 disallowed_tools,
573 )
574 .await
575 } else {
576 Err(ToolError::not_available(format!(
577 "tool '{tool_name}' is not registered"
578 )))
579 }
580 } else if tool_name == CODE_EXECUTION_TOOL_NAME {
581 execute_code_execution_tool(&tool_input, &workspace)
582 .await
583 .map(RichToolResult::plain)
584 } else if tool_name == JS_EXECUTION_TOOL_NAME {
585 execute_js_execution_tool(&tool_input, &workspace)
586 .await
587 .map(RichToolResult::plain)
588 } else if tool_name == EXECUTE_TOOLS_TOOL_NAME {
589 if let Some(registry) = registry {
590 let context = context_override
591 .as_ref()
592 .cloned()
593 .unwrap_or_else(|| registry.context().clone());
594 crate::tools::codemode::execute_tools_tool(&tool_input, registry, &context)
595 .await
596 .map(RichToolResult::plain)
597 } else {
598 Err(ToolError::not_available(format!(
599 "tool '{tool_name}' is not registered"
600 )))
601 }
602 } else if let Some(registry) = registry {
603 registry
604 .execute_rich_full_with_context(&tool_name, tool_input, context_override.as_ref())
605 .await
606 } else {
607 Err(ToolError::not_available(format!(
608 "tool '{tool_name}' is not registered"
609 )))
610 };
611
612 let duration_ms = started_at.elapsed().as_millis() as u64;
613 // The surface-agnostic choke point for every tool call, so this one
614 // bump covers exec and the CLI as well as the TUI. `memory_search` is
615 // counted here for the same reason — one site, not one per tool.
616 let telemetry = codewhale_telemetry::session_counters();
617 telemetry.bump(codewhale_telemetry::Counter::ToolCalls);
618 if tool_name == "memory_search" {
619 telemetry.bump(codewhale_telemetry::Counter::MemorySearch);
620 }
621 match &outcome {
622 Ok(result) => {
623 tracing::debug!(
624 target: "engine.tool_execution",
625 tool = %tool_name,
626 dispatch,
627 duration_ms,
628 success = result.result.success,
629 output_bytes = result.result.content.len(),
630 "tool.exec.end",
631 );
632 }
633 Err(err) => {
634 let kind = match err {
635 ToolError::InvalidInput { .. } => "invalid_input",
636 ToolError::MissingField { .. } => "missing_field",
637 ToolError::PathEscape { .. } => "path_escape",
638 ToolError::ExecutionFailed { .. } => "execution_failed",
639 ToolError::Timeout { .. } => "timeout",
640 ToolError::Cancelled { .. } => "cancelled",
641 ToolError::NotAvailable { .. } => "not_available",
642 ToolError::PermissionDenied { .. } => "permission_denied",
643 };
644 // The discriminant and nothing else. `ToolError::PathEscape`'s
645 // `Display` *is* an absolute path, and several sibling
646 // variants render a literal source fragment the model emitted.
647 match err {
648 ToolError::PermissionDenied { .. } => {
649 telemetry.bump_error(codewhale_telemetry::ErrorCounter::ToolDeniedByPolicy)
650 }
651 ToolError::Timeout { .. } => {
652 telemetry.bump_error(codewhale_telemetry::ErrorCounter::ToolTimeout);
653 }
654 _ => {}
655 }
656 tracing::warn!(
657 target: "engine.tool_execution",
658 tool = %tool_name,
659 dispatch,
660 duration_ms,
661 error_kind = kind,
662 error = %err,
663 "tool.exec.end",
664 );
665 }
666 }
667 outcome
668 }
669 }
670
671 #[cfg(test)]
672 mod tests {
673 use super::*;
674 use serde_json::json;
675 use std::time::Duration;
676
677 const TEST_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(10);
678
679 #[tokio::test]
680 async fn tool_heartbeat_emits_for_slow_tool() {
681 let (tx, mut rx) = mpsc::channel(4);
682 let guard = ToolHeartbeatGuard::start(tx, TEST_HEARTBEAT_INTERVAL);
683
684 let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
685 .await
686 .expect("heartbeat before slow tool completes")
687 .expect("event channel stays open");
688
689 assert!(matches!(event, Event::ToolCallHeartbeat));
690 drop(guard);
691 }
692
693 #[tokio::test]
694 async fn tool_heartbeat_is_delayed_for_fast_tool() {
695 let (tx, mut rx) = mpsc::channel(4);
696
697 let guard = ToolHeartbeatGuard::start(tx, TEST_HEARTBEAT_INTERVAL);
698 drop(guard);
699 tokio::time::sleep(TEST_HEARTBEAT_INTERVAL * 2).await;
700
701 assert!(rx.try_recv().is_err(), "fast tool emitted a heartbeat");
702 }
703
704 #[tokio::test]
705 async fn tool_heartbeat_stops_after_tool_completes() {
706 let (tx, mut rx) = mpsc::channel(8);
707 let guard = ToolHeartbeatGuard::start(tx, TEST_HEARTBEAT_INTERVAL);
708
709 let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
710 .await
711 .expect("heartbeat before slow tool completes")
712 .expect("event channel stays open");
713 assert!(matches!(event, Event::ToolCallHeartbeat));
714
715 drop(guard);
716 tokio::task::yield_now().await;
717 while rx.try_recv().is_ok() {}
718 tokio::time::sleep(TEST_HEARTBEAT_INTERVAL * 2).await;
719 assert!(
720 rx.try_recv().is_err(),
721 "heartbeat continued after tool completion"
722 );
723 }
724
725 #[tokio::test]
726 async fn full_event_channel_never_blocks_tool_heartbeat() {
727 let (tx, mut rx) = mpsc::channel(1);
728 tx.try_send(Event::status("filler")).expect("fill channel");
729
730 let result = tokio::time::timeout(Duration::from_secs(1), async {
731 let guard = ToolHeartbeatGuard::start(tx, TEST_HEARTBEAT_INTERVAL);
732 tokio::time::sleep(TEST_HEARTBEAT_INTERVAL * 3).await;
733 drop(guard);
734 "done"
735 })
736 .await
737 .expect("full event channel must not block tool completion");
738
739 assert_eq!(result, "done");
740 assert!(matches!(rx.recv().await, Some(Event::Status { .. })));
741 assert!(rx.try_recv().is_err(), "heartbeat displaced queued event");
742 }
743
744 #[tokio::test]
745 async fn terminal_guard_queues_resume_when_event_channel_is_full() {
746 let (tx, mut rx) = mpsc::channel(1);
747 tx.try_send(Event::status("filler")).expect("fill channel");
748
749 drop(InteractiveTerminalGuard { tx: Some(tx) });
750
751 assert!(matches!(rx.recv().await, Some(Event::Status { .. })));
752 let resumed = tokio::time::timeout(Duration::from_secs(1), rx.recv())
753 .await
754 .expect("queued resume event")
755 .expect("event channel still open");
756 assert!(matches!(resumed, Event::ResumeEvents));
757 }
758
759 #[tokio::test]
760 async fn terminal_guard_waits_for_pause_ack_before_returning() {
761 let (tx, mut rx) = mpsc::channel(4);
762 let task = tokio::spawn(InteractiveTerminalGuard::engage(tx, true));
763
764 let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
765 .await
766 .expect("pause event")
767 .expect("event channel still open");
768 let ack = match event {
769 Event::PauseEvents { ack: Some(ack) } => ack,
770 other => panic!("expected PauseEvents with ack, got {other:?}"),
771 };
772
773 tokio::task::yield_now().await;
774 assert!(!task.is_finished(), "guard returned before pause ack");
775
776 ack.notify_one();
777 let guard = tokio::time::timeout(Duration::from_secs(1), task)
778 .await
779 .expect("guard returned after ack")
780 .expect("guard task joined")
781 .expect("terminal handoff acknowledged");
782
783 drop(guard);
784 let resumed = tokio::time::timeout(Duration::from_secs(1), rx.recv())
785 .await
786 .expect("resume event")
787 .expect("event channel still open");
788 assert!(matches!(resumed, Event::ResumeEvents));
789 }
790
791 #[tokio::test]
792 async fn terminal_guard_refuses_child_and_queues_resume_when_pause_is_not_acknowledged() {
793 let (tx, mut rx) = mpsc::channel(4);
794 let task = tokio::spawn(InteractiveTerminalGuard::engage(tx, true));
795
796 let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
797 .await
798 .expect("pause event")
799 .expect("event channel still open");
800 let _unacknowledged_pause = match event {
801 Event::PauseEvents { ack: Some(ack) } => ack,
802 other => panic!("expected PauseEvents with ack, got {other:?}"),
803 };
804
805 let handoff = tokio::time::timeout(Duration::from_secs(2), task)
806 .await
807 .expect("guard refused child after pause timeout")
808 .expect("guard task joined");
809 let err = match handoff {
810 Ok(_) => panic!("unacknowledged terminal handoff must fail closed"),
811 Err(err) => err,
812 };
813 assert!(
814 err.to_string().contains("was not acknowledged"),
815 "unexpected handoff error: {err}"
816 );
817
818 let resumed = tokio::time::timeout(Duration::from_secs(1), rx.recv())
819 .await
820 .expect("queued resume event")
821 .expect("event channel still open");
822 assert!(matches!(resumed, Event::ResumeEvents));
823 }
824
825 #[tokio::test]
826 async fn terminal_guard_cancellation_during_pause_ack_still_queues_resume() {
827 let (tx, mut rx) = mpsc::channel(4);
828 let task = tokio::spawn(InteractiveTerminalGuard::engage(tx, true));
829
830 let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
831 .await
832 .expect("pause event")
833 .expect("event channel still open");
834 let _unacknowledged_pause = match event {
835 Event::PauseEvents { ack: Some(ack) } => ack,
836 other => panic!("expected PauseEvents with ack, got {other:?}"),
837 };
838
839 task.abort();
840 let cancelled = match task.await {
841 Ok(_) => panic!("engage future should be cancelled"),
842 Err(cancelled) => cancelled,
843 };
844 assert!(cancelled.is_cancelled());
845
846 let resumed = tokio::time::timeout(Duration::from_secs(1), rx.recv())
847 .await
848 .expect("queued resume event")
849 .expect("event channel still open");
850 assert!(matches!(resumed, Event::ResumeEvents));
851 }
852
853 #[cfg(unix)]
854 #[test]
855 fn inherited_interactive_shell_is_refused_before_terminal_handoff() {
856 for tool_name in ["bash", "Bash", "exec_shell"] {
857 let err = inherited_interactive_shell_refusal(tool_name, true)
858 .expect("Unix inherited-interactive shell must fail preflight");
859 assert!(
860 err.to_string().contains("foreground TTY ownership"),
861 "{tool_name}: {err}"
862 );
863 }
864 assert!(inherited_interactive_shell_refusal("Bash", false).is_none());
865 assert!(
866 inherited_interactive_shell_refusal(REQUEST_USER_INPUT_NAME, true).is_none(),
867 "user-input modal keeps its own terminal handoff"
868 );
869 }
870
871 #[test]
872 fn emit_tool_audit_to_path_writes_jsonl_lines() {
873 let tmp = tempfile::tempdir().expect("tempdir");
874 let path = tmp.path().join("audit.log");
875 let marker = path.display().to_string();
876
877 emit_tool_audit_to_path(
878 &path,
879 json!({
880 "event": "tool.spillover",
881 "test_marker": marker,
882 "tool_id": "call-abc",
883 "tool_name": "exec_shell",
884 "path": "/tmp/foo.txt",
885 }),
886 );
887 emit_tool_audit_to_path(
888 &path,
889 json!({
890 "event": "tool.result",
891 "test_marker": marker,
892 "tool_id": "call-xyz",
893 "success": true,
894 }),
895 );
896
897 let body = std::fs::read_to_string(&path).expect("audit log written");
898 let entries: Vec<serde_json::Value> = body
899 .lines()
900 .map(|line| serde_json::from_str(line).expect("audit line is JSON"))
901 .filter(|entry: &serde_json::Value| {
902 entry.get("test_marker").and_then(|v| v.as_str()) == Some(marker.as_str())
903 })
904 .collect();
905 assert_eq!(entries.len(), 2, "two marked emits -> two lines");
906
907 // Each line round-trips as JSON, has the expected event key.
908 let first = &entries[0];
909 assert_eq!(
910 first.get("event").and_then(|v| v.as_str()),
911 Some("tool.spillover")
912 );
913 assert_eq!(
914 first.get("tool_id").and_then(|v| v.as_str()),
915 Some("call-abc")
916 );
917
918 let second = &entries[1];
919 assert_eq!(
920 second.get("event").and_then(|v| v.as_str()),
921 Some("tool.result")
922 );
923 }
924
925 #[test]
926 fn emit_tool_audit_creates_parent_directory() {
927 let tmp = tempfile::tempdir().expect("tempdir");
928 // Path with a parent that doesn't exist yet — the writer
929 // should create it.
930 let nested = tmp.path().join("nested").join("dir").join("audit.log");
931 emit_tool_audit_to_path(&nested, json!({"event": "test"}));
932 assert!(nested.exists(), "writer should mkdir -p the parent chain");
933 }
934 }
935
935 lines RUST