返回 CodeWhale
tool_catalog.rs
根目录 / crates / tui / src / core / engine / tool_catalog.rs
1 //! Deferred tool catalog and built-in advanced tool helpers.
2 //!
3 //! The streaming turn loop owns when tools are offered or executed. This module
4 //! owns the catalog-level policy around deferred loading, tool search, missing
5 //! tool suggestions, and the small set of built-in advanced tools that are not
6 //! registered by the normal runtime tool registry.
7
8 use std::collections::HashSet;
9 use std::path::Path;
10 use std::time::Duration;
11
12 use serde_json::{Value, json};
13
14 #[cfg(test)]
15 use crate::mcp::McpPool;
16 use crate::model_profile::ToolSurfaceBudget;
17 use crate::models::Tool;
18 use crate::tools::spec::{ToolError, ToolResult, optional_str, optional_u64, required_str};
19 use crate::tui::app::AppMode;
20
21 use crate::dependencies::ExternalTool;
22 use crate::regex_cache::compile_user_regex;
23
24 pub(super) const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel";
25 pub(super) const REQUEST_USER_INPUT_NAME: &str = "request_user_input";
26 pub(super) const CODE_EXECUTION_TOOL_NAME: &str = "code_execution";
27 const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825";
28 const CODE_EXECUTION_DESCRIPTION: &str = "Execute Python code with the local Python interpreter in the workspace and return stdout/stderr/return_code as JSON.";
29 pub(super) use crate::tools::js_execution::JS_EXECUTION_TOOL_NAME;
30 pub(super) const TOOL_SEARCH_NAME: &str = "tool_search";
31 const TOOL_SEARCH_TYPE: &str = "tool_search_20251119";
32 const LEGACY_TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex";
33 const LEGACY_TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25";
34 const TOOL_SEARCH_DEFAULT_MAX_RESULTS: usize = 20;
35 const TOOL_SEARCH_MAX_RESULTS_LIMIT: usize = 100;
36
37 pub(super) fn is_tool_search_tool(name: &str) -> bool {
38 matches!(
39 name,
40 TOOL_SEARCH_NAME | LEGACY_TOOL_SEARCH_REGEX_NAME | LEGACY_TOOL_SEARCH_BM25_NAME
41 )
42 }
43
44 // Crate-visible rather than `pub(super)` so the hook gate's classifier test can
45 // assert it still recognises every default-active name. Without that anchor the
46 // test pins hardcoded strings and stays green through a tool rename, while the
47 // gate silently reclassifies the renamed tool as unknown.
48 pub(crate) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[
49 // #4625: the model-facing shell tool is `Bash`; legacy `exec_shell*`
50 // names are hidden compat aliases and must not be default-active.
51 "Bash",
52 "File",
53 "Git",
54 "Run",
55 "agent",
56 // Skills use a bounded ambient index. Keep the tiny discovery/load schema
57 // active so omitted skills remain one-call discoverable on the first turn.
58 "load_skill",
59 "remember",
60 // Piagent phase B: the model-facing durable-task tool is `tasks`; the
61 // legacy `task_create`/`task_list`/`task_read` names it replaces are
62 // hidden compat aliases and must not be default-active.
63 "tasks",
64 "work_update",
65 ];
66
67 const CORE_ACTION_TOOL_FALLBACKS: &[CoreActionToolFallback] = &[
68 CoreActionToolFallback {
69 name: "Bash",
70 description: "Run shell commands in the workspace.",
71 unavailable_reason: "Not present in the current model-visible catalog. Interactive Agent sessions expose shell by default unless allow_shell = false; noninteractive and durable profiles require allow_shell = true. Plan mode hides shell, and command tool allow/deny gates can also block it.",
72 },
73 CoreActionToolFallback {
74 name: "File",
75 description: "Read, search, and modify workspace files.",
76 unavailable_reason: "Not present in the current model-visible catalog. File reads are available in Plan and Agent modes; write and edit actions require an executable mode, while patch also requires the apply_patch feature.",
77 },
78 ];
79
80 #[derive(Debug, Clone, Copy)]
81 struct CoreActionToolFallback {
82 name: &'static str,
83 description: &'static str,
84 unavailable_reason: &'static str,
85 }
86
87 /// Pre-computed lowercased haystack + name for each fallback; built once.
88 struct CachedFallback {
89 fallback: CoreActionToolFallback,
90 haystack: String,
91 name_lower: String,
92 }
93
94 static CACHED_FALLBACKS: std::sync::OnceLock<Vec<CachedFallback>> = std::sync::OnceLock::new();
95
96 fn cached_fallbacks() -> &'static [CachedFallback] {
97 CACHED_FALLBACKS.get_or_init(|| {
98 CORE_ACTION_TOOL_FALLBACKS
99 .iter()
100 .map(|f| CachedFallback {
101 fallback: *f,
102 haystack: format!(
103 "{}\n{}\n{}",
104 f.name.to_lowercase(),
105 f.description.to_lowercase(),
106 f.unavailable_reason.to_lowercase(),
107 ),
108 name_lower: f.name.to_lowercase(),
109 })
110 .collect()
111 })
112 }
113
114 /// Membership index over [`DEFAULT_ACTIVE_NATIVE_TOOLS`], built once for the
115 /// process lifetime. The array stays the source of truth for ordered
116 /// inspection; this set only accelerates the
117 /// hot membership check in [`should_default_defer_tool`], which runs once per
118 /// catalog tool on every catalog rebuild (i.e. per turn) — an O(n·m) linear
119 /// scan over the array collapses to O(1) hashed lookups.
120 static DEFAULT_ACTIVE_NATIVE_TOOLS_SET: std::sync::OnceLock<HashSet<&'static str>> =
121 std::sync::OnceLock::new();
122
123 fn default_active_native_tools_set() -> &'static HashSet<&'static str> {
124 DEFAULT_ACTIVE_NATIVE_TOOLS_SET
125 .get_or_init(|| DEFAULT_ACTIVE_NATIVE_TOOLS.iter().copied().collect())
126 }
127
128 pub(super) fn should_default_defer_tool(name: &str, always_load: &HashSet<String>) -> bool {
129 if always_load.contains(name) {
130 return false;
131 }
132
133 if is_tool_search_tool(name) {
134 return false;
135 }
136
137 // Membership-only test (no ordering dependency): the side set built from
138 // DEFAULT_ACTIVE_NATIVE_TOOLS returns identical hit/miss results as the
139 // former `.iter().any(...)` linear scan.
140 !default_active_native_tools_set().contains(name)
141 }
142
143 pub(super) fn apply_native_tool_deferral(catalog: &mut [Tool], always_load: &HashSet<String>) {
144 for tool in catalog {
145 tool.defer_loading = Some(should_default_defer_tool(&tool.name, always_load));
146 }
147 }
148
149 fn should_keep_mcp_tool_loaded(name: &str) -> bool {
150 matches!(
151 name,
152 "list_mcp_resources"
153 | "list_mcp_resource_templates"
154 | "mcp_read_resource"
155 | "read_mcp_resource"
156 | "mcp_get_prompt"
157 )
158 }
159
160 pub(super) fn apply_mcp_tool_deferral(
161 catalog: &mut [Tool],
162 mode: AppMode,
163 always_load: &HashSet<String>,
164 ) {
165 for tool in catalog {
166 if always_load.contains(&tool.name) {
167 tool.defer_loading = Some(false);
168 continue;
169 }
170 tool.defer_loading =
171 Some(mode != AppMode::Yolo && !should_keep_mcp_tool_loaded(&tool.name));
172 }
173 }
174
175 /// Build the model tool catalog from native and MCP tool lists.
176 ///
177 /// **Catalog-head stability invariant.** The head of the catalog (all
178 /// non-deferred tools) must remain byte-identical across mode toggles
179 /// (Plan ↔ Agent ↔ YOLO) for tools that are common to both modes.
180 /// Deferred tool activations append to the tail and never reorder the
181 /// head. This invariant is critical for DeepSeek's KV prefix cache:
182 /// the tools array is part of the immutable prefix, and any byte-level
183 /// change in the head forces a full re-prefill on the next turn.
184 #[cfg(test)]
185 pub(super) fn build_model_tool_catalog(
186 native_tools: Vec<Tool>,
187 mcp_tools: Vec<Tool>,
188 mode: AppMode,
189 always_load: &HashSet<String>,
190 ) -> Vec<Tool> {
191 build_model_tool_catalog_with_surface(
192 native_tools,
193 mcp_tools,
194 mode,
195 always_load,
196 ToolSurfaceBudget::Standard,
197 )
198 }
199
200 pub(super) fn build_model_tool_catalog_with_surface(
201 mut native_tools: Vec<Tool>,
202 mut mcp_tools: Vec<Tool>,
203 mode: AppMode,
204 always_load: &HashSet<String>,
205 surface_budget: ToolSurfaceBudget,
206 ) -> Vec<Tool> {
207 apply_native_tool_deferral(&mut native_tools, always_load);
208 apply_mcp_tool_deferral(&mut mcp_tools, mode, always_load);
209 apply_tool_surface_budget(&mut native_tools, surface_budget, always_load);
210 apply_tool_surface_budget(&mut mcp_tools, surface_budget, always_load);
211 // Sort each partition by name for prefix-cache stability (#263). The
212 // upstream `to_api_tools()` already sorts the registry's HashMap output;
213 // this catalog is built from caller-supplied Vecs which the test harness
214 // and (future) caller refactors may not pre-sort. Built-ins stay as a
215 // contiguous prefix ahead of MCP tools so adding/removing an MCP tool
216 // never shifts a built-in's position.
217 native_tools.sort_by(|a, b| a.name.cmp(&b.name));
218 mcp_tools.sort_by(|a, b| a.name.cmp(&b.name));
219 native_tools.extend(mcp_tools);
220 native_tools
221 }
222
223 const REGISTRY_FIRST_SHELL_GUIDANCE: &str = "Before using this tool for a task whose core operation is a specialized capability (for example media or document conversion, data transformation, browser automation, database or service access, or a developer utility), call registry_sync first. If its complete catalog contains any plausible match, call start_registry_mcp_server and inspect the connected tools before using a shell alternative. Use the shell directly for ordinary repo-native work and simple file operations, or after every Registry entry is clearly irrelevant or the matching server fails to start.";
224
225 /// Put the Registry-first decision at the point where the model considers its
226 /// strongest fallback. The discovery skill body is lazy-loaded, so relying on
227 /// it alone creates a loop: the model must already prefer discovery before it
228 /// can read the instruction that tells it to prefer discovery.
229 ///
230 /// This is applied only while MCP is enabled. It changes no dispatch order and
231 /// performs no task matching in the host; the model still compares the user's
232 /// context against the Registry catalog itself.
233 pub(super) fn apply_registry_first_shell_guidance(catalog: &mut [Tool]) {
234 let Some(shell) = catalog.iter_mut().find(|tool| tool.name == "exec_shell") else {
235 return;
236 };
237 if shell.description.contains(REGISTRY_FIRST_SHELL_GUIDANCE) {
238 return;
239 }
240 if !shell.description.ends_with(char::is_whitespace) {
241 shell.description.push(' ');
242 }
243 shell.description.push_str(REGISTRY_FIRST_SHELL_GUIDANCE);
244 }
245
246 fn apply_tool_surface_budget(
247 catalog: &mut [Tool],
248 surface_budget: ToolSurfaceBudget,
249 always_load: &HashSet<String>,
250 ) {
251 if !matches!(surface_budget, ToolSurfaceBudget::Compact) {
252 return;
253 }
254 for tool in catalog {
255 if always_load.contains(&tool.name) {
256 continue;
257 }
258 if matches!(tool.name.as_str(), "agent" | "Run" | "tasks" | "Web") {
259 tool.defer_loading = Some(true);
260 }
261 }
262 }
263
264 /// Whether two tool-surface budgets currently produce the same catalog.
265 ///
266 /// Runs [`apply_tool_surface_budget`] over `catalog` under both budgets and
267 /// compares the results. `/preview-request` publishes the Standard-vs-Full
268 /// answer as a derived field so the truthful "these are currently collapsed"
269 /// disclosure cannot drift from the code: the day the shaper narrows Standard
270 /// differently from Full, this starts returning `false` on its own.
271 pub(super) fn surface_budgets_produce_same_catalog(
272 catalog: &[Tool],
273 always_load: &HashSet<String>,
274 left_budget: ToolSurfaceBudget,
275 right_budget: ToolSurfaceBudget,
276 ) -> bool {
277 let mut left = catalog.to_vec();
278 let mut right = catalog.to_vec();
279 apply_tool_surface_budget(&mut left, left_budget, always_load);
280 apply_tool_surface_budget(&mut right, right_budget, always_load);
281 serde_json::to_string(&left).ok() == serde_json::to_string(&right).ok()
282 }
283
284 pub(super) fn ensure_advanced_tooling(
285 catalog: &mut Vec<Tool>,
286 mode: AppMode,
287 always_load: &HashSet<String>,
288 ) {
289 // code_execution depends on a locally-installed Python interpreter
290 // (python3 / python / py -3). Before v0.8.31, the tool was always
291 // advertised and would fail at execution time on Windows where
292 // `python3` isn't on PATH — the model treated the tool as reliable
293 // once it appeared in the catalog. We now probe at catalog-build
294 // time and only advertise when an interpreter resolves. See
295 // `crate::dependencies::resolve_python_interpreter` for the probe.
296 if mode != AppMode::Plan
297 && !catalog.iter().any(|t| t.name == CODE_EXECUTION_TOOL_NAME)
298 && crate::dependencies::resolve_python_interpreter().is_some()
299 {
300 catalog.push(Tool {
301 tool_type: Some(CODE_EXECUTION_TOOL_TYPE.to_string()),
302 name: CODE_EXECUTION_TOOL_NAME.to_string(),
303 description: CODE_EXECUTION_DESCRIPTION.to_string(),
304 input_schema: json!({
305 "type": "object",
306 "properties": {
307 "code": { "type": "string", "description": "Python source code to execute." }
308 },
309 "required": ["code"]
310 }),
311 allowed_callers: Some(vec!["direct".to_string()]),
312 defer_loading: Some(should_default_defer_tool(
313 CODE_EXECUTION_TOOL_NAME,
314 always_load,
315 )),
316 input_examples: None,
317 strict: None,
318 cache_control: None,
319 });
320 }
321
322 // js_execution mirrors code_execution: gate on Node.js being
323 // present locally so the model never sees a runtime it can't
324 // actually use. Plan mode hides shell/exec surfaces (including
325 // both interpreter tools) by construction; Agent / YOLO advertise
326 // the tool only when `resolve_node()` succeeds.
327 if mode != AppMode::Plan
328 && !catalog.iter().any(|t| t.name == JS_EXECUTION_TOOL_NAME)
329 && crate::dependencies::resolve_node().is_some()
330 {
331 let mut tool = crate::tools::js_execution::js_execution_tool_definition();
332 tool.defer_loading = Some(should_default_defer_tool(&tool.name, always_load));
333 catalog.push(tool);
334 }
335
336 if !catalog.iter().any(|t| t.name == TOOL_SEARCH_NAME) {
337 catalog.push(Tool {
338 tool_type: Some(TOOL_SEARCH_TYPE.to_string()),
339 name: TOOL_SEARCH_NAME.to_string(),
340 description: "Search deferred tool definitions and return matching tool references.".to_string(),
341 input_schema: json!({
342 "type": "object",
343 "properties": {
344 "query": { "type": "string", "description": "Search query for tool discovery." },
345 "match": {
346 "type": "string",
347 "enum": ["bm25", "regex"],
348 "default": "bm25",
349 "description": "Matching algorithm: bm25 for natural-language matching, regex for a regular expression over tool names/descriptions/schema."
350 },
351 "max_results": {
352 "type": "integer",
353 "minimum": 1,
354 "maximum": TOOL_SEARCH_MAX_RESULTS_LIMIT,
355 "default": TOOL_SEARCH_DEFAULT_MAX_RESULTS,
356 "description": "Maximum number of matching tool references to return."
357 }
358 },
359 "required": ["query"]
360 }),
361 allowed_callers: Some(vec!["direct".to_string()]),
362 defer_loading: Some(false),
363 input_examples: None,
364 strict: None,
365 cache_control: None,
366 });
367 }
368 }
369
370 pub(super) fn initial_active_tools(catalog: &[Tool]) -> HashSet<String> {
371 let mut active = HashSet::new();
372 for tool in catalog {
373 if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) {
374 active.insert(tool.name.clone());
375 }
376 }
377 if active.is_empty()
378 && !catalog.is_empty()
379 && let Some(first) = catalog.first()
380 {
381 active.insert(first.name.clone());
382 }
383 active
384 }
385
386 fn active_tool_list_from_catalog(catalog: &[Tool], active: &HashSet<String>) -> Vec<Tool> {
387 // Two-pass for prefix-cache stability (#263). Always-loaded tools come
388 // first in their stable catalog order; tools that started life deferred
389 // and were activated mid-conversation by ToolSearch get appended at the
390 // tail. Otherwise activating a deferred tool shifts every later tool's
391 // byte offset and busts the cached prefix from that point onwards.
392 let catalog_len = catalog.len();
393 let mut head: Vec<Tool> = Vec::with_capacity(catalog_len);
394 let mut tail: Vec<Tool> = Vec::with_capacity(catalog_len);
395 for tool in catalog {
396 if !active.contains(&tool.name) {
397 continue;
398 }
399 if tool.defer_loading.unwrap_or(false) {
400 tail.push(tool.clone());
401 } else {
402 head.push(tool.clone());
403 }
404 }
405 head.extend(tail);
406 head
407 }
408
409 pub(super) fn active_tools_for_step(catalog: &[Tool], active: &HashSet<String>) -> Vec<Tool> {
410 active_tool_list_from_catalog(catalog, active)
411 }
412
413 /// One turn's executable and model-visible tool contract.
414 ///
415 /// The catalog is also the prompt's only availability taxonomy: stable prompt
416 /// prose deliberately does not enumerate tool names. Keeping the concrete
417 /// registry, searchable catalog, initial request subset, and command gates in
418 /// one value prevents preview, dispatch, and execution from reconstructing
419 /// different surfaces from mutable engine configuration.
420 pub(super) struct ToolSurfacePolicy {
421 /// Runtime registry that executes native and plugin tools.
422 pub(super) registry: crate::tools::ToolRegistry,
423 /// Full model-facing catalog, including deferred entries.
424 pub(super) catalog: Vec<Tool>,
425 /// Names active at the start of the turn.
426 pub(super) active_names: HashSet<String>,
427 /// Exact initial `tools` field. `None` means no field is sent.
428 pub(super) active: Option<Vec<Tool>>,
429 pub(super) mode: AppMode,
430 pub(super) strict_tool_mode: bool,
431 allowed_tools: Option<Vec<String>>,
432 disallowed_tools: Option<Vec<String>>,
433 /// Hard per-turn cap on admitted tool calls (#4415). The turn loop copies
434 /// this limit into its own admission counter at turn start; `None` means
435 /// unlimited (the default), which keeps the admission gate inert.
436 pub(super) max_tool_calls: Option<u32>,
437 questions_allowed: bool,
438 }
439
440 impl ToolSurfacePolicy {
441 #[allow(clippy::too_many_arguments)]
442 pub(super) fn new(
443 registry: crate::tools::ToolRegistry,
444 tools: Option<Vec<Tool>>,
445 mode: AppMode,
446 always_load: &HashSet<String>,
447 dynamic_active_tools: &[&'static str],
448 strict_tool_mode: bool,
449 allowed_tools: Option<Vec<String>>,
450 disallowed_tools: Option<Vec<String>>,
451 max_tool_calls: Option<u32>,
452 approval_mode: crate::tui::approval::ApprovalMode,
453 ) -> Self {
454 let mut catalog = tools.unwrap_or_default();
455 if !catalog.is_empty() {
456 ensure_advanced_tooling(&mut catalog, mode, always_load);
457 }
458
459 // Synthetic tools are injected before narrowing. Doing this after the
460 // retain would re-advertise tool_search/code execution despite an
461 // explicit command gate.
462 catalog.retain(|tool| {
463 !tool_denied(disallowed_tools.as_deref(), &tool.name)
464 && tool_allowed(allowed_tools.as_deref(), &tool.name)
465 });
466 let questions_allowed =
467 super::super::authority::permission_posture_allows_questions(approval_mode);
468 if !questions_allowed {
469 catalog.retain(|tool| tool.name != REQUEST_USER_INPUT_NAME);
470 }
471
472 let mut active_names = initial_active_tools(&catalog);
473 active_names.extend(dynamic_active_tools.iter().map(|name| (*name).to_string()));
474 active_names.retain(|name| catalog.iter().any(|tool| tool.name == *name));
475 let active = active_tools_for_request(&catalog, &active_names, strict_tool_mode);
476
477 Self {
478 registry,
479 catalog,
480 active_names,
481 active,
482 mode,
483 strict_tool_mode,
484 allowed_tools,
485 disallowed_tools,
486 max_tool_calls,
487 questions_allowed,
488 }
489 }
490
491 #[cfg(test)]
492 pub(super) fn allows_tool(&self, name: &str) -> bool {
493 !self.denies_tool(name) && self.passes_allow_list(name)
494 }
495
496 pub(super) fn passes_allow_list(&self, name: &str) -> bool {
497 tool_allowed(self.allowed_tools.as_deref(), name)
498 }
499
500 pub(super) fn denies_tool(&self, name: &str) -> bool {
501 tool_denied(self.disallowed_tools.as_deref(), name)
502 }
503
504 pub(super) fn allows_questions(&self) -> bool {
505 self.questions_allowed
506 }
507 }
508
509 pub(super) fn tool_allowed(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
510 let Some(allowed_tools) = allowed_tools else {
511 return true;
512 };
513 tool_matches_any_rule(allowed_tools, tool_name)
514 }
515
516 pub(super) fn tool_denied(disallowed_tools: Option<&[String]>, tool_name: &str) -> bool {
517 disallowed_tools.is_some_and(|rules| tool_matches_any_rule(rules, tool_name))
518 }
519
520 fn tool_matches_any_rule(rules: &[String], tool_name: &str) -> bool {
521 let tool_name = tool_name.to_ascii_lowercase();
522 rules.iter().any(|rule| {
523 let rule = rule.to_ascii_lowercase();
524 rule.strip_suffix('*')
525 .map_or_else(|| tool_name == rule, |prefix| tool_name.starts_with(prefix))
526 })
527 }
528
529 /// The `tools` field of one outbound request, from a catalog and the set of
530 /// currently-active tool names.
531 ///
532 /// Shared by [`ToolSurfacePolicy`] and the per-step rebuild inside the turn
533 /// loop, so activating a deferred tool mid-turn goes through one code path.
534 pub(super) fn active_tools_for_request(
535 catalog: &[Tool],
536 active: &HashSet<String>,
537 strict_tool_mode: bool,
538 ) -> Option<Vec<Tool>> {
539 if catalog.is_empty() {
540 return None;
541 }
542 let mut tools = active_tools_for_step(catalog, active);
543 if strict_tool_mode {
544 crate::tools::schema_sanitize::prepare_tools_for_strict_mode(&mut tools);
545 }
546 Some(tools)
547 }
548
549 fn tool_search_haystack(tool: &Tool) -> String {
550 format!(
551 "{}\n{}\n{}",
552 tool.name.to_lowercase(),
553 tool.description.to_lowercase(),
554 tool.input_schema.to_string().to_lowercase()
555 )
556 }
557
558 fn catalog_contains_tool(catalog: &[Tool], name: &str) -> bool {
559 catalog.iter().any(|tool| tool.name == name)
560 }
561
562 fn unavailable_core_action_tools_with_regex(
563 catalog: &[Tool],
564 query: &str,
565 max_results: usize,
566 ) -> Result<Vec<CoreActionToolFallback>, ToolError> {
567 if max_results == 0 {
568 return Ok(Vec::new());
569 }
570 let regex = compile_user_regex(query)
571 .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?;
572 Ok(cached_fallbacks()
573 .iter()
574 .filter(|cf| !catalog_contains_tool(catalog, cf.fallback.name))
575 .filter(|cf| regex.is_match(&cf.haystack))
576 .take(max_results)
577 .map(|cf| cf.fallback)
578 .collect())
579 }
580
581 fn unavailable_core_action_tools_with_bm25_like(
582 catalog: &[Tool],
583 query: &str,
584 max_results: usize,
585 ) -> Vec<CoreActionToolFallback> {
586 if max_results == 0 {
587 return Vec::new();
588 }
589 let terms: Vec<String> = query
590 .split_whitespace()
591 .map(|term| term.trim().to_lowercase())
592 .filter(|term| !term.is_empty())
593 .collect();
594 if terms.is_empty() {
595 return Vec::new();
596 }
597
598 let mut scored: Vec<(i64, CoreActionToolFallback)> = Vec::new();
599 for cf in cached_fallbacks() {
600 if catalog_contains_tool(catalog, cf.fallback.name) {
601 continue;
602 }
603 let hay = &cf.haystack;
604 let name = &cf.name_lower;
605 let mut score = 0i64;
606 for term in &terms {
607 if hay.contains(term) {
608 score += 1;
609 }
610 if name.contains(term) {
611 score += 2;
612 }
613 }
614 if score > 0 {
615 scored.push((score, cf.fallback));
616 }
617 }
618 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(b.1.name)));
619 scored
620 .into_iter()
621 .take(max_results)
622 .map(|(_, fallback)| fallback)
623 .collect()
624 }
625
626 fn discover_tools_with_regex(
627 catalog: &[Tool],
628 query: &str,
629 max_results: usize,
630 ) -> Result<Vec<String>, ToolError> {
631 let regex = compile_user_regex(query)
632 .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?;
633
634 let mut matches = Vec::new();
635 for tool in catalog {
636 if is_tool_search_tool(&tool.name) {
637 continue;
638 }
639 let hay = tool_search_haystack(tool);
640 if regex.is_match(&hay) {
641 matches.push(tool.name.clone());
642 }
643 if matches.len() >= max_results {
644 break;
645 }
646 }
647 Ok(matches)
648 }
649
650 fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str, max_results: usize) -> Vec<String> {
651 let terms: Vec<String> = query
652 .split_whitespace()
653 .map(|term| term.trim().to_lowercase())
654 .filter(|term| !term.is_empty())
655 .collect();
656 if terms.is_empty() {
657 return Vec::new();
658 }
659
660 let mut scored: Vec<(i64, String)> = Vec::new();
661 for tool in catalog {
662 if is_tool_search_tool(&tool.name) {
663 continue;
664 }
665 let hay = tool_search_haystack(tool);
666 let mut score = 0i64;
667 for term in &terms {
668 if hay.contains(term) {
669 score += 1;
670 }
671 if tool.name.to_lowercase().contains(term) {
672 score += 2;
673 }
674 }
675 if score > 0 {
676 scored.push((score, tool.name.clone()));
677 }
678 }
679 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
680 scored
681 .into_iter()
682 .take(max_results)
683 .map(|(_, name)| name)
684 .collect()
685 }
686
687 fn edit_distance(a: &str, b: &str) -> usize {
688 if a == b {
689 return 0;
690 }
691 if a.is_empty() {
692 return b.chars().count();
693 }
694 if b.is_empty() {
695 return a.chars().count();
696 }
697
698 let b_chars: Vec<char> = b.chars().collect();
699 let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
700 let mut curr = vec![0usize; b_chars.len() + 1];
701
702 for (i, a_ch) in a.chars().enumerate() {
703 curr[0] = i + 1;
704 for (j, b_ch) in b_chars.iter().enumerate() {
705 let cost = if a_ch == *b_ch { 0 } else { 1 };
706 let delete = prev[j + 1] + 1;
707 let insert = curr[j] + 1;
708 let substitute = prev[j] + cost;
709 curr[j + 1] = delete.min(insert).min(substitute);
710 }
711 std::mem::swap(&mut prev, &mut curr);
712 }
713
714 prev[b_chars.len()]
715 }
716
717 fn suggest_tool_names(catalog: &[Tool], requested: &str, limit: usize) -> Vec<String> {
718 let requested = requested.trim().to_ascii_lowercase();
719 if requested.is_empty() || limit == 0 {
720 return Vec::new();
721 }
722
723 let mut candidates: Vec<(u8, usize, String)> = Vec::new();
724 for tool in catalog {
725 let candidate = tool.name.to_ascii_lowercase();
726 let prefix_match = candidate.starts_with(&requested) || requested.starts_with(&candidate);
727 let contains_match = candidate.contains(&requested) || requested.contains(&candidate);
728 let distance = edit_distance(&candidate, &requested);
729 let close_typo = distance <= 3;
730
731 if !(prefix_match || contains_match || close_typo) {
732 continue;
733 }
734
735 let rank = if prefix_match {
736 0
737 } else if contains_match {
738 1
739 } else {
740 2
741 };
742 candidates.push((rank, distance, tool.name.clone()));
743 }
744
745 candidates.sort_by(|a, b| {
746 a.0.cmp(&b.0)
747 .then_with(|| a.1.cmp(&b.1))
748 .then_with(|| a.2.cmp(&b.2))
749 });
750 candidates.dedup_by(|a, b| a.2 == b.2);
751 candidates
752 .into_iter()
753 .take(limit)
754 .map(|(_, _, name)| name)
755 .collect()
756 }
757
758 /// Catalog tools the engine injects itself rather than registering, plus the
759 /// legacy tool-search spellings. Exposed so the read-only request projection
760 /// can label their provenance as `synthetic` from the same source of truth as
761 /// `is_synthetic_catalog_tool` test coverage instead of guessing.
762 ///
763 /// MCP-contributed names are deliberately *not* here: those resolve through the
764 /// real pool, and stay unknown when the pool did not resolve them.
765 /// [`MULTI_TOOL_PARALLEL_NAME`] is not here either — it is a call name the model
766 /// may emit, never a catalog entry, so it can never appear in a transmitted
767 /// tool array and has no catalog provenance to report.
768 pub(super) fn default_synthetic_catalog_tool_names() -> Vec<String> {
769 let mut names: Vec<String> = vec![
770 TOOL_SEARCH_NAME.to_string(),
771 LEGACY_TOOL_SEARCH_REGEX_NAME.to_string(),
772 LEGACY_TOOL_SEARCH_BM25_NAME.to_string(),
773 CODE_EXECUTION_TOOL_NAME.to_string(),
774 JS_EXECUTION_TOOL_NAME.to_string(),
775 ];
776 names.sort();
777 names.dedup();
778 names
779 }
780
781 #[cfg(test)]
782 fn is_synthetic_catalog_tool(name: &str) -> bool {
783 is_tool_search_tool(name)
784 || matches!(name, CODE_EXECUTION_TOOL_NAME | JS_EXECUTION_TOOL_NAME)
785 || McpPool::is_mcp_tool(name)
786 }
787
788 #[cfg(test)]
789 pub(super) fn tool_catalog_consistency_issues(
790 catalog: &[Tool],
791 registry: &crate::tools::ToolRegistry,
792 ) -> Vec<String> {
793 let catalog_names = catalog
794 .iter()
795 .map(|tool| tool.name.as_str())
796 .collect::<HashSet<_>>();
797 let registry_api_tools = registry.to_api_tools();
798 let registry_model_visible_names = registry_api_tools
799 .iter()
800 .map(|tool| tool.name.as_str())
801 .collect::<HashSet<_>>();
802 let mut issues = Vec::new();
803
804 for tool in catalog {
805 if is_synthetic_catalog_tool(&tool.name) {
806 continue;
807 }
808 if !registry.contains(&tool.name) {
809 issues.push(format!(
810 "catalog advertises '{}' but no registered handler exists",
811 tool.name
812 ));
813 }
814 }
815
816 for name in DEFAULT_ACTIVE_NATIVE_TOOLS {
817 if registry_model_visible_names.contains(name) && !catalog_names.contains(name) {
818 issues.push(format!(
819 "registered core tool '{name}' is missing from the model/search catalog"
820 ));
821 }
822 }
823
824 issues.sort();
825 issues
826 }
827
828 pub(super) fn missing_tool_error_message(tool_name: &str, catalog: &[Tool]) -> String {
829 // Dogfood A5 (#4092): models mid-checklist sometimes emit each list entry
830 // as its own tool call named `item`/`todo`/... . Fuzzy suggestions are
831 // actively misleading there ("Did you mean: note, tts?"); name the actual
832 // fix instead.
833 if matches!(
834 tool_name,
835 "item" | "items" | "todo" | "todos" | "checklist" | "checklist_item" | "plan_item"
836 ) {
837 return format!(
838 "Tool '{tool_name}' is not available in the current tool catalog. \
839 Checklist entries are not separate tool calls — write the whole list \
840 in one `work_update` call with a `todos` array of \
841 {{content, status}} objects."
842 );
843 }
844 let suggestions = suggest_tool_names(catalog, tool_name, 3);
845 let shell_hint = if is_shell_tool_name(tool_name) {
846 Some(shell_tool_allow_shell_hint())
847 } else {
848 None
849 };
850 // #5123-class: `exec_shell` was renamed to `Bash`. Name the rename first —
851 // otherwise the error misdiagnoses a retired-name call as an allow_shell
852 // permission problem and sends the model fixing the wrong thing.
853 let renamed_action = match tool_name {
854 "exec_shell" => Some("run"),
855 "exec_shell_wait" => Some("wait"),
856 "exec_shell_interact" => Some("interact"),
857 "exec_shell_cancel" => Some("cancel"),
858 _ => None,
859 };
860 if let Some(action) = renamed_action {
861 return format!(
862 "Tool '{tool_name}' is not available in the current tool catalog. \
863 `exec_shell` was renamed to `Bash` — call `Bash` with action \"{action}\" instead. \
864 If `Bash` is also absent: {shell_hint}.",
865 shell_hint = shell_tool_allow_shell_hint()
866 );
867 }
868 if suggestions.is_empty() {
869 if let Some(shell_hint) = shell_hint {
870 return format!(
871 "Tool '{tool_name}' is not available in the current tool catalog. \
872 {shell_hint}, or use {TOOL_SEARCH_NAME} with a short query."
873 );
874 }
875 return format!(
876 "Tool '{tool_name}' is not available in the current tool catalog. \
877 Verify mode/feature flags, or use {TOOL_SEARCH_NAME} with a short query."
878 );
879 }
880
881 let suggestion_text = format!("Did you mean: {}?", suggestions.join(", "));
882 if let Some(shell_hint) = shell_hint {
883 return format!(
884 "Tool '{tool_name}' is not available in the current tool catalog. \
885 {suggestion_text} {shell_hint}. \
886 You can also use {TOOL_SEARCH_NAME} to discover tools."
887 );
888 }
889
890 format!(
891 "Tool '{tool_name}' is not available in the current tool catalog. \
892 {suggestion_text} You can also use {TOOL_SEARCH_NAME} to discover tools."
893 )
894 }
895
896 fn shell_tool_allow_shell_hint() -> &'static str {
897 "Shell tools are absent because this session or profile disabled shell access, \
898 commonly via top-level `allow_shell = false` or Plan mode. \
899 Interactive Act mode exposes shell by default with approval gating unless disabled. \
900 Run `/config allow_shell true` for this session or add `--save` for future sessions; \
901 the next turn will expose shell again"
902 }
903
904 fn is_shell_tool_name(tool_name: &str) -> bool {
905 matches!(
906 tool_name,
907 "exec_shell"
908 | "exec_shell_wait"
909 | "exec_shell_interact"
910 | "task_shell_start"
911 | "task_shell_wait"
912 )
913 }
914
915 #[cfg(test)]
916 pub(super) fn maybe_activate_requested_deferred_tool(
917 tool_name: &str,
918 catalog: &[Tool],
919 active_tools: &mut HashSet<String>,
920 ) -> bool {
921 let Some(def) = catalog.iter().find(|def| def.name == tool_name) else {
922 return false;
923 };
924
925 if !def.defer_loading.unwrap_or(false) || active_tools.contains(tool_name) {
926 return false;
927 }
928
929 active_tools.insert(tool_name.to_string())
930 }
931
932 pub(super) fn maybe_hydrate_requested_deferred_tool(
933 tool_name: &str,
934 tool_input: &Value,
935 catalog: &[Tool],
936 active_tools_at_batch_start: &HashSet<String>,
937 hydrated_tools_this_batch: &mut HashSet<String>,
938 ) -> Option<ToolResult> {
939 let def = catalog.iter().find(|def| def.name == tool_name)?;
940
941 if !def.defer_loading.unwrap_or(false) || active_tools_at_batch_start.contains(tool_name) {
942 return None;
943 }
944
945 hydrated_tools_this_batch.insert(tool_name.to_string());
946 Some(deferred_tool_schema_hydration_result(def, tool_input))
947 }
948
949 #[cfg(test)]
950 pub(super) fn preflight_requested_deferred_tool(
951 tool_name: &str,
952 tool_input: &Value,
953 catalog: &[Tool],
954 active_tools: &mut HashSet<String>,
955 ) -> Option<ToolResult> {
956 let active_tools_at_batch_start = active_tools.clone();
957 let mut hydrated_tools_this_batch = HashSet::new();
958 let result = maybe_hydrate_requested_deferred_tool(
959 tool_name,
960 tool_input,
961 catalog,
962 &active_tools_at_batch_start,
963 &mut hydrated_tools_this_batch,
964 );
965 active_tools.extend(hydrated_tools_this_batch);
966 result
967 }
968
969 fn deferred_tool_schema_hydration_result(tool: &Tool, tool_input: &Value) -> ToolResult {
970 let expected = schema_fields(&tool.input_schema);
971 let required = schema_required_fields(&tool.input_schema);
972 let received = received_field_names(tool_input);
973 let missing = required
974 .iter()
975 .filter(|field| !received.contains(field))
976 .cloned()
977 .collect::<Vec<_>>();
978 let unexpected = received
979 .iter()
980 .filter(|field| !expected.iter().any(|expected| &expected.name == *field))
981 .cloned()
982 .collect::<Vec<_>>();
983 let corrections = likely_field_corrections(&received, &expected, &tool.name);
984
985 let mut lines = vec![
986 format!("Tool `{}` was deferred and has now been loaded.", tool.name),
987 String::new(),
988 "The tool was not executed. Retry with the loaded schema.".to_string(),
989 String::new(),
990 "Expected fields:".to_string(),
991 ];
992 if expected.is_empty() {
993 lines.push(" (none)".to_string());
994 } else {
995 for field in &expected {
996 let required_marker = if required.contains(&field.name) {
997 " required"
998 } else {
999 ""
1000 };
1001 lines.push(format!(
1002 " {}: {}{}",
1003 field.name, field.kind, required_marker
1004 ));
1005 }
1006 }
1007 lines.push(String::new());
1008 lines.push("Received fields:".to_string());
1009 if received.is_empty() {
1010 lines.push(" (none)".to_string());
1011 } else {
1012 lines.push(format!(" {}", received.join(", ")));
1013 }
1014 if !missing.is_empty() {
1015 lines.push(String::new());
1016 lines.push("Missing required fields:".to_string());
1017 lines.push(format!(" {}", missing.join(", ")));
1018 }
1019 if !unexpected.is_empty() {
1020 lines.push(String::new());
1021 lines.push("Unexpected fields:".to_string());
1022 lines.push(format!(" {}", unexpected.join(", ")));
1023 }
1024 if !corrections.is_empty() {
1025 lines.push(String::new());
1026 lines.push("Likely corrections:".to_string());
1027 for correction in &corrections {
1028 lines.push(format!(" {correction}"));
1029 }
1030 }
1031
1032 ToolResult::success(lines.join("\n")).with_metadata(json!({
1033 "event": "tool.schema_hydrated",
1034 "tool": tool.name,
1035 "executed": false,
1036 "retry_required": true,
1037 "reason": "deferred_tool_first_use",
1038 "deferred_tool_loaded": true,
1039 "tool_name": tool.name,
1040 "expected_fields": expected.iter().map(|field| field.name.clone()).collect::<Vec<_>>(),
1041 "received_fields": received,
1042 "missing_required_fields": missing,
1043 "unexpected_fields": unexpected,
1044 "likely_corrections": corrections,
1045 }))
1046 }
1047
1048 #[derive(Debug, Clone)]
1049 struct SchemaField {
1050 name: String,
1051 kind: String,
1052 }
1053
1054 fn schema_fields(schema: &Value) -> Vec<SchemaField> {
1055 let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
1056 return Vec::new();
1057 };
1058 let mut fields = properties
1059 .iter()
1060 .map(|(name, spec)| SchemaField {
1061 name: name.clone(),
1062 kind: schema_type_label(spec),
1063 })
1064 .collect::<Vec<_>>();
1065 fields.sort_by(|a, b| a.name.cmp(&b.name));
1066 fields
1067 }
1068
1069 fn schema_required_fields(schema: &Value) -> Vec<String> {
1070 let mut required = schema
1071 .get("required")
1072 .and_then(Value::as_array)
1073 .into_iter()
1074 .flatten()
1075 .filter_map(|value| value.as_str().map(str::to_string))
1076 .collect::<Vec<_>>();
1077 required.sort();
1078 required
1079 }
1080
1081 fn schema_type_label(spec: &Value) -> String {
1082 let Some(kind) = spec.get("type").and_then(Value::as_str) else {
1083 return "value".to_string();
1084 };
1085 if let Some(values) = spec.get("enum").and_then(Value::as_array) {
1086 let labels = values.iter().filter_map(Value::as_str).collect::<Vec<_>>();
1087 if !labels.is_empty() {
1088 return format!("{kind} ({})", labels.join(" | "));
1089 }
1090 }
1091 kind.to_string()
1092 }
1093
1094 fn received_field_names(input: &Value) -> Vec<String> {
1095 let mut fields = input
1096 .as_object()
1097 .map(|object| object.keys().cloned().collect::<Vec<_>>())
1098 .unwrap_or_default();
1099 fields.sort();
1100 fields
1101 }
1102
1103 fn likely_field_corrections(
1104 received: &[String],
1105 expected: &[SchemaField],
1106 tool_name: &str,
1107 ) -> Vec<String> {
1108 let has_expected = |name: &str| expected.iter().any(|field| field.name == name);
1109 let has_received = |name: &str| received.iter().any(|field| field == name);
1110 let mut corrections = Vec::new();
1111
1112 if has_received("old_string") && has_expected("search") {
1113 corrections.push("old_string -> search".to_string());
1114 } else if has_received("old_str") && has_expected("search") {
1115 corrections.push("old_str -> search".to_string());
1116 }
1117 if has_received("new_string") && has_expected("replace") {
1118 corrections.push("new_string -> replace".to_string());
1119 } else if has_received("new_str") && has_expected("replace") {
1120 corrections.push("new_str -> replace".to_string());
1121 } else if has_received("replacement") && has_expected("replace") {
1122 corrections.push("replacement -> replace".to_string());
1123 }
1124 if matches!(tool_name, "checklist_update" | "todo_update") && has_received("todos") {
1125 corrections.push(
1126 "Use work_update to replace the full list, or retry checklist_update/todo_update with id and status."
1127 .to_string(),
1128 );
1129 }
1130 // RLM source fields are easy to misname (#2659). rlm_open takes exactly one
1131 // of file_path / content / url / session_object; nudge common wrong names
1132 // toward those. The unified `rlm` tool carries the same fields for
1133 // action=open, so it gets the same correction.
1134 if matches!(tool_name, "rlm_open" | "rlm") {
1135 for wrong in [
1136 "prompt",
1137 "resident_file",
1138 "text",
1139 "body",
1140 "path",
1141 "file",
1142 "source",
1143 ] {
1144 if has_received(wrong)
1145 && !has_received("file_path")
1146 && !has_received("content")
1147 && !has_received("url")
1148 && !has_received("session_object")
1149 {
1150 corrections.push(format!("{wrong} -> file_path (local file), content (inline text), url, or session_object"));
1151 }
1152 }
1153 }
1154 corrections
1155 }
1156
1157 pub(super) fn execute_tool_search(
1158 tool_name: &str,
1159 input: &serde_json::Value,
1160 catalog: &[Tool],
1161 active_tools: &mut HashSet<String>,
1162 ) -> Result<ToolResult, ToolError> {
1163 let query = required_str(input, "query")?;
1164 let match_kind = match tool_name {
1165 LEGACY_TOOL_SEARCH_REGEX_NAME => "regex",
1166 LEGACY_TOOL_SEARCH_BM25_NAME => "bm25",
1167 _ => optional_str(input, "match")?.unwrap_or("bm25"),
1168 };
1169 if !matches!(match_kind, "bm25" | "regex") {
1170 return Err(ToolError::invalid_input(format!(
1171 "Unsupported match algorithm '{match_kind}'. Expected one of: bm25, regex"
1172 )));
1173 }
1174 let max_results = usize::try_from(optional_u64(
1175 input,
1176 "max_results",
1177 TOOL_SEARCH_DEFAULT_MAX_RESULTS as u64,
1178 )?)
1179 .unwrap_or(TOOL_SEARCH_DEFAULT_MAX_RESULTS)
1180 .clamp(1, TOOL_SEARCH_MAX_RESULTS_LIMIT);
1181 let discovered = if match_kind == "regex" {
1182 discover_tools_with_regex(catalog, query, max_results)?
1183 } else {
1184 discover_tools_with_bm25_like(catalog, query, max_results)
1185 };
1186 let remaining_results = max_results.saturating_sub(discovered.len());
1187 let unavailable = if match_kind == "regex" {
1188 unavailable_core_action_tools_with_regex(catalog, query, remaining_results)?
1189 } else {
1190 unavailable_core_action_tools_with_bm25_like(catalog, query, remaining_results)
1191 };
1192
1193 for name in &discovered {
1194 active_tools.insert(name.clone());
1195 }
1196
1197 let references = discovered
1198 .iter()
1199 .map(|name| json!({"type": "tool_reference", "tool_name": name}))
1200 .collect::<Vec<_>>();
1201 let unavailable_references = unavailable
1202 .iter()
1203 .map(|fallback| {
1204 json!({
1205 "type": "unavailable_tool_reference",
1206 "tool_name": fallback.name,
1207 "reason": fallback.unavailable_reason,
1208 })
1209 })
1210 .collect::<Vec<_>>();
1211
1212 let payload = json!({
1213 "type": "tool_search_tool_search_result",
1214 "tool_references": references,
1215 "unavailable_tool_references": unavailable_references.clone(),
1216 });
1217
1218 Ok(ToolResult {
1219 content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()),
1220 success: true,
1221 metadata: Some(json!({
1222 "tool_references": discovered,
1223 "unavailable_tool_references": unavailable_references,
1224 })),
1225 })
1226 }
1227
1228 pub(super) async fn execute_code_execution_tool(
1229 input: &serde_json::Value,
1230 workspace: &Path,
1231 ) -> Result<ToolResult, ToolError> {
1232 let code = required_str(input, "code")?;
1233
1234 // Resolve the locally-installed Python interpreter we cached at
1235 // catalog-build time. If it's absent now (somehow registered but
1236 // disappeared between startup and this call — concurrent uninstall,
1237 // PATH change, etc.) the ExternalTool::tokio_command() will return
1238 // None and we fail fast with a clear message.
1239 //
1240 // Write the code to a temp file and execute it as a script rather
1241 // than passing it via `-c "<code>"`. Reasons:
1242 // * `-c` has length limits (argv) on Windows.
1243 // * Multiline code with quote nesting is brittle through `-c`.
1244 // * Tracebacks reference a real filename instead of `<string>`,
1245 // so the model can interpret line numbers correctly.
1246 // Tempfile lives only for the duration of this execution; Drop
1247 // removes it. We use `.py` so any shebang / encoding-sniffer
1248 // logic in the interpreter behaves normally.
1249 let temp_dir = tempfile::tempdir()
1250 .map_err(|e| ToolError::execution_failed(format!("tempdir failed: {e}")))?;
1251 let script_path = temp_dir.path().join("code_execution.py");
1252 tokio::fs::write(&script_path, code)
1253 .await
1254 .map_err(|e| ToolError::execution_failed(format!("tempfile write failed: {e}")))?;
1255
1256 let mut cmd = crate::dependencies::Python::tokio_command().ok_or_else(|| {
1257 ToolError::execution_failed(
1258 "code_execution: Python interpreter became unavailable".to_string(),
1259 )
1260 })?;
1261 cmd.arg(&script_path).current_dir(workspace);
1262
1263 let output = tokio::time::timeout(Duration::from_secs(120), cmd.output())
1264 .await
1265 .map_err(|_| ToolError::Timeout { seconds: 120 })
1266 .and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?;
1267
1268 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1269 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1270 let return_code = output.status.code().unwrap_or(-1);
1271 let success = output.status.success();
1272 let payload = json!({
1273 "type": "code_execution_result",
1274 "stdout": stdout,
1275 "stderr": stderr,
1276 "return_code": return_code,
1277 "content": [],
1278 });
1279
1280 Ok(ToolResult {
1281 content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()),
1282 success,
1283 metadata: Some(payload),
1284 })
1285 }
1286
1287 #[cfg(test)]
1288 mod synthetic_name_tests {
1289 use super::{
1290 CODE_EXECUTION_DESCRIPTION, default_synthetic_catalog_tool_names, is_synthetic_catalog_tool,
1291 };
1292
1293 /// `code_execution` writes the script to a tempdir and runs it as a plain
1294 /// child process in the workspace — no seccomp, no jail, no container. The
1295 /// description is model-facing, so calling it a sandbox would tell the model
1296 /// it has isolation the runtime never provides.
1297 #[test]
1298 fn code_execution_description_does_not_claim_process_sandboxing() {
1299 assert!(CODE_EXECUTION_DESCRIPTION.contains("local Python interpreter"));
1300 assert!(!CODE_EXECUTION_DESCRIPTION.contains("sandbox"));
1301 }
1302
1303 /// The published synthetic-name list and the predicate that classifies a
1304 /// catalog entry as synthetic must agree. A name that appears in the list
1305 /// but is not classified synthetic would let the request projection report
1306 /// a provenance the engine itself disputes.
1307 #[test]
1308 fn published_synthetic_names_agree_with_the_synthetic_predicate() {
1309 let names = default_synthetic_catalog_tool_names();
1310 assert!(!names.is_empty());
1311 for name in &names {
1312 assert!(
1313 is_synthetic_catalog_tool(name),
1314 "'{name}' is published as synthetic but the predicate disagrees"
1315 );
1316 }
1317 let mut sorted = names.clone();
1318 sorted.sort();
1319 sorted.dedup();
1320 assert_eq!(names, sorted, "the list must be sorted and deduplicated");
1321
1322 // MCP names resolve through the real pool, so they are deliberately
1323 // absent here even though the predicate accepts them.
1324 assert!(!names.iter().any(|name| name.starts_with("mcp_")));
1325
1326 // `multi_tool_use.parallel` is a call name, never a catalog entry, so
1327 // it has no catalog provenance and must not be published as synthetic.
1328 assert!(
1329 !names
1330 .iter()
1331 .any(|name| name == super::MULTI_TOOL_PARALLEL_NAME)
1332 );
1333 }
1334 }
1335
1335 lines RUST