返回 DeepSeek-TUI-2026
dispatch.rs
根目录 / crates / tui / src / core / engine / dispatch.rs
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
360 lines RUST