| 1 | //! Tool dispatch — plan/execute helpers for the per-turn tool batch. |
| 2 | //! |
| 3 | //! Extracted from `core/engine.rs` (P1.3). The high-level ordering still |
| 4 | //! lives in `Engine::handle_deepseek_turn`; this module owns: |
| 5 | //! |
| 6 | //! * Streaming-buffer parsing into a finalized `serde_json::Value` tool input |
| 7 | //! (`final_tool_input`, `parse_tool_input`, fenced/JSON segment helpers). |
| 8 | //! * The `multi_tool_use.parallel` payload parser. |
| 9 | //! * Policy predicates the turn loop consults — when a batch can run in |
| 10 | //! parallel, when an `update_plan` step should stop the turn, when a Plan |
| 11 | //! prompt should force a plan-first hop, and the small set of read-only |
| 12 | //! MCP tools that are safe to run in parallel. |
| 13 | //! * The tool execution plan/outcome types the batch driver passes around. |
| 14 | //! |
| 15 | //! All items are `pub(super)`-only: the public engine surface (Op/Event, |
| 16 | //! `EngineHandle`, `spawn_engine`) stays in `core/engine.rs`. |
| 17 | |
| 18 | use serde_json::json; |
| 19 | |
| 20 | use crate::models::{Tool, ToolCaller}; |
| 21 | use crate::tools::spec::{ToolError, ToolResult}; |
| 22 | use crate::tui::app::AppMode; |
| 23 | |
| 24 | use super::ToolUseState; |
| 25 | |
| 26 | // === Types ============================================================ |
| 27 | |
| 28 | #[allow(dead_code)] // `index` mirrors batch order for diagnostic ergonomics. |
| 29 | pub(super) struct ToolExecOutcome { |
| 30 | pub(super) index: usize, |
| 31 | pub(super) id: String, |
| 32 | pub(super) name: String, |
| 33 | pub(super) input: serde_json::Value, |
| 34 | pub(super) started_at: std::time::Instant, |
| 35 | pub(super) result: Result<ToolResult, ToolError>, |
| 36 | } |
| 37 | |
| 38 | #[derive(Debug, Clone)] |
| 39 | pub(super) struct ToolExecutionPlan { |
| 40 | pub(super) index: usize, |
| 41 | pub(super) id: String, |
| 42 | pub(super) name: String, |
| 43 | pub(super) input: serde_json::Value, |
| 44 | pub(super) caller: Option<ToolCaller>, |
| 45 | pub(super) interactive: bool, |
| 46 | pub(super) approval_required: bool, |
| 47 | pub(super) approval_description: String, |
| 48 | pub(super) supports_parallel: bool, |
| 49 | pub(super) read_only: bool, |
| 50 | pub(super) blocked_error: Option<ToolError>, |
| 51 | pub(super) guard_result: Option<ToolResult>, |
| 52 | } |
| 53 | |
| 54 | #[derive(Debug, serde::Serialize)] |
| 55 | pub(super) struct ParallelToolResultEntry { |
| 56 | pub(super) tool_name: String, |
| 57 | pub(super) success: bool, |
| 58 | pub(super) content: String, |
| 59 | #[serde(skip_serializing_if = "Option::is_none")] |
| 60 | pub(super) error: Option<String>, |
| 61 | } |
| 62 | |
| 63 | #[derive(Debug, serde::Serialize)] |
| 64 | pub(super) struct ParallelToolResult { |
| 65 | pub(super) results: Vec<ParallelToolResultEntry>, |
| 66 | } |
| 67 | |
| 68 | // Hold the lock guard for the duration of a tool execution. |
| 69 | // The inner guards are held for RAII purposes (dropped when the guard is dropped). |
| 70 | pub(super) enum ToolExecGuard<'a> { |
| 71 | Read(#[allow(dead_code)] tokio::sync::RwLockReadGuard<'a, ()>), |
| 72 | Write(#[allow(dead_code)] tokio::sync::RwLockWriteGuard<'a, ()>), |
| 73 | } |
| 74 | |
| 75 | // === Caller policy and errors ======================================== |
| 76 | |
| 77 | pub(super) fn caller_type_for_tool_use(caller: Option<&ToolCaller>) -> &str { |
| 78 | caller.map_or("direct", |c| c.caller_type.as_str()) |
| 79 | } |
| 80 | |
| 81 | pub(super) fn caller_allowed_for_tool( |
| 82 | caller: Option<&ToolCaller>, |
| 83 | tool_def: Option<&Tool>, |
| 84 | ) -> bool { |
| 85 | let requested = caller_type_for_tool_use(caller); |
| 86 | if let Some(def) = tool_def |
| 87 | && let Some(allowed) = &def.allowed_callers |
| 88 | { |
| 89 | if allowed.is_empty() { |
| 90 | return requested == "direct"; |
| 91 | } |
| 92 | return allowed.iter().any(|item| item == requested); |
| 93 | } |
| 94 | requested == "direct" |
| 95 | } |
| 96 | |
| 97 | pub(super) fn format_tool_error(err: &ToolError, tool_name: &str) -> String { |
| 98 | match err { |
| 99 | ToolError::InvalidInput { message } => { |
| 100 | format!("Invalid input for tool '{tool_name}': {message}") |
| 101 | } |
| 102 | ToolError::MissingField { field } => { |
| 103 | format!("Tool '{tool_name}' is missing required field '{field}'") |
| 104 | } |
| 105 | ToolError::PathEscape { path } => format!( |
| 106 | "Path escapes workspace: {}. Use a workspace-relative path or enable trust mode.", |
| 107 | path.display() |
| 108 | ), |
| 109 | ToolError::ExecutionFailed { message } => message.clone(), |
| 110 | ToolError::Timeout { seconds } => format!( |
| 111 | "Tool '{tool_name}' timed out after {seconds}s. Try a narrower scope or a longer timeout." |
| 112 | ), |
| 113 | ToolError::NotAvailable { message } => { |
| 114 | let lower = message.to_ascii_lowercase(); |
| 115 | if lower.contains("current tool catalog") || lower.contains("did you mean:") { |
| 116 | message.clone() |
| 117 | } else { |
| 118 | format!( |
| 119 | "Tool '{tool_name}' is not available: {message}. Check mode, feature flags, or tool name." |
| 120 | ) |
| 121 | } |
| 122 | } |
| 123 | ToolError::PermissionDenied { message } => format!( |
| 124 | "Tool '{tool_name}' was denied: {message}. Adjust approval mode or request permission." |
| 125 | ), |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // === Streaming-buffer parsing ========================================= |
| 130 | |
| 131 | /// Promote a streaming `ToolUseState` to a finalized JSON input. |
| 132 | /// |
| 133 | /// Order of preference: |
| 134 | /// |
| 135 | /// 1. `input_buffer` (the raw streamed delta concatenation) — parsed as |
| 136 | /// JSON. This is the most authoritative because it's what the model |
| 137 | /// actually emitted. |
| 138 | /// 2. `input` (the per-delta best-effort parse mirror) — used when the |
| 139 | /// buffer is empty (pre-streaming tool calls take this path). |
| 140 | /// 3. `input_buffer` non-empty but unparseable → fall back to `input` |
| 141 | /// (the per-delta parser has already mirrored the most recent valid |
| 142 | /// partial parse into `tool_state.input`). |
| 143 | pub(super) fn final_tool_input(state: &ToolUseState) -> serde_json::Value { |
| 144 | if !state.input_buffer.trim().is_empty() |
| 145 | && let Some(parsed) = parse_tool_input(&state.input_buffer) |
| 146 | { |
| 147 | return parsed; |
| 148 | } |
| 149 | state.input.clone() |
| 150 | } |
| 151 | |
| 152 | pub(super) fn parse_tool_input(buffer: &str) -> Option<serde_json::Value> { |
| 153 | let trimmed = buffer.trim(); |
| 154 | if trimmed.is_empty() { |
| 155 | return None; |
| 156 | } |
| 157 | // Try the deterministic arg-repair ladder first (handles trailing commas, |
| 158 | // unclosed braces, embedded control chars, etc.) |
| 159 | if let Ok(value) = crate::tools::arg_repair::repair(trimmed) { |
| 160 | return Some(value); |
| 161 | } |
| 162 | // Fall back to existing strategies for code-fenced, double-encoded, and |
| 163 | // segment-extraction patterns that the repair ladder doesn't cover. |
| 164 | if let Some(stripped) = strip_code_fences(trimmed) |
| 165 | && let Ok(value) = serde_json::from_str::<serde_json::Value>(&stripped) |
| 166 | { |
| 167 | return Some(value); |
| 168 | } |
| 169 | if let Ok(serde_json::Value::String(inner)) = serde_json::from_str::<serde_json::Value>(trimmed) |
| 170 | && let Ok(value) = serde_json::from_str::<serde_json::Value>(&inner) |
| 171 | { |
| 172 | return Some(value); |
| 173 | } |
| 174 | extract_json_segment(trimmed) |
| 175 | .and_then(|segment| serde_json::from_str::<serde_json::Value>(&segment).ok()) |
| 176 | } |
| 177 | |
| 178 | fn strip_code_fences(text: &str) -> Option<String> { |
| 179 | if !text.contains("```") { |
| 180 | return None; |
| 181 | } |
| 182 | let mut lines = Vec::new(); |
| 183 | for line in text.lines() { |
| 184 | if line.trim_start().starts_with("```") { |
| 185 | continue; |
| 186 | } |
| 187 | lines.push(line); |
| 188 | } |
| 189 | let stripped = lines.join("\n"); |
| 190 | let stripped = stripped.trim(); |
| 191 | if stripped.is_empty() { |
| 192 | None |
| 193 | } else { |
| 194 | Some(stripped.to_string()) |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | fn extract_json_segment(text: &str) -> Option<String> { |
| 199 | extract_balanced_segment(text, '{', '}').or_else(|| extract_balanced_segment(text, '[', ']')) |
| 200 | } |
| 201 | |
| 202 | fn extract_balanced_segment(text: &str, open: char, close: char) -> Option<String> { |
| 203 | let start = text.find(open)?; |
| 204 | let mut depth = 0i32; |
| 205 | let mut end = None; |
| 206 | for (offset, ch) in text[start..].char_indices() { |
| 207 | if ch == open { |
| 208 | depth += 1; |
| 209 | } else if ch == close { |
| 210 | depth -= 1; |
| 211 | if depth == 0 { |
| 212 | end = Some(start + offset + ch.len_utf8()); |
| 213 | break; |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | end.map(|end_idx| text[start..end_idx].to_string()) |
| 218 | } |
| 219 | |
| 220 | fn normalize_parallel_tool_name(raw: &str) -> String { |
| 221 | let mut name = raw.trim(); |
| 222 | for prefix in ["functions.", "tools.", "tool."] { |
| 223 | if let Some(stripped) = name.strip_prefix(prefix) { |
| 224 | name = stripped; |
| 225 | break; |
| 226 | } |
| 227 | } |
| 228 | name.to_string() |
| 229 | } |
| 230 | |
| 231 | pub(super) fn parse_parallel_tool_calls( |
| 232 | input: &serde_json::Value, |
| 233 | ) -> Result<Vec<(String, serde_json::Value)>, ToolError> { |
| 234 | let tool_uses = input |
| 235 | .get("tool_uses") |
| 236 | .and_then(|v| v.as_array()) |
| 237 | .ok_or_else(|| ToolError::missing_field("tool_uses"))?; |
| 238 | if tool_uses.is_empty() { |
| 239 | return Err(ToolError::invalid_input( |
| 240 | "multi_tool_use.parallel requires at least one tool call", |
| 241 | )); |
| 242 | } |
| 243 | |
| 244 | let mut calls = Vec::with_capacity(tool_uses.len()); |
| 245 | for item in tool_uses { |
| 246 | let name = item |
| 247 | .get("recipient_name") |
| 248 | .or_else(|| item.get("tool_name")) |
| 249 | .or_else(|| item.get("name")) |
| 250 | .or_else(|| item.get("tool")) |
| 251 | .and_then(|v| v.as_str()) |
| 252 | .ok_or_else(|| ToolError::missing_field("recipient_name"))?; |
| 253 | let params = item |
| 254 | .get("parameters") |
| 255 | .or_else(|| item.get("input")) |
| 256 | .or_else(|| item.get("args")) |
| 257 | .or_else(|| item.get("arguments")) |
| 258 | .cloned() |
| 259 | .unwrap_or_else(|| json!({})); |
| 260 | calls.push((normalize_parallel_tool_name(name), params)); |
| 261 | } |
| 262 | |
| 263 | Ok(calls) |
| 264 | } |
| 265 | |
| 266 | // === Dispatch policy ================================================== |
| 267 | |
| 268 | pub(super) fn should_parallelize_tool_batch(plans: &[ToolExecutionPlan]) -> bool { |
| 269 | !plans.is_empty() |
| 270 | && plans.iter().all(|plan| { |
| 271 | plan.read_only && plan.supports_parallel && !plan.approval_required && !plan.interactive |
| 272 | }) |
| 273 | } |
| 274 | |
| 275 | pub(super) fn should_stop_after_plan_tool( |
| 276 | mode: AppMode, |
| 277 | tool_name: &str, |
| 278 | result: &Result<ToolResult, ToolError>, |
| 279 | ) -> bool { |
| 280 | mode == AppMode::Plan && tool_name == "update_plan" && result.is_ok() |
| 281 | } |
| 282 | |
| 283 | pub(super) fn should_force_update_plan_first(mode: AppMode, content: &str) -> bool { |
| 284 | if mode != AppMode::Plan { |
| 285 | return false; |
| 286 | } |
| 287 | |
| 288 | let lower = content.to_ascii_lowercase(); |
| 289 | let asks_for_direct_plan = [ |
| 290 | "quick plan", |
| 291 | "short plan", |
| 292 | "simple plan", |
| 293 | "3-step plan", |
| 294 | "3 step plan", |
| 295 | "three-step plan", |
| 296 | "three step plan", |
| 297 | "high-level plan", |
| 298 | "high level plan", |
| 299 | "give me a plan", |
| 300 | "make a plan", |
| 301 | "outline a plan", |
| 302 | "draft a plan", |
| 303 | ] |
| 304 | .iter() |
| 305 | .any(|needle| lower.contains(needle)); |
| 306 | |
| 307 | if !asks_for_direct_plan { |
| 308 | return false; |
| 309 | } |
| 310 | |
| 311 | let asks_for_repo_exploration = [ |
| 312 | "inspect the repo", |
| 313 | "inspect the code", |
| 314 | "explore the repo", |
| 315 | "search the repo", |
| 316 | "read the code", |
| 317 | "review the code", |
| 318 | "analyze the code", |
| 319 | "investigate", |
| 320 | "look through", |
| 321 | "understand the current", |
| 322 | "ground it in the codebase", |
| 323 | "based on the codebase", |
| 324 | ] |
| 325 | .iter() |
| 326 | .any(|needle| lower.contains(needle)); |
| 327 | |
| 328 | !asks_for_repo_exploration |
| 329 | } |
| 330 | |
| 331 | pub(super) fn mcp_tool_is_parallel_safe(name: &str) -> bool { |
| 332 | matches!( |
| 333 | name, |
| 334 | "list_mcp_resources" |
| 335 | | "list_mcp_resource_templates" |
| 336 | | "mcp_read_resource" |
| 337 | | "read_mcp_resource" |
| 338 | | "mcp_get_prompt" |
| 339 | ) |
| 340 | } |
| 341 | |
| 342 | pub(super) fn mcp_tool_is_read_only(name: &str) -> bool { |
| 343 | matches!( |
| 344 | name, |
| 345 | "list_mcp_resources" |
| 346 | | "list_mcp_resource_templates" |
| 347 | | "mcp_read_resource" |
| 348 | | "read_mcp_resource" |
| 349 | | "mcp_get_prompt" |
| 350 | ) |
| 351 | } |
| 352 | |
| 353 | pub(super) fn mcp_tool_approval_description(name: &str) -> String { |
| 354 | if mcp_tool_is_read_only(name) { |
| 355 | format!("Read-only MCP tool '{name}'") |
| 356 | } else { |
| 357 | format!("MCP tool '{name}' may have side effects") |
| 358 | } |
| 359 | } |
| 360 |