返回 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::tools::spec::{ToolError, ToolResult, optional_str, optional_u64, required_str};
18 use codewhale_config::AppMode;
19 use codewhale_execpolicy::ApprovalMode;
20 use codewhale_models::Tool;
21
22 use crate::core::session::ToolActivationCache;
23 use crate::dependencies::ExternalTool;
24 use crate::features::{Feature, Features};
25 use crate::regex_cache::compile_user_regex;
26
27 pub(super) const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel";
28 pub(crate) const REQUEST_USER_INPUT_NAME: &str = "request_user_input";
29 pub(super) const CODE_EXECUTION_TOOL_NAME: &str = "code_execution";
30 const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825";
31 const CODE_EXECUTION_DESCRIPTION: &str = "Execute Python code with the local Python interpreter in the workspace and return stdout/stderr/return_code as JSON.";
32 pub(super) use crate::tools::codemode::EXECUTE_TOOLS_TOOL_NAME;
33 pub(super) use crate::tools::js_execution::JS_EXECUTION_TOOL_NAME;
34 pub(crate) const TOOL_SEARCH_NAME: &str = "tool_search";
35 const TOOL_RESULT_RETRIEVAL_NAME: &str = "retrieve_tool_result";
36 const TOOL_SEARCH_TYPE: &str = "tool_search_20251119";
37 const LEGACY_TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex";
38 const LEGACY_TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25";
39 const TOOL_SEARCH_DEFAULT_MAX_RESULTS: usize = 8;
40 const TOOL_SEARCH_MAX_RESULTS_LIMIT: usize = 8;
41
42 pub(crate) fn is_tool_search_tool(name: &str) -> bool {
43 matches!(
44 name,
45 TOOL_SEARCH_NAME | LEGACY_TOOL_SEARCH_REGEX_NAME | LEGACY_TOOL_SEARCH_BM25_NAME
46 )
47 }
48
49 // Crate-visible so the hook gate tests the real eager names instead of a copy.
50 #[rustfmt::skip]
51 pub(crate) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[
52 // Core work controls are eager; specialized tools stay searchable.
53 "read", "write", "edit", "bash", "agent", "workflow", "todo_write",
54 // Continuation instructions require these controls. Hiding them behind
55 // discovery leaves a model unable to stop the work it was asked to run.
56 "create_goal", "get_goal", "update_goal",
57 ];
58
59 const CORE_ACTION_TOOL_FALLBACKS: &[CoreActionToolFallback] = &[
60 CoreActionToolFallback {
61 name: "bash",
62 description: "Run shell commands in the workspace.",
63 unavailable_reason: "Not present in the current model-visible catalog. The session profile, feature availability, or a command tool allow/deny gate can remove shell access. Plan keeps the same primitive identity but centrally refuses execution.",
64 },
65 CoreActionToolFallback {
66 name: "read",
67 description: "Read workspace files.",
68 unavailable_reason: "Not present in the current model-visible catalog. File reads are available in Plan and executable modes unless a command allow/deny gate removes them.",
69 },
70 CoreActionToolFallback {
71 name: "write",
72 description: "Create or replace workspace files.",
73 unavailable_reason: "Not present in the current model-visible catalog. Plan mode has no file-mutation authority; switch to Work mode before writing.",
74 },
75 CoreActionToolFallback {
76 name: "edit",
77 description: "Apply exact replacements to workspace files.",
78 unavailable_reason: "Not present in the current model-visible catalog. Plan mode has no file-mutation authority; switch to Work mode before editing.",
79 },
80 ];
81
82 #[derive(Debug, Clone, Copy)]
83 struct CoreActionToolFallback {
84 name: &'static str,
85 description: &'static str,
86 unavailable_reason: &'static str,
87 }
88
89 /// Pre-computed lowercased haystack + name for each fallback; built once.
90 struct CachedFallback {
91 fallback: CoreActionToolFallback,
92 haystack: String,
93 name_lower: String,
94 }
95
96 static CACHED_FALLBACKS: std::sync::OnceLock<Vec<CachedFallback>> = std::sync::OnceLock::new();
97
98 fn cached_fallbacks() -> &'static [CachedFallback] {
99 CACHED_FALLBACKS.get_or_init(|| {
100 CORE_ACTION_TOOL_FALLBACKS
101 .iter()
102 .map(|f| CachedFallback {
103 fallback: *f,
104 haystack: format!(
105 "{}\n{}\n{}",
106 f.name.to_lowercase(),
107 f.description.to_lowercase(),
108 f.unavailable_reason.to_lowercase(),
109 ),
110 name_lower: f.name.to_lowercase(),
111 })
112 .collect()
113 })
114 }
115
116 /// Membership index over [`DEFAULT_ACTIVE_NATIVE_TOOLS`], built once for the
117 /// process lifetime. The array stays the source of truth for ordered
118 /// inspection; this set only accelerates the
119 /// hot membership check in [`should_default_defer_tool`], which runs once per
120 /// catalog tool on every catalog rebuild (i.e. per turn) — an O(n·m) linear
121 /// scan over the array collapses to O(1) hashed lookups.
122 static DEFAULT_ACTIVE_NATIVE_TOOLS_SET: std::sync::OnceLock<HashSet<&'static str>> =
123 std::sync::OnceLock::new();
124
125 fn default_active_native_tools_set() -> &'static HashSet<&'static str> {
126 DEFAULT_ACTIVE_NATIVE_TOOLS_SET
127 .get_or_init(|| DEFAULT_ACTIVE_NATIVE_TOOLS.iter().copied().collect())
128 }
129
130 pub(super) fn should_default_defer_tool(name: &str, always_load: &HashSet<String>) -> bool {
131 if always_load.contains(name) {
132 return false;
133 }
134
135 if is_tool_search_tool(name) {
136 return false;
137 }
138
139 // Membership-only test (no ordering dependency): the side set built from
140 // DEFAULT_ACTIVE_NATIVE_TOOLS returns identical hit/miss results as the
141 // former `.iter().any(...)` linear scan.
142 !default_active_native_tools_set().contains(name)
143 }
144
145 pub(crate) fn apply_native_tool_deferral(catalog: &mut [Tool], always_load: &HashSet<String>) {
146 for tool in catalog {
147 tool.defer_loading = Some(should_default_defer_tool(&tool.name, always_load));
148 }
149 }
150
151 pub(super) fn apply_mcp_tool_deferral(
152 catalog: &mut [Tool],
153 _mode: AppMode,
154 always_load: &HashSet<String>,
155 ) {
156 for tool in catalog {
157 if always_load.contains(&tool.name) {
158 tool.defer_loading = Some(false);
159 continue;
160 }
161 tool.defer_loading = Some(true);
162 }
163 }
164
165 /// Build the model tool catalog from native and MCP tool lists.
166 ///
167 /// **Catalog-head stability invariant.** The head of the catalog (all
168 /// non-deferred tools) must remain byte-identical across mode toggles
169 /// (Plan ↔ Agent ↔ YOLO) for tools that are common to both modes.
170 /// Deferred tool activations append to the tail and never reorder the
171 /// head. This invariant is critical for DeepSeek's KV prefix cache:
172 /// the tools array is part of the immutable prefix, and any byte-level
173 /// change in the head forces a full re-prefill on the next turn.
174 #[cfg(test)]
175 pub(super) fn build_model_tool_catalog(
176 native_tools: Vec<Tool>,
177 mcp_tools: Vec<Tool>,
178 mode: AppMode,
179 always_load: &HashSet<String>,
180 ) -> Vec<Tool> {
181 build_model_tool_catalog_with_surface(
182 native_tools,
183 mcp_tools,
184 mode,
185 always_load,
186 ToolSurfaceBudget::Standard,
187 )
188 }
189
190 pub(super) fn build_model_tool_catalog_with_surface(
191 mut native_tools: Vec<Tool>,
192 mut mcp_tools: Vec<Tool>,
193 mode: AppMode,
194 always_load: &HashSet<String>,
195 surface_budget: ToolSurfaceBudget,
196 ) -> Vec<Tool> {
197 apply_native_tool_deferral(&mut native_tools, always_load);
198 apply_mcp_tool_deferral(&mut mcp_tools, mode, always_load);
199 apply_tool_surface_budget(&mut native_tools, surface_budget, always_load);
200 apply_tool_surface_budget(&mut mcp_tools, surface_budget, always_load);
201 // Sort each partition by name for prefix-cache stability (#263). The
202 // upstream `to_api_tools()` already sorts the registry's HashMap output;
203 // this catalog is built from caller-supplied Vecs which the test harness
204 // and (future) caller refactors may not pre-sort. Built-ins stay as a
205 // contiguous prefix ahead of MCP tools so adding/removing an MCP tool
206 // never shifts a built-in's position.
207 native_tools.sort_by(|a, b| a.name.cmp(&b.name));
208 mcp_tools.sort_by(|a, b| a.name.cmp(&b.name));
209 native_tools.extend(mcp_tools);
210 native_tools
211 }
212
213 // A second Registry authority used to live here: it appended a "call
214 // registry_sync before this tool" paragraph to a model-visible `exec_shell`
215 // description. The model-visible shell tool is `bash` — `exec_shell` is only a
216 // canonical *action* name (see `tools::canonical_action`) — so the hook never
217 // fired on a live catalog, and its own test pinned that it must not touch
218 // `bash`. The Registry instruction in `Engine::new` is the single prompt
219 // authority for this decision; a per-tool copy of it is not revived here.
220
221 pub(super) fn apply_tool_surface_budget(
222 catalog: &mut [Tool],
223 surface_budget: ToolSurfaceBudget,
224 always_load: &HashSet<String>,
225 ) {
226 if !matches!(surface_budget, ToolSurfaceBudget::Compact) {
227 return;
228 }
229 for tool in catalog {
230 if always_load.contains(&tool.name) {
231 continue;
232 }
233 if matches!(tool.name.as_str(), "Run" | "tasks" | "Web") {
234 tool.defer_loading = Some(true);
235 }
236 }
237 }
238
239 /// Whether two tool-surface budgets currently produce the same catalog.
240 ///
241 /// Runs [`apply_tool_surface_budget`] over `catalog` under both budgets and
242 /// compares the results. `/preview-request` publishes the Standard-vs-Full
243 /// answer as a derived field so the truthful "these are currently collapsed"
244 /// disclosure cannot drift from the code: the day the shaper narrows Standard
245 /// differently from Full, this starts returning `false` on its own.
246 pub(super) fn surface_budgets_produce_same_catalog(
247 catalog: &[Tool],
248 always_load: &HashSet<String>,
249 left_budget: ToolSurfaceBudget,
250 right_budget: ToolSurfaceBudget,
251 ) -> bool {
252 let mut left = catalog.to_vec();
253 let mut right = catalog.to_vec();
254 apply_tool_surface_budget(&mut left, left_budget, always_load);
255 apply_tool_surface_budget(&mut right, right_budget, always_load);
256 serde_json::to_string(&left).ok() == serde_json::to_string(&right).ok()
257 }
258
259 /// How the harness exposes tool-calling to the model, resolved per turn.
260 ///
261 /// Mirrors Codex's `ToolMode`: the model's own metadata wins, `[features]`
262 /// flags override the default, and anything else is [`ToolMode::Direct`].
263 /// There is no user-facing mode to enter — the catalog shape is the whole
264 /// mechanism, so `CodeMode` only promotes `execute_tools` from deferred to
265 /// eager. (A `CodeModeOnly` restriction needs dispatch enforcement and is a
266 /// later slice, not a third variant here.)
267 ///
268 /// KV-cache effect: the inputs are session config (plus future per-model
269 /// metadata), so the resolved mode is prefix-stable within a session; a flag
270 /// flip refreshes the prefix under an explicit config-change reason like any
271 /// other catalog reshape.
272 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
273 pub(crate) enum ToolMode {
274 /// Composition by choice: `execute_tools` stays deferred until `tool_search`.
275 Direct,
276 /// Composition by default: `execute_tools` is eager alongside direct tools.
277 CodeMode,
278 }
279
280 /// Resolve the turn's tool mode: model hint first, `[features] code_mode`
281 /// second, [`ToolMode::Direct`] otherwise. The engine passes `None` for the
282 /// hint until per-model metadata is wired (model_registry follow-up).
283 pub(crate) fn requested_tool_mode(model_hint: Option<ToolMode>, features: &Features) -> ToolMode {
284 model_hint.unwrap_or_else(|| {
285 if features.enabled(Feature::CodeMode) {
286 ToolMode::CodeMode
287 } else {
288 ToolMode::Direct
289 }
290 })
291 }
292
293 pub(crate) fn ensure_advanced_tooling(
294 catalog: &mut Vec<Tool>,
295 mode: AppMode,
296 always_load: &HashSet<String>,
297 tool_mode: ToolMode,
298 ) {
299 // code_execution depends on a locally-installed Python interpreter
300 // (python3 / python / py -3). Before v0.8.31, the tool was always
301 // advertised and would fail at execution time on Windows where
302 // `python3` isn't on PATH — the model treated the tool as reliable
303 // once it appeared in the catalog. We now probe at catalog-build
304 // time and only advertise when an interpreter resolves. See
305 // `crate::dependencies::resolve_python_interpreter` for the probe.
306 if mode != AppMode::Plan
307 && !catalog.iter().any(|t| t.name == CODE_EXECUTION_TOOL_NAME)
308 && crate::dependencies::resolve_python_interpreter().is_some()
309 {
310 catalog.push(Tool {
311 tool_type: Some(CODE_EXECUTION_TOOL_TYPE.to_string()),
312 name: CODE_EXECUTION_TOOL_NAME.to_string(),
313 description: CODE_EXECUTION_DESCRIPTION.to_string(),
314 input_schema: json!({
315 "type": "object",
316 "properties": {
317 "code": { "type": "string", "description": "Python source code to execute." }
318 },
319 "required": ["code"]
320 }),
321 allowed_callers: Some(vec!["direct".to_string()]),
322 defer_loading: Some(should_default_defer_tool(
323 CODE_EXECUTION_TOOL_NAME,
324 always_load,
325 )),
326 input_examples: None,
327 strict: None,
328 cache_control: None,
329 });
330 }
331
332 // js_execution mirrors code_execution: gate on Node.js being
333 // present locally so the model never sees a runtime it can't
334 // actually use. Plan mode hides shell/exec surfaces (including
335 // both interpreter tools) by construction; Agent / YOLO advertise
336 // the tool only when `resolve_node()` succeeds.
337 if mode != AppMode::Plan
338 && !catalog.iter().any(|t| t.name == JS_EXECUTION_TOOL_NAME)
339 && crate::dependencies::resolve_node().is_some()
340 {
341 let mut tool = crate::tools::js_execution::js_execution_tool_definition();
342 tool.defer_loading = Some(should_default_defer_tool(&tool.name, always_load));
343 catalog.push(tool);
344 }
345
346 // execute_tools needs no dependency probe: QuickJS is compiled in.
347 // Otherwise it follows the interpreter tools exactly — hidden from Plan,
348 // deferred everywhere else — except under CodeMode, where the harness
349 // promotes composition to eager instead of waiting for tool_search.
350 if mode != AppMode::Plan && !catalog.iter().any(|t| t.name == EXECUTE_TOOLS_TOOL_NAME) {
351 let mut tool = crate::tools::codemode::execute_tools_tool_definition();
352 tool.defer_loading = Some(
353 tool_mode == ToolMode::Direct && should_default_defer_tool(&tool.name, always_load),
354 );
355 catalog.push(tool);
356 }
357
358 if !catalog.iter().any(|t| t.name == TOOL_SEARCH_NAME) {
359 catalog.push(Tool {
360 tool_type: Some(TOOL_SEARCH_TYPE.to_string()),
361 name: TOOL_SEARCH_NAME.to_string(),
362 description: "Search deferred tool definitions and return matching tool references.".to_string(),
363 input_schema: json!({
364 "type": "object",
365 "properties": {
366 "query": { "type": "string", "description": "Search query for tool discovery." },
367 "match": {
368 "type": "string",
369 "enum": ["bm25", "regex"],
370 "default": "bm25",
371 "description": "Matching algorithm: bm25 for natural-language matching, regex for a regular expression over tool names/descriptions/schema."
372 },
373 "max_results": {
374 "type": "integer",
375 "minimum": 1,
376 "maximum": TOOL_SEARCH_MAX_RESULTS_LIMIT,
377 "default": TOOL_SEARCH_DEFAULT_MAX_RESULTS,
378 "description": "Maximum number of matching tool references to return."
379 }
380 },
381 "required": ["query"]
382 }),
383 allowed_callers: Some(vec!["direct".to_string()]),
384 defer_loading: Some(false),
385 input_examples: None,
386 strict: None,
387 cache_control: None,
388 });
389 }
390 }
391
392 pub(crate) fn initial_active_tools(catalog: &[Tool]) -> HashSet<String> {
393 let mut active = HashSet::new();
394 for tool in catalog {
395 if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) {
396 active.insert(tool.name.clone());
397 }
398 }
399 if active.is_empty()
400 && !catalog.is_empty()
401 && let Some(first) = catalog.first()
402 {
403 active.insert(first.name.clone());
404 }
405 active
406 }
407
408 /// Remove schemas evicted from the conversation cache without hiding tools
409 /// that the current catalog now exposes eagerly.
410 ///
411 /// A cached tool can become eager after an explicit `tools_always_load`
412 /// change or another policy update. Cache revalidation correctly forgets the
413 /// old deferred entry, but the eager catalog entry must remain active.
414 pub(crate) fn remove_evicted_cache_activations(
415 catalog: &[Tool],
416 active: &mut HashSet<String>,
417 evicted: impl IntoIterator<Item = String>,
418 ) {
419 for name in evicted {
420 let is_eager_now = catalog
421 .iter()
422 .any(|tool| tool.name == name && !tool.defer_loading.unwrap_or(false));
423 if !is_eager_now {
424 active.remove(&name);
425 }
426 }
427 }
428
429 /// Promote a successfully executed deferred tool only when this conversation
430 /// had already activated it. Execution can update recency, never grant a name.
431 pub(crate) fn touch_cached_tool_after_execution(
432 catalog: &[Tool],
433 active: &mut HashSet<String>,
434 cache: &mut ToolActivationCache,
435 name: &str,
436 ) -> bool {
437 if !cache.names().any(|cached| cached == name) {
438 return false;
439 }
440 let delta = cache.activate(catalog, &[name.to_string()]);
441 remove_evicted_cache_activations(catalog, active, delta.evicted);
442 active.extend(delta.admitted);
443 true
444 }
445
446 /// Make the recovery schema visible on the next provider step when a tool
447 /// result actually publishes retrievable evidence. The initial toolbox stays
448 /// small, while a receipt never advertises a deferred route that merely asks
449 /// the model to repeat the same call.
450 pub(crate) fn activate_result_dependencies(
451 catalog: &[Tool],
452 active: &mut HashSet<String>,
453 cache: &mut ToolActivationCache,
454 result: &ToolResult,
455 ) -> bool {
456 let needs_retrieval = result
457 .metadata
458 .as_ref()
459 .and_then(|metadata| metadata.get("evidence_available"))
460 .and_then(Value::as_bool)
461 == Some(true);
462 if !needs_retrieval {
463 return false;
464 }
465 let delta = cache.activate(catalog, &[TOOL_RESULT_RETRIEVAL_NAME.to_string()]);
466 remove_evicted_cache_activations(catalog, active, delta.evicted.iter().cloned());
467 active.extend(delta.admitted.iter().cloned());
468 !delta.admitted.is_empty() || !delta.evicted.is_empty()
469 }
470
471 fn active_tool_list_from_catalog(catalog: &[Tool], active: &HashSet<String>) -> Vec<Tool> {
472 // Two-pass for prefix-cache stability (#263). Always-loaded tools come
473 // first in their stable catalog order; tools that started life deferred
474 // and were activated mid-conversation by ToolSearch get appended at the
475 // tail. Otherwise activating a deferred tool shifts every later tool's
476 // byte offset and busts the cached prefix from that point onwards.
477 let catalog_len = catalog.len();
478 let mut head: Vec<Tool> = Vec::with_capacity(catalog_len);
479 let mut tail: Vec<Tool> = Vec::with_capacity(catalog_len);
480 for tool in catalog {
481 if !active.contains(&tool.name) {
482 continue;
483 }
484 if tool.defer_loading.unwrap_or(false) {
485 tail.push(tool.clone());
486 } else {
487 head.push(tool.clone());
488 }
489 }
490 head.extend(tail);
491 head
492 }
493
494 pub(super) fn active_tools_for_step(catalog: &[Tool], active: &HashSet<String>) -> Vec<Tool> {
495 active_tool_list_from_catalog(catalog, active)
496 }
497
498 /// One turn's executable and model-visible tool contract.
499 ///
500 /// The catalog is also the prompt's only availability taxonomy: stable prompt
501 /// prose deliberately does not enumerate tool names. Keeping the concrete
502 /// registry, searchable catalog, initial request subset, and command gates in
503 /// one value prevents preview, dispatch, and execution from reconstructing
504 /// different surfaces from mutable engine configuration.
505 pub(super) struct ToolSurfacePolicy {
506 /// Runtime registry that executes native and plugin tools.
507 pub(super) registry: crate::tools::ToolRegistry,
508 /// Full model-facing catalog, including deferred entries.
509 pub(super) catalog: Vec<Tool>,
510 /// Names active at the start of the turn.
511 pub(super) active_names: HashSet<String>,
512 /// Exact initial `tools` field. `None` means no field is sent.
513 pub(super) active: Option<Vec<Tool>>,
514 pub(super) mode: AppMode,
515 pub(super) strict_tool_mode: bool,
516 allowed_tools: Option<Vec<String>>,
517 disallowed_tools: Option<Vec<String>>,
518 /// Hard per-turn cap on admitted tool calls (#4415). The turn loop copies
519 /// this limit into its own admission counter at turn start; `None` means
520 /// unlimited (the default), which keeps the admission gate inert.
521 pub(super) max_tool_calls: Option<u32>,
522 questions_allowed: bool,
523 }
524
525 impl ToolSurfacePolicy {
526 #[allow(clippy::too_many_arguments)]
527 pub(super) fn new(
528 registry: crate::tools::ToolRegistry,
529 tools: Option<Vec<Tool>>,
530 mode: AppMode,
531 always_load: &HashSet<String>,
532 dynamic_active_tools: &[&'static str],
533 strict_tool_mode: bool,
534 allowed_tools: Option<Vec<String>>,
535 disallowed_tools: Option<Vec<String>>,
536 max_tool_calls: Option<u32>,
537 approval_mode: ApprovalMode,
538 tool_mode: ToolMode,
539 ) -> Self {
540 let mut catalog = tools.unwrap_or_default();
541 if !catalog.is_empty() {
542 ensure_advanced_tooling(&mut catalog, mode, always_load, tool_mode);
543 }
544
545 // Synthetic tools are injected before narrowing. Doing this after the
546 // retain would re-advertise tool_search/code execution despite an
547 // explicit command gate.
548 catalog.retain(|tool| {
549 !tool_denied(disallowed_tools.as_deref(), &tool.name)
550 && tool_allowed(allowed_tools.as_deref(), &tool.name)
551 });
552 for tool in &mut catalog {
553 if let Some(actions) = tool
554 .input_schema
555 .pointer_mut("/properties/action/enum")
556 .and_then(Value::as_array_mut)
557 {
558 actions.retain(|action| {
559 !tool_call_denied(
560 disallowed_tools.as_deref(),
561 &tool.name,
562 &json!({"action": action}),
563 )
564 });
565 }
566 }
567 catalog.retain(|tool| {
568 tool.input_schema
569 .pointer("/properties/action/enum")
570 .and_then(Value::as_array)
571 .is_none_or(|actions| !actions.is_empty())
572 });
573 let questions_allowed =
574 super::super::authority::permission_posture_allows_questions(approval_mode);
575 if !questions_allowed {
576 catalog.retain(|tool| tool.name != REQUEST_USER_INPUT_NAME);
577 }
578
579 let mut active_names = initial_active_tools(&catalog);
580 active_names.extend(dynamic_active_tools.iter().map(|name| (*name).to_string()));
581 active_names.retain(|name| catalog.iter().any(|tool| tool.name == *name));
582 let active = active_tools_for_request(&catalog, &active_names, strict_tool_mode);
583
584 Self {
585 registry,
586 catalog,
587 active_names,
588 active,
589 mode,
590 strict_tool_mode,
591 allowed_tools,
592 disallowed_tools,
593 max_tool_calls,
594 questions_allowed,
595 }
596 }
597
598 #[cfg(test)]
599 pub(super) fn allows_tool(&self, name: &str) -> bool {
600 !self.denies_tool(name) && self.passes_allow_list(name)
601 }
602
603 pub(super) fn passes_allow_list(&self, name: &str) -> bool {
604 tool_allowed(self.allowed_tools.as_deref(), name)
605 }
606
607 pub(super) fn denies_tool(&self, name: &str) -> bool {
608 tool_denied(self.disallowed_tools.as_deref(), name)
609 }
610
611 pub(super) fn denies_call(&self, name: &str, input: &Value) -> bool {
612 tool_call_denied(self.disallowed_tools.as_deref(), name, input)
613 }
614
615 pub(super) fn allows_questions(&self) -> bool {
616 self.questions_allowed
617 }
618 }
619
620 pub(super) fn tool_allowed(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
621 let Some(allowed_tools) = allowed_tools else {
622 return true;
623 };
624 tool_matches_any_rule(allowed_tools, tool_name)
625 }
626
627 pub(crate) fn tool_denied(disallowed_tools: Option<&[String]>, tool_name: &str) -> bool {
628 disallowed_tools.is_some_and(|rules| {
629 tool_matches_any_rule(rules, tool_name)
630 || (requires_raw_shell(tool_name) && tool_matches_any_rule(rules, "Bash"))
631 })
632 }
633
634 /// Execution dependencies narrow denials only. Treating these as symmetric
635 /// aliases would also grant task/terminal execution to an allowlist of Bash.
636 fn requires_raw_shell(name: &str) -> bool {
637 matches!(
638 name.to_ascii_lowercase().as_str(),
639 "bash"
640 | "exec_shell"
641 | "exec_shell_interact"
642 | "exec_interact"
643 | "task_shell_start"
644 | "task_gate_run"
645 // These owners start a fresh execution and currently cannot
646 // transport this command's deny ceiling. Fail closed until they
647 // can preserve it; inspection and cancellation stay available.
648 | "task_create"
649 | "automation_create"
650 | "automation_update"
651 | "automation_resume"
652 | "automation_run"
653 | "terminal/run"
654 | "terminal/send"
655 | "terminal/reset"
656 | "code_execution"
657 | "js_execution"
658 | "rlm_eval"
659 )
660 }
661
662 pub(crate) fn tool_call_denied(rules: Option<&[String]>, name: &str, input: &Value) -> bool {
663 use crate::tools::canonical_action::canonical_action_alias;
664 use crate::tools::execution_envelope::{VerificationBound, classify_verification};
665
666 let action = canonical_action_alias(name, input);
667 tool_denied(rules, name)
668 || tool_denied(rules, action)
669 || (action == "rlm_open"
670 && input
671 .get("url")
672 .and_then(Value::as_str)
673 .is_some_and(|url| !url.trim().is_empty())
674 && tool_denied(rules, "fetch_url"))
675 || (matches!(
676 classify_verification(action, input),
677 Some(VerificationBound::Unbounded)
678 ) && rules.is_some_and(|rules| tool_matches_any_rule(rules, "Bash")))
679 }
680
681 /// Repeat the command ceiling at native dispatch and direct delegation sinks.
682 /// The existing child evidence exception is limited to the canonical lowercase
683 /// tool, a child-owned context, and the same strict read-only grammar enforced
684 /// by Bash itself. It never admits a session, stdin, or background command.
685 pub(crate) fn enforce_tool_denial(
686 context: &crate::tools::spec::ToolContext,
687 name: &str,
688 input: &Value,
689 ) -> Result<(), ToolError> {
690 let bounded_child_read = name == "bash"
691 && context.owner_agent_id.is_some()
692 && context.shell_policy == crate::worker_profile::ShellPolicy::ReadOnly
693 && crate::tools::shell::agent_readonly_bash_input(input);
694 if !bounded_child_read && tool_call_denied(Some(&context.disallowed_tools), name, input) {
695 return Err(ToolError::permission_denied(format!(
696 "Tool '{name}' or its execution dependency is in the disallowed-tools list"
697 )));
698 }
699 Ok(())
700 }
701
702 pub(crate) fn tool_matches_any_rule(rules: &[String], tool_name: &str) -> bool {
703 let tool_name = tool_name.to_ascii_lowercase();
704 rules.iter().any(|rule| {
705 let rule = rule.to_ascii_lowercase();
706 let (rule_body, is_prefix) = rule
707 .strip_suffix('*')
708 .map_or((rule.as_str(), false), |prefix| (prefix, true));
709 std::iter::once(tool_name.as_str())
710 .chain(policy_tool_aliases(&tool_name).iter().copied())
711 .any(|candidate| {
712 if is_prefix {
713 candidate.starts_with(rule_body)
714 } else {
715 candidate == rule_body
716 }
717 })
718 })
719 }
720
721 /// Whether an explicit tool allowlist is provably limited to the native file
722 /// and shell primitives. Unknown names and wildcards remain conservative
723 /// because a configured MCP server may own them.
724 pub(crate) fn allowlist_is_native_file_and_shell_only(allowed_tools: Option<&[String]>) -> bool {
725 let Some(rules) = allowed_tools else {
726 return false;
727 };
728 const NATIVE_NAMES: &[&str] = &[
729 "bash",
730 "exec_shell",
731 "read",
732 "read_file",
733 "write",
734 "write_file",
735 "edit",
736 "edit_file",
737 "file",
738 ];
739 rules.iter().all(|rule| {
740 let rule = rule.trim();
741 !rule.is_empty()
742 && !rule.ends_with('*')
743 && NATIVE_NAMES.contains(&rule.to_ascii_lowercase().as_str())
744 })
745 }
746
747 fn policy_tool_aliases(name: &str) -> &'static [&'static str] {
748 match name {
749 "read" | "read_file" => &["read", "read_file", "file"],
750 "write" | "write_file" => &["write", "write_file", "file"],
751 "edit" | "edit_file" => &["edit", "edit_file", "file"],
752 "file" => &[
753 "file",
754 "read",
755 "read_file",
756 "write",
757 "write_file",
758 "edit",
759 "edit_file",
760 ],
761 "bash" | "exec_shell" => &["bash", "exec_shell"],
762 "mcp_read_resource" | "read_mcp_resource" => &["mcp_read_resource", "read_mcp_resource"],
763 _ => &[],
764 }
765 }
766
767 /// The `tools` field of one outbound request, from a catalog and the set of
768 /// currently-active tool names.
769 ///
770 /// Shared by [`ToolSurfacePolicy`] and the per-step rebuild inside the turn
771 /// loop, so activating a deferred tool mid-turn goes through one code path.
772 pub(crate) fn active_tools_for_request(
773 catalog: &[Tool],
774 active: &HashSet<String>,
775 strict_tool_mode: bool,
776 ) -> Option<Vec<Tool>> {
777 if catalog.is_empty() {
778 return None;
779 }
780 let mut tools = active_tools_for_step(catalog, active);
781 if strict_tool_mode {
782 crate::tools::schema_sanitize::prepare_tools_for_strict_mode(&mut tools);
783 }
784 Some(tools)
785 }
786
787 /// Reusable scratch for one `tool_search` catalog scan.
788 ///
789 /// Each deferred tool needs a lowercased `name\ndescription\ninput_schema` blob
790 /// that is compared once and dropped. Building it with `format!` also copied all
791 /// three pieces a second time into the concatenation, and the bm25 scorer then
792 /// re-lowered `tool.name` once per query term for a value that does not vary
793 /// across terms. Reusing one set of buffers across the scan removes the
794 /// concatenation copy and the per-term lowering, and keeps the buffers' capacity
795 /// instead of reallocating per tool (#6213 T5).
796 ///
797 /// This is the same precomputed-index idiom `CachedFallback` already uses for
798 /// the static core-action fallbacks in this file; it is not a new pattern.
799 ///
800 /// Lowercasing deliberately stays `str::to_lowercase`, matching the original
801 /// exactly. A per-`char` fold would allocate less but is not the same function —
802 /// it differs on Greek final sigma — and this path runs a handful of times per
803 /// turn beside a multi-second provider call, so it is not worth a semantic
804 /// change.
805 #[derive(Default)]
806 struct ToolSearchScratch {
807 /// `tool.name`, lowercased. Loop-invariant across query terms, so the bm25
808 /// scorer reads this instead of re-lowering the name once per term.
809 name_lower: String,
810 /// Compact JSON of `tool.input_schema`, before lowercasing.
811 schema_json: String,
812 /// The match target: `name\ndescription\nschema`, all lowercased.
813 hay: String,
814 }
815
816 impl ToolSearchScratch {
817 fn load(&mut self, tool: &Tool) {
818 use std::fmt::Write as _;
819
820 self.name_lower.clear();
821 self.name_lower.push_str(&tool.name.to_lowercase());
822
823 self.schema_json.clear();
824 // `Value`'s `Display` is what `to_string()` calls, so this is the same
825 // text without materializing an owned copy first. Infallible for a
826 // `String` sink; a formatting error could only shorten the schema,
827 // which weakens matching and never breaks correctness.
828 let _ = write!(self.schema_json, "{}", tool.input_schema);
829
830 self.hay.clear();
831 self.hay.push_str(&self.name_lower);
832 self.hay.push('\n');
833 self.hay.push_str(&tool.description.to_lowercase());
834 self.hay.push('\n');
835 self.hay.push_str(&self.schema_json.to_lowercase());
836 }
837 }
838
839 fn catalog_contains_tool(catalog: &[Tool], name: &str) -> bool {
840 catalog.iter().any(|tool| tool.name == name)
841 }
842
843 fn unavailable_core_action_tools_with_regex(
844 catalog: &[Tool],
845 query: &str,
846 max_results: usize,
847 ) -> Result<Vec<CoreActionToolFallback>, ToolError> {
848 if max_results == 0 {
849 return Ok(Vec::new());
850 }
851 let regex = compile_user_regex(query)
852 .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?;
853 Ok(cached_fallbacks()
854 .iter()
855 .filter(|cf| !catalog_contains_tool(catalog, cf.fallback.name))
856 .filter(|cf| regex.is_match(&cf.haystack))
857 .take(max_results)
858 .map(|cf| cf.fallback)
859 .collect())
860 }
861
862 fn unavailable_core_action_tools_with_bm25_like(
863 catalog: &[Tool],
864 query: &str,
865 max_results: usize,
866 ) -> Vec<CoreActionToolFallback> {
867 if max_results == 0 {
868 return Vec::new();
869 }
870 let terms: Vec<String> = query
871 .split_whitespace()
872 .map(|term| term.trim().to_lowercase())
873 .filter(|term| !term.is_empty())
874 .collect();
875 if terms.is_empty() {
876 return Vec::new();
877 }
878
879 let mut scored: Vec<(i64, CoreActionToolFallback)> = Vec::new();
880 for cf in cached_fallbacks() {
881 if catalog_contains_tool(catalog, cf.fallback.name) {
882 continue;
883 }
884 let hay = &cf.haystack;
885 let name = &cf.name_lower;
886 let mut score = 0i64;
887 for term in &terms {
888 if hay.contains(term) {
889 score += 1;
890 }
891 if name.contains(term) {
892 score += 2;
893 }
894 }
895 if score > 0 {
896 scored.push((score, cf.fallback));
897 }
898 }
899 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(b.1.name)));
900 scored
901 .into_iter()
902 .take(max_results)
903 .map(|(_, fallback)| fallback)
904 .collect()
905 }
906
907 fn discover_tools_with_regex(
908 catalog: &[Tool],
909 query: &str,
910 max_results: usize,
911 ) -> Result<Vec<String>, ToolError> {
912 let regex = compile_user_regex(query)
913 .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?;
914
915 let mut matches = Vec::new();
916 let mut scratch = ToolSearchScratch::default();
917 for tool in catalog {
918 // tool_search loads definitions omitted from the current request. An
919 // eager tool is already present, so returning it as a cache candidate
920 // would misclassify it as rejected (the cache intentionally accepts
921 // deferred definitions only).
922 if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) {
923 continue;
924 }
925 scratch.load(tool);
926 if regex.is_match(&scratch.hay) {
927 matches.push(tool.name.clone());
928 }
929 if matches.len() >= max_results {
930 break;
931 }
932 }
933 Ok(matches)
934 }
935
936 fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str, max_results: usize) -> Vec<String> {
937 let terms: Vec<String> = query
938 .split_whitespace()
939 .map(|term| term.trim().to_lowercase())
940 .filter(|term| !term.is_empty())
941 .collect();
942 if terms.is_empty() {
943 return Vec::new();
944 }
945
946 let mut scored: Vec<(i64, String)> = Vec::new();
947 let mut scratch = ToolSearchScratch::default();
948 for tool in catalog {
949 if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) {
950 continue;
951 }
952 scratch.load(tool);
953 let mut score = 0i64;
954 for term in &terms {
955 if scratch.hay.contains(term) {
956 score += 1;
957 }
958 // Loop-invariant: lowered once by `load`, not once per term.
959 if scratch.name_lower.contains(term) {
960 score += 2;
961 }
962 }
963 if score > 0 {
964 scored.push((score, tool.name.clone()));
965 }
966 }
967 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
968 scored
969 .into_iter()
970 .take(max_results)
971 .map(|(_, name)| name)
972 .collect()
973 }
974
975 fn edit_distance(a: &str, b: &str) -> usize {
976 if a == b {
977 return 0;
978 }
979 if a.is_empty() {
980 return b.chars().count();
981 }
982 if b.is_empty() {
983 return a.chars().count();
984 }
985
986 let b_chars: Vec<char> = b.chars().collect();
987 let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
988 let mut curr = vec![0usize; b_chars.len() + 1];
989
990 for (i, a_ch) in a.chars().enumerate() {
991 curr[0] = i + 1;
992 for (j, b_ch) in b_chars.iter().enumerate() {
993 let cost = if a_ch == *b_ch { 0 } else { 1 };
994 let delete = prev[j + 1] + 1;
995 let insert = curr[j] + 1;
996 let substitute = prev[j] + cost;
997 curr[j + 1] = delete.min(insert).min(substitute);
998 }
999 std::mem::swap(&mut prev, &mut curr);
1000 }
1001
1002 prev[b_chars.len()]
1003 }
1004
1005 fn suggest_tool_names(catalog: &[Tool], requested: &str, limit: usize) -> Vec<String> {
1006 let requested = requested.trim().to_ascii_lowercase();
1007 if requested.is_empty() || limit == 0 {
1008 return Vec::new();
1009 }
1010
1011 let mut candidates: Vec<(u8, usize, String)> = Vec::new();
1012 for tool in catalog {
1013 let candidate = tool.name.to_ascii_lowercase();
1014 let prefix_match = candidate.starts_with(&requested) || requested.starts_with(&candidate);
1015 let contains_match = candidate.contains(&requested) || requested.contains(&candidate);
1016 let distance = edit_distance(&candidate, &requested);
1017 let close_typo = distance <= 3;
1018
1019 if !(prefix_match || contains_match || close_typo) {
1020 continue;
1021 }
1022
1023 let rank = if prefix_match {
1024 0
1025 } else if contains_match {
1026 1
1027 } else {
1028 2
1029 };
1030 candidates.push((rank, distance, tool.name.clone()));
1031 }
1032
1033 candidates.sort_by(|a, b| {
1034 a.0.cmp(&b.0)
1035 .then_with(|| a.1.cmp(&b.1))
1036 .then_with(|| a.2.cmp(&b.2))
1037 });
1038 candidates.dedup_by(|a, b| a.2 == b.2);
1039 candidates
1040 .into_iter()
1041 .take(limit)
1042 .map(|(_, _, name)| name)
1043 .collect()
1044 }
1045
1046 /// Catalog tools the engine injects itself rather than registering, plus the
1047 /// legacy tool-search spellings. Exposed so the read-only request projection
1048 /// can label their provenance as `synthetic` from the same source of truth as
1049 /// `is_synthetic_catalog_tool` test coverage instead of guessing.
1050 ///
1051 /// MCP-contributed names are deliberately *not* here: those resolve through the
1052 /// real pool, and stay unknown when the pool did not resolve them.
1053 /// [`MULTI_TOOL_PARALLEL_NAME`] is not here either — it is a call name the model
1054 /// may emit, never a catalog entry, so it can never appear in a transmitted
1055 /// tool array and has no catalog provenance to report.
1056 pub(super) fn default_synthetic_catalog_tool_names() -> Vec<String> {
1057 let mut names: Vec<String> = vec![
1058 TOOL_SEARCH_NAME.to_string(),
1059 LEGACY_TOOL_SEARCH_REGEX_NAME.to_string(),
1060 LEGACY_TOOL_SEARCH_BM25_NAME.to_string(),
1061 CODE_EXECUTION_TOOL_NAME.to_string(),
1062 JS_EXECUTION_TOOL_NAME.to_string(),
1063 EXECUTE_TOOLS_TOOL_NAME.to_string(),
1064 ];
1065 names.sort();
1066 names.dedup();
1067 names
1068 }
1069
1070 #[cfg(test)]
1071 fn is_synthetic_catalog_tool(name: &str) -> bool {
1072 is_tool_search_tool(name)
1073 || matches!(
1074 name,
1075 CODE_EXECUTION_TOOL_NAME | JS_EXECUTION_TOOL_NAME | EXECUTE_TOOLS_TOOL_NAME
1076 )
1077 || McpPool::is_mcp_tool(name)
1078 }
1079
1080 #[cfg(test)]
1081 pub(super) fn tool_catalog_consistency_issues(
1082 catalog: &[Tool],
1083 registry: &crate::tools::ToolRegistry,
1084 ) -> Vec<String> {
1085 let catalog_names = catalog
1086 .iter()
1087 .map(|tool| tool.name.as_str())
1088 .collect::<HashSet<_>>();
1089 let registry_api_tools = registry.to_api_tools();
1090 let registry_model_visible_names = registry_api_tools
1091 .iter()
1092 .map(|tool| tool.name.as_str())
1093 .collect::<HashSet<_>>();
1094 let mut issues = Vec::new();
1095
1096 for tool in catalog {
1097 if is_synthetic_catalog_tool(&tool.name) {
1098 continue;
1099 }
1100 if !registry.contains(&tool.name) {
1101 issues.push(format!(
1102 "catalog advertises '{}' but no registered handler exists",
1103 tool.name
1104 ));
1105 }
1106 }
1107
1108 for name in DEFAULT_ACTIVE_NATIVE_TOOLS {
1109 if registry_model_visible_names.contains(name) && !catalog_names.contains(name) {
1110 issues.push(format!(
1111 "registered core tool '{name}' is missing from the model/search catalog"
1112 ));
1113 }
1114 }
1115
1116 issues.sort();
1117 issues
1118 }
1119
1120 pub(super) fn missing_tool_error_message(tool_name: &str, catalog: &[Tool]) -> String {
1121 // Dogfood A5 (#4092): models mid-checklist sometimes emit each list entry
1122 // as its own tool call named `item`/`todo`/... . Fuzzy suggestions are
1123 // actively misleading there ("Did you mean: note, tts?"); name the actual
1124 // fix instead.
1125 if matches!(
1126 tool_name,
1127 "item" | "items" | "todo" | "todos" | "checklist" | "checklist_item" | "plan_item"
1128 ) {
1129 return format!(
1130 "Tool '{tool_name}' is not available in the current tool catalog. \
1131 Checklist entries are not separate tool calls — write the whole list \
1132 in one `todo_write` call with a `todos` array of \
1133 {{content, status}} objects."
1134 );
1135 }
1136 let suggestions = suggest_tool_names(catalog, tool_name, 3);
1137 let shell_hint = if is_shell_tool_name(tool_name) {
1138 Some(shell_tool_allow_shell_hint())
1139 } else {
1140 None
1141 };
1142 // #5123-class: `exec_shell` was replaced by lowercase `bash`. Name it first —
1143 // otherwise the error misdiagnoses a retired-name call as an allow_shell
1144 // permission problem and sends the model fixing the wrong thing.
1145 if tool_name == "exec_shell" {
1146 return format!(
1147 "Tool '{tool_name}' is not available in the current tool catalog. \
1148 `exec_shell` was replaced by `bash` — call `bash` with a `command` instead. \
1149 If `bash` is also absent: {shell_hint}.",
1150 shell_hint = shell_tool_allow_shell_hint()
1151 );
1152 }
1153 if matches!(
1154 tool_name,
1155 "exec_shell_wait" | "exec_shell_interact" | "exec_shell_cancel"
1156 ) {
1157 return format!(
1158 "Tool '{tool_name}' is not available in the current tool catalog. \
1159 Lowercase `bash` is foreground-only; use {TOOL_SEARCH_NAME} to discover shell session controls."
1160 );
1161 }
1162 if suggestions.is_empty() {
1163 if let Some(shell_hint) = shell_hint {
1164 return format!(
1165 "Tool '{tool_name}' is not available in the current tool catalog. \
1166 {shell_hint}, or use {TOOL_SEARCH_NAME} with a short query."
1167 );
1168 }
1169 return format!(
1170 "Tool '{tool_name}' is not available in the current tool catalog. \
1171 Verify mode/feature flags, or use {TOOL_SEARCH_NAME} with a short query."
1172 );
1173 }
1174
1175 let suggestion_text = format!("Did you mean: {}?", suggestions.join(", "));
1176 if let Some(shell_hint) = shell_hint {
1177 return format!(
1178 "Tool '{tool_name}' is not available in the current tool catalog. \
1179 {suggestion_text} {shell_hint}. \
1180 You can also use {TOOL_SEARCH_NAME} to discover tools."
1181 );
1182 }
1183
1184 format!(
1185 "Tool '{tool_name}' is not available in the current tool catalog. \
1186 {suggestion_text} You can also use {TOOL_SEARCH_NAME} to discover tools."
1187 )
1188 }
1189
1190 fn shell_tool_allow_shell_hint() -> &'static str {
1191 "Shell tools are absent because this session or profile disabled shell access, \
1192 commonly via top-level `allow_shell = false`. \
1193 Work mode exposes shell by default with approval gating unless disabled. \
1194 Run `/config allow_shell true` for this session or add `--save` for future sessions; \
1195 the next turn will expose shell again"
1196 }
1197
1198 fn is_shell_tool_name(tool_name: &str) -> bool {
1199 matches!(
1200 tool_name,
1201 "exec_shell"
1202 | "exec_shell_wait"
1203 | "exec_shell_interact"
1204 | "task_shell_start"
1205 | "task_shell_wait"
1206 )
1207 }
1208
1209 pub(super) fn maybe_hydrate_requested_deferred_tool(
1210 tool_name: &str,
1211 tool_input: &Value,
1212 catalog: &[Tool],
1213 active_tools_at_batch_start: &HashSet<String>,
1214 hydrated_tools_this_batch: &mut HashSet<String>,
1215 ) -> Option<ToolResult> {
1216 let def = catalog.iter().find(|def| def.name == tool_name)?;
1217
1218 if !def.defer_loading.unwrap_or(false) || active_tools_at_batch_start.contains(tool_name) {
1219 return None;
1220 }
1221
1222 hydrated_tools_this_batch.insert(tool_name.to_string());
1223 Some(deferred_tool_schema_hydration_result(def, tool_input))
1224 }
1225
1226 #[cfg(test)]
1227 pub(super) fn preflight_requested_deferred_tool(
1228 tool_name: &str,
1229 tool_input: &Value,
1230 catalog: &[Tool],
1231 active_tools: &mut HashSet<String>,
1232 ) -> Option<ToolResult> {
1233 let active_tools_at_batch_start = active_tools.clone();
1234 let mut hydrated_tools_this_batch = HashSet::new();
1235 let result = maybe_hydrate_requested_deferred_tool(
1236 tool_name,
1237 tool_input,
1238 catalog,
1239 &active_tools_at_batch_start,
1240 &mut hydrated_tools_this_batch,
1241 );
1242 active_tools.extend(hydrated_tools_this_batch);
1243 result
1244 }
1245
1246 fn deferred_tool_schema_hydration_result(tool: &Tool, tool_input: &Value) -> ToolResult {
1247 let expected = schema_fields(&tool.input_schema);
1248 let required = schema_required_fields(&tool.input_schema);
1249 let received = received_field_names(tool_input);
1250 let missing = required
1251 .iter()
1252 .filter(|field| !received.contains(field))
1253 .cloned()
1254 .collect::<Vec<_>>();
1255 let unexpected = received
1256 .iter()
1257 .filter(|field| !expected.iter().any(|expected| &expected.name == *field))
1258 .cloned()
1259 .collect::<Vec<_>>();
1260 let corrections = likely_field_corrections(&received, &expected, &tool.name);
1261
1262 let mut lines = vec![
1263 format!("Tool `{}` was deferred and has now been loaded.", tool.name),
1264 String::new(),
1265 "The tool was not executed. Retry with the loaded schema.".to_string(),
1266 String::new(),
1267 "Expected fields:".to_string(),
1268 ];
1269 if expected.is_empty() {
1270 lines.push(" (none)".to_string());
1271 } else {
1272 for field in &expected {
1273 let required_marker = if required.contains(&field.name) {
1274 " required"
1275 } else {
1276 ""
1277 };
1278 lines.push(format!(
1279 " {}: {}{}",
1280 field.name, field.kind, required_marker
1281 ));
1282 }
1283 }
1284 lines.push(String::new());
1285 lines.push("Received fields:".to_string());
1286 if received.is_empty() {
1287 lines.push(" (none)".to_string());
1288 } else {
1289 lines.push(format!(" {}", received.join(", ")));
1290 }
1291 if !missing.is_empty() {
1292 lines.push(String::new());
1293 lines.push("Missing required fields:".to_string());
1294 lines.push(format!(" {}", missing.join(", ")));
1295 }
1296 if !unexpected.is_empty() {
1297 lines.push(String::new());
1298 lines.push("Unexpected fields:".to_string());
1299 lines.push(format!(" {}", unexpected.join(", ")));
1300 }
1301 if !corrections.is_empty() {
1302 lines.push(String::new());
1303 lines.push("Likely corrections:".to_string());
1304 for correction in &corrections {
1305 lines.push(format!(" {correction}"));
1306 }
1307 }
1308
1309 ToolResult::success(lines.join("\n")).with_metadata(json!({
1310 "event": "tool.schema_hydrated",
1311 "tool": tool.name,
1312 "executed": false,
1313 "retry_required": true,
1314 "reason": "deferred_tool_first_use",
1315 "deferred_tool_loaded": true,
1316 "tool_name": tool.name,
1317 "expected_fields": expected.iter().map(|field| field.name.clone()).collect::<Vec<_>>(),
1318 "received_fields": received,
1319 "missing_required_fields": missing,
1320 "unexpected_fields": unexpected,
1321 "likely_corrections": corrections,
1322 }))
1323 }
1324
1325 #[derive(Debug, Clone)]
1326 struct SchemaField {
1327 name: String,
1328 kind: String,
1329 }
1330
1331 fn schema_fields(schema: &Value) -> Vec<SchemaField> {
1332 let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
1333 return Vec::new();
1334 };
1335 let mut fields = properties
1336 .iter()
1337 .map(|(name, spec)| SchemaField {
1338 name: name.clone(),
1339 kind: schema_type_label(spec),
1340 })
1341 .collect::<Vec<_>>();
1342 fields.sort_by(|a, b| a.name.cmp(&b.name));
1343 fields
1344 }
1345
1346 fn schema_required_fields(schema: &Value) -> Vec<String> {
1347 let mut required = schema
1348 .get("required")
1349 .and_then(Value::as_array)
1350 .into_iter()
1351 .flatten()
1352 .filter_map(|value| value.as_str().map(str::to_string))
1353 .collect::<Vec<_>>();
1354 required.sort();
1355 required
1356 }
1357
1358 fn schema_type_label(spec: &Value) -> String {
1359 let Some(kind) = spec.get("type").and_then(Value::as_str) else {
1360 return "value".to_string();
1361 };
1362 if let Some(values) = spec.get("enum").and_then(Value::as_array) {
1363 let labels = values.iter().filter_map(Value::as_str).collect::<Vec<_>>();
1364 if !labels.is_empty() {
1365 return format!("{kind} ({})", labels.join(" | "));
1366 }
1367 }
1368 kind.to_string()
1369 }
1370
1371 fn received_field_names(input: &Value) -> Vec<String> {
1372 let mut fields = input
1373 .as_object()
1374 .map(|object| object.keys().cloned().collect::<Vec<_>>())
1375 .unwrap_or_default();
1376 fields.sort();
1377 fields
1378 }
1379
1380 fn likely_field_corrections(
1381 received: &[String],
1382 expected: &[SchemaField],
1383 tool_name: &str,
1384 ) -> Vec<String> {
1385 let has_expected = |name: &str| expected.iter().any(|field| field.name == name);
1386 let has_received = |name: &str| received.iter().any(|field| field == name);
1387 let mut corrections = Vec::new();
1388
1389 if has_received("old_string") && has_expected("search") {
1390 corrections.push("old_string -> search".to_string());
1391 } else if has_received("old_str") && has_expected("search") {
1392 corrections.push("old_str -> search".to_string());
1393 }
1394 if has_received("new_string") && has_expected("replace") {
1395 corrections.push("new_string -> replace".to_string());
1396 } else if has_received("new_str") && has_expected("replace") {
1397 corrections.push("new_str -> replace".to_string());
1398 } else if has_received("replacement") && has_expected("replace") {
1399 corrections.push("replacement -> replace".to_string());
1400 }
1401 if matches!(tool_name, "checklist_update" | "todo_update") && has_received("todos") {
1402 corrections.push(
1403 "Use todo_write to replace the full list, or retry checklist_update/todo_update with id and status."
1404 .to_string(),
1405 );
1406 }
1407 // RLM source fields are easy to misname (#2659). rlm_open takes exactly one
1408 // of file_path / content / url / session_object; nudge common wrong names
1409 // toward those. The unified `rlm` tool carries the same fields for
1410 // action=open, so it gets the same correction.
1411 if matches!(tool_name, "rlm_open" | "rlm") {
1412 for wrong in [
1413 "prompt",
1414 "resident_file",
1415 "text",
1416 "body",
1417 "path",
1418 "file",
1419 "source",
1420 ] {
1421 if has_received(wrong)
1422 && !has_received("file_path")
1423 && !has_received("content")
1424 && !has_received("url")
1425 && !has_received("session_object")
1426 {
1427 corrections.push(format!("{wrong} -> file_path (local file), content (inline text), url, or session_object"));
1428 }
1429 }
1430 }
1431 corrections
1432 }
1433
1434 #[cfg(test)]
1435 pub(super) fn execute_tool_search(
1436 tool_name: &str,
1437 input: &serde_json::Value,
1438 catalog: &[Tool],
1439 active_tools: &mut HashSet<String>,
1440 ) -> Result<ToolResult, ToolError> {
1441 execute_tool_search_inner(tool_name, input, catalog, active_tools, None)
1442 }
1443
1444 /// Execute tool search while retaining activated schemas in the bounded
1445 /// conversation cache. Kept separate from the test-only pure search helper so existing
1446 /// pure catalog tests and compatibility callers do not need an engine session.
1447 pub(crate) fn execute_tool_search_with_cache(
1448 tool_name: &str,
1449 input: &serde_json::Value,
1450 catalog: &[Tool],
1451 active_tools: &mut HashSet<String>,
1452 cache: &mut crate::core::session::ToolActivationCache,
1453 ) -> Result<ToolResult, ToolError> {
1454 execute_tool_search_inner(tool_name, input, catalog, active_tools, Some(cache))
1455 }
1456
1457 fn execute_tool_search_inner(
1458 tool_name: &str,
1459 input: &serde_json::Value,
1460 catalog: &[Tool],
1461 active_tools: &mut HashSet<String>,
1462 cache: Option<&mut crate::core::session::ToolActivationCache>,
1463 ) -> Result<ToolResult, ToolError> {
1464 let query = required_str(input, "query")?;
1465 let match_kind = match tool_name {
1466 LEGACY_TOOL_SEARCH_REGEX_NAME => "regex",
1467 LEGACY_TOOL_SEARCH_BM25_NAME => "bm25",
1468 _ => optional_str(input, "match")?.unwrap_or("bm25"),
1469 };
1470 if !matches!(match_kind, "bm25" | "regex") {
1471 return Err(ToolError::invalid_input(format!(
1472 "Unsupported match algorithm '{match_kind}'. Expected one of: bm25, regex"
1473 )));
1474 }
1475 let max_results = usize::try_from(optional_u64(
1476 input,
1477 "max_results",
1478 TOOL_SEARCH_DEFAULT_MAX_RESULTS as u64,
1479 )?)
1480 .unwrap_or(TOOL_SEARCH_DEFAULT_MAX_RESULTS)
1481 .clamp(1, TOOL_SEARCH_MAX_RESULTS_LIMIT);
1482 let mut discovered = if match_kind == "regex" {
1483 discover_tools_with_regex(catalog, query, max_results)?
1484 } else {
1485 discover_tools_with_bm25_like(catalog, query, max_results)
1486 };
1487 let remaining_results = max_results.saturating_sub(discovered.len());
1488 let unavailable = if match_kind == "regex" {
1489 unavailable_core_action_tools_with_regex(catalog, query, remaining_results)?
1490 } else {
1491 unavailable_core_action_tools_with_bm25_like(catalog, query, remaining_results)
1492 };
1493
1494 let mut cache_rejected = Vec::new();
1495 if let Some(cache) = cache {
1496 let delta = cache.activate(catalog, &discovered);
1497 remove_evicted_cache_activations(catalog, active_tools, delta.evicted);
1498 cache_rejected = delta.rejected;
1499 discovered = delta.admitted;
1500 }
1501 for name in &discovered {
1502 active_tools.insert(name.clone());
1503 }
1504
1505 let references = discovered
1506 .iter()
1507 .map(|name| json!({"type": "tool_reference", "tool_name": name}))
1508 .collect::<Vec<_>>();
1509 let mut unavailable_references = unavailable
1510 .iter()
1511 .map(|fallback| {
1512 json!({
1513 "type": "unavailable_tool_reference",
1514 "tool_name": fallback.name,
1515 "reason": fallback.unavailable_reason,
1516 })
1517 })
1518 .collect::<Vec<_>>();
1519 unavailable_references.extend(cache_rejected.iter().map(|name| {
1520 json!({
1521 "type": "unavailable_tool_reference",
1522 "tool_name": name,
1523 "reason": "The tool schema exceeds the bounded conversation toolbox (8 cached tools, 16KiB of added serialized schemas). Narrow the search or use a smaller matching tool."
1524 })
1525 }));
1526
1527 let payload = json!({
1528 "type": "tool_search_tool_search_result",
1529 "tool_references": references,
1530 "unavailable_tool_references": unavailable_references.clone(),
1531 });
1532
1533 Ok(ToolResult {
1534 content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()),
1535 success: true,
1536 metadata: Some(json!({
1537 "tool_references": discovered,
1538 "unavailable_tool_references": unavailable_references,
1539 })),
1540 })
1541 }
1542
1543 pub(super) async fn execute_code_execution_tool(
1544 input: &serde_json::Value,
1545 workspace: &Path,
1546 ) -> Result<ToolResult, ToolError> {
1547 let code = required_str(input, "code")?;
1548
1549 // Resolve the locally-installed Python interpreter we cached at
1550 // catalog-build time. If it's absent now (somehow registered but
1551 // disappeared between startup and this call — concurrent uninstall,
1552 // PATH change, etc.) the ExternalTool::tokio_command() will return
1553 // None and we fail fast with a clear message.
1554 //
1555 // Write the code to a temp file and execute it as a script rather
1556 // than passing it via `-c "<code>"`. Reasons:
1557 // * `-c` has length limits (argv) on Windows.
1558 // * Multiline code with quote nesting is brittle through `-c`.
1559 // * Tracebacks reference a real filename instead of `<string>`,
1560 // so the model can interpret line numbers correctly.
1561 // Tempfile lives only for the duration of this execution; Drop
1562 // removes it. We use `.py` so any shebang / encoding-sniffer
1563 // logic in the interpreter behaves normally.
1564 let temp_dir = tempfile::tempdir()
1565 .map_err(|e| ToolError::execution_failed(format!("tempdir failed: {e}")))?;
1566 let script_path = temp_dir.path().join("code_execution.py");
1567 tokio::fs::write(&script_path, code)
1568 .await
1569 .map_err(|e| ToolError::execution_failed(format!("tempfile write failed: {e}")))?;
1570
1571 let mut cmd = crate::dependencies::Python::tokio_command().ok_or_else(|| {
1572 ToolError::execution_failed(
1573 "code_execution: Python interpreter became unavailable".to_string(),
1574 )
1575 })?;
1576 cmd.arg(&script_path).current_dir(workspace);
1577
1578 let output = tokio::time::timeout(Duration::from_secs(120), cmd.output())
1579 .await
1580 .map_err(|_| ToolError::Timeout { seconds: 120 })
1581 .and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?;
1582
1583 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1584 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1585 let return_code = output.status.code().unwrap_or(-1);
1586 let success = output.status.success();
1587 let payload = json!({
1588 "type": "code_execution_result",
1589 "stdout": stdout,
1590 "stderr": stderr,
1591 "return_code": return_code,
1592 "content": [],
1593 });
1594
1595 Ok(ToolResult {
1596 content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()),
1597 success,
1598 metadata: Some(payload),
1599 })
1600 }
1601
1602 #[cfg(test)]
1603 #[path = "tool_catalog/tests.rs"]
1604 mod synthetic_name_tests;
1605
1605 lines RUST