返回 CodeWhale
registry.rs
根目录 / crates / tui / src / tools / registry.rs
1 //! Tool registry for managing and executing tools.
2 //!
3 //! The registry provides:
4 //! - Dynamic tool registration
5 //! - Tool lookup by name
6 //! - Conversion to API Tool format
7 //! - Filtering by capability
8
9 use std::collections::HashMap;
10 use std::sync::{Arc, OnceLock};
11
12 use std::path::{Path, PathBuf};
13
14 use codewhale_protocol::runtime::DynamicToolSpec;
15 use serde_json::Value;
16
17 use crate::client::DeepSeekClient;
18 use crate::models::Tool;
19 use crate::tools::goal::SharedGoalState;
20
21 use super::schema_canonicalize;
22 use super::schema_sanitize;
23 use super::spec::{
24 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
25 };
26
27 // === Types ===
28
29 /// Registry that holds all available tools.
30 pub struct ToolRegistry {
31 tools: HashMap<String, Arc<dyn ToolSpec>>,
32 context: ToolContext,
33 /// Memoised serialised tool catalog. Rebuilt lazily on first
34 /// `to_api_tools` call after a mutation; pinned across reads so the
35 /// description and schema bytes stay byte-stable for DeepSeek's KV
36 /// prefix cache. Invalidated on `register` / `remove_tool`.
37 api_cache: OnceLock<Vec<Tool>>,
38 }
39
40 impl ToolRegistry {
41 /// Create a new empty registry with the given context.
42 #[must_use]
43 pub fn new(context: ToolContext) -> Self {
44 Self {
45 tools: HashMap::new(),
46 context,
47 api_cache: OnceLock::new(),
48 }
49 }
50
51 /// Register a tool in the registry.
52 pub fn register(&mut self, tool: Arc<dyn ToolSpec>) {
53 let name = tool.name().to_string();
54 if self.tools.insert(name.clone(), tool).is_some() {
55 tracing::warn!("Overwriting existing tool: {}", name);
56 }
57 self.invalidate_api_cache();
58 }
59
60 /// Register multiple tools at once.
61 pub fn register_all(&mut self, tools: Vec<Arc<dyn ToolSpec>>) {
62 for tool in tools {
63 self.register(tool);
64 }
65 }
66
67 /// Get a tool by name.
68 #[must_use]
69 pub fn get(&self, name: &str) -> Option<Arc<dyn ToolSpec>> {
70 self.tools.get(name).cloned()
71 }
72
73 /// Check if a tool exists.
74 #[must_use]
75 pub fn contains(&self, name: &str) -> bool {
76 self.tools.contains_key(name)
77 }
78
79 /// Get all registered tool names.
80 #[must_use]
81 pub fn names(&self) -> Vec<&str> {
82 self.tools.keys().map(std::string::String::as_str).collect()
83 }
84
85 /// Get all registered tools.
86 #[must_use]
87 pub fn all(&self) -> Vec<Arc<dyn ToolSpec>> {
88 self.tools.values().cloned().collect()
89 }
90
91 /// Execute a tool by name, returning the full `ToolResult`.
92 pub async fn execute_full(&self, name: &str, input: Value) -> Result<ToolResult, ToolError> {
93 let tool = self
94 .get(name)
95 .ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?;
96
97 enforce_tool_authority(name, &input, tool.as_ref(), &self.context)?;
98 tool.execute(input, &self.context).await
99 }
100
101 /// Execute a tool with an optional context override.
102 ///
103 /// This is used for retrying tools with elevated sandbox policies.
104 /// After execution, results are stamped with adaptive evidence routing.
105 pub async fn execute_full_with_context(
106 &self,
107 name: &str,
108 input: Value,
109 context_override: Option<&ToolContext>,
110 ) -> Result<ToolResult, ToolError> {
111 let tool = self
112 .get(name)
113 .ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?;
114
115 let ctx = context_override.unwrap_or(&self.context);
116 enforce_tool_authority(name, &input, tool.as_ref(), ctx)?;
117 let mut result = tool.execute(input.clone(), ctx).await?;
118
119 // Adaptive evidence routing (#4619) is storage-free here because this
120 // layer does not own a call id. The engine/subagent completion boundary
121 // publishes the exact artifact. Classic workshop previews remain an
122 // explicit local rollback path.
123 let raw_bypass = input.get("raw").and_then(|v| v.as_bool()).unwrap_or(false);
124
125 if let Some(router) = ctx.large_output_router.as_ref() {
126 use crate::tools::large_output_router::{
127 EvidenceRouting, LargeOutputRouter, RouteDecision, classic_output_routing_enabled,
128 };
129 if !classic_output_routing_enabled() {
130 let (estimated_routing, estimated_tokens, threshold) =
131 router.evidence_routing(name, &result, raw_bypass);
132 let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
133 if let Some(object) = metadata.as_object_mut() {
134 // A tool that self-bounds its output behind its own
135 // recovery contract (e.g. read_file's `next_start_line`
136 // paging) declares its routing itself; the size estimate
137 // must not override that and double-wrap the result.
138 let routing = object
139 .get("evidence_routing")
140 .cloned()
141 .and_then(|value| serde_json::from_value::<EvidenceRouting>(value).ok())
142 .unwrap_or(estimated_routing);
143 object.insert(
144 "evidence_routing".to_string(),
145 serde_json::to_value(routing)
146 .unwrap_or_else(|_| serde_json::json!("inline")),
147 );
148 object.insert(
149 "evidence_estimated_tokens".to_string(),
150 estimated_tokens.into(),
151 );
152 object.insert("evidence_threshold_tokens".to_string(), threshold.into());
153 }
154 return Ok(result);
155 }
156 match router.route(name, &result, raw_bypass) {
157 RouteDecision::PassThrough => {}
158 RouteDecision::Synthesise {
159 estimated_tokens,
160 threshold,
161 } => {
162 // Store the raw output in the workshop variable store.
163 if let Some(vars_arc) = ctx.workshop_vars.as_ref() {
164 let mut vars = vars_arc.lock().await;
165 vars.store_raw(name, &result.content);
166 }
167
168 // Build a terse synthesis using the same model the registry
169 // was constructed for (workshop Flash model). For now we
170 // produce a structured header + truncated preview without
171 // a live API call so the engine stays dependency-free at
172 // the registry layer. A follow-up can wire in the Flash
173 // client when the async LLM call is safe here.
174 let preview_chars = 1_200usize;
175 let preview: String = result.content.chars().take(preview_chars).collect();
176 let ellipsis = if result.content.chars().count() > preview_chars {
177 "\n… [output truncated — full text in workshop variable `last_tool_result`]"
178 } else {
179 ""
180 };
181 let synthesis = format!("{preview}{ellipsis}");
182 let wrapped = LargeOutputRouter::wrap_synthesis(
183 name,
184 &synthesis,
185 estimated_tokens,
186 threshold,
187 );
188 tracing::debug!(
189 tool = name,
190 estimated_tokens,
191 threshold,
192 "large-output routed through workshop"
193 );
194 return Ok(ToolResult::success(wrapped));
195 }
196 }
197 }
198
199 Ok(result)
200 }
201
202 /// Get the current tool context.
203 #[must_use]
204 pub fn context(&self) -> &ToolContext {
205 &self.context
206 }
207
208 /// Convert all tools to API Tool format for sending to the model.
209 ///
210 /// Output is sorted by tool name for **prefix-cache stability** (#263).
211 /// Rust's `HashMap` uses a randomly-seeded hasher per process, so a raw
212 /// `self.tools.values()` iteration emits tools in a different order on
213 /// every `deepseek` launch, invalidating DeepSeek's KV prefix cache for
214 /// every cross-session resume. Sorting here matches the way Claude Code
215 /// stabilises its tool array (`assembleToolPool` in their reference).
216 ///
217 /// The serialised catalog is memoised on first call and pinned across
218 /// reads so each tool's `description()` and `input_schema()` are sampled
219 /// exactly once per registration. MCP adapters whose upstream description
220 /// drifts on reconnect would otherwise rewrite the catalog mid-session
221 /// and bust the prefix cache. The cache is invalidated on `register`,
222 /// `remove`, and `clear`.
223 #[must_use]
224 pub fn to_api_tools(&self) -> Vec<Tool> {
225 self.api_cache
226 .get_or_init(|| self.build_api_tools())
227 .clone()
228 }
229
230 fn build_api_tools(&self) -> Vec<Tool> {
231 let mut tools: Vec<&Arc<dyn ToolSpec>> = self.tools.values().collect();
232 tools.sort_by(|a, b| a.name().cmp(b.name()));
233 tools
234 .into_iter()
235 .filter(|tool| tool.model_visible())
236 .map(|tool| {
237 let mut schema = tool.input_schema();
238 schema_sanitize::sanitize(&mut schema);
239 schema_canonicalize::canonicalize_schema(&mut schema);
240 Tool {
241 tool_type: None,
242 name: tool.name().to_string(),
243 description: tool.description().to_string(),
244 input_schema: schema,
245 allowed_callers: Some(vec!["direct".to_string()]),
246 defer_loading: Some(tool.defer_loading()),
247 input_examples: None,
248 strict: None,
249 cache_control: None,
250 }
251 })
252 .collect()
253 }
254
255 fn invalidate_api_cache(&mut self) {
256 self.api_cache = OnceLock::new();
257 }
258
259 /// Convert tools to API Tool format with optional cache control on the last tool.
260 #[must_use]
261 pub fn to_api_tools_with_cache(&self, enable_cache: bool) -> Vec<Tool> {
262 let mut tools = self.to_api_tools();
263 if enable_cache && let Some(last) = tools.last_mut() {
264 last.cache_control = Some(crate::models::CacheControl {
265 cache_type: "ephemeral".to_string(),
266 });
267 }
268 tools
269 }
270
271 /// Flatten every registered tool into the exact facts the read-only
272 /// request projection is allowed to report: name, description, model
273 /// visibility, declared capabilities, declared approval requirement, and
274 /// whether the tool came from the plugin surface.
275 ///
276 /// This hands out *data*, never tool objects, so the projection layer
277 /// cannot execute anything. Output is sorted by name and does not touch the
278 /// registry's own ordering or the memoised API catalog.
279 #[must_use]
280 pub fn registry_facts(
281 &self,
282 plugin_names: &std::collections::HashSet<String>,
283 ) -> Vec<crate::tool_inspection::RegistryFacts> {
284 let mut facts: Vec<crate::tool_inspection::RegistryFacts> = self
285 .tools
286 .values()
287 .map(|tool| crate::tool_inspection::RegistryFacts {
288 name: tool.name().to_string(),
289 description: tool.description().to_string(),
290 model_visible: tool.model_visible(),
291 capabilities: tool
292 .capabilities()
293 .iter()
294 .map(|capability| format!("{capability:?}"))
295 .collect(),
296 approval: format!("{:?}", tool.approval_requirement()),
297 plugin: plugin_names.contains(tool.name()),
298 })
299 .collect();
300 facts.sort_by(|a, b| a.name.cmp(&b.name));
301 facts
302 }
303
304 /// Resolve a non-canonical tool name to a registered canonical name.
305 ///
306 /// Runs a deterministic ladder against the registered tool names:
307 /// 1. Lowercase exact match.
308 /// 2. Hyphens/spaces → underscores (read-file → read_file).
309 /// 3. CamelCase → snake_case (ReadFile → read_file).
310 /// 4. Strip trailing `_tool` / `-tool` suffix (twice).
311 ///
312 /// Returns `None` when no normalization matches (the caller surfaces
313 /// "Unknown tool … did you mean: …"). There is deliberately **no fuzzy
314 /// step**: a prefix guess over the registry would execute an arbitrary
315 /// sibling tool the model never asked for (#5123-class) — a hallucinated
316 /// name must fail, never dispatch.
317 #[must_use]
318 pub fn resolve(&self, requested: &str) -> Option<&str> {
319 let names: Vec<&str> = self.tools.keys().map(String::as_str).collect();
320 let lower = requested.to_lowercase();
321
322 // 1. ASCII case-insensitive exact
323 if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {
324 return Some(n);
325 }
326 // 2. hyphen/space → underscore
327 let snaked = lower.replace(['-', ' '], "_");
328 if let Some(n) = names.iter().find(|n| **n == snaked) {
329 return Some(n);
330 }
331 // 3. CamelCase → snake_case
332 let cc = to_snake_case(requested);
333 if let Some(n) = names.iter().find(|n| **n == cc) {
334 return Some(n);
335 }
336 // 4. strip _tool/-tool/tool suffix, twice
337 let mut stripped = cc.clone();
338 for _ in 0..2 {
339 for suf in ["_tool", "-tool", "tool"] {
340 if let Some(s) = stripped.strip_suffix(suf) {
341 stripped = s.to_string();
342 break;
343 }
344 }
345 }
346 if !stripped.is_empty()
347 && let Some(n) = names.iter().find(|n| **n == stripped)
348 {
349 return Some(n);
350 }
351 None
352 }
353
354 /// Remove a tool from the registry by name. Returns `true` if the tool
355 /// was present and removed, `false` if no tool with that name existed.
356 pub fn remove_tool(&mut self, name: &str) -> bool {
357 let existed = self.tools.remove(name).is_some();
358 if existed {
359 self.invalidate_api_cache();
360 }
361 existed
362 }
363
364 /// Apply config.toml tool overrides to this registry.
365 ///
366 /// For each entry in `overrides`:
367 /// - `Disabled` removes the tool.
368 /// - `Script` / `Command` replaces the tool with the user's implementation.
369 ///
370 /// `plugin_dir` is used as the base for relative script paths.
371 pub fn apply_overrides(
372 &mut self,
373 overrides: &std::collections::HashMap<String, crate::config::ToolOverride>,
374 plugin_dir: &Path,
375 ) {
376 for (tool_name, override_cfg) in overrides {
377 match override_cfg {
378 crate::config::ToolOverride::Disabled => {
379 if self.remove_tool(tool_name) {
380 tracing::info!("Tool '{}' disabled via config override", tool_name);
381 } else {
382 tracing::warn!("Cannot disable tool '{}': not registered", tool_name);
383 }
384 }
385 _ => {
386 // Script and Command overrides create replacement tools.
387 use crate::tools::plugin::tool_from_override;
388 match tool_from_override(tool_name, override_cfg, plugin_dir) {
389 Some(replacement) => {
390 self.register(replacement);
391 tracing::info!("Tool '{}' replaced via config override", tool_name);
392 }
393 None => {
394 if self.remove_tool(tool_name) {
395 tracing::warn!(
396 "Tool '{}' override did not create a replacement; removed the original tool to avoid override fallthrough",
397 tool_name
398 );
399 } else {
400 tracing::warn!(
401 "Tool '{}' override did not create a replacement and no registered tool existed",
402 tool_name
403 );
404 }
405 }
406 }
407 }
408 }
409 }
410 }
411
412 /// Load and register plugin tools from a directory.
413 ///
414 /// Each script with valid frontmatter (`# name:`, `# description:`, etc.)
415 /// becomes a registered `ScriptPluginTool`. Tools whose name matches an
416 /// already-registered tool will overwrite it.
417 pub fn load_plugins(&mut self, plugin_dir: &Path) {
418 if !plugin_dir.exists() {
419 tracing::debug!(
420 "Plugin directory {} does not exist, skipping",
421 plugin_dir.display()
422 );
423 return;
424 }
425 let plugins = crate::tools::plugin::load_plugin_tools(plugin_dir);
426 let count = plugins.len();
427 for tool in plugins {
428 self.register(tool);
429 }
430 if count > 0 {
431 tracing::info!(
432 "Loaded {count} plugin tool(s) from {}",
433 plugin_dir.display()
434 );
435 }
436 }
437 }
438
439 fn enforce_tool_authority(
440 name: &str,
441 input: &Value,
442 tool: &dyn ToolSpec,
443 context: &ToolContext,
444 ) -> Result<(), ToolError> {
445 let Some(authority) = context.tool_authority.as_ref() else {
446 return Ok(());
447 };
448 let capabilities = tool.capabilities();
449 if matches!(name, "Bash" | "exec_shell" | "Run") {
450 return Err(ToolError::permission_denied(format!(
451 "worker '{}' cannot run {name}: arbitrary command execution is outside its machine-readable authority envelope",
452 authority.owner
453 )));
454 }
455 if name == "Git" || name.starts_with("git_") || name == "review" {
456 return Err(ToolError::permission_denied(format!(
457 "worker '{}' cannot run {name}: repository-configured Git helpers cannot prove read-only execution under its machine-readable authority envelope",
458 authority.owner
459 )));
460 }
461 if tool.is_read_only_for(input) {
462 return Ok(());
463 }
464 if capabilities.contains(&ToolCapability::ExecutesCode) {
465 return Err(ToolError::permission_denied(format!(
466 "worker '{}' cannot run {name}: code or child execution is outside its machine-readable authority envelope",
467 authority.owner
468 )));
469 }
470 if let Some(paths) = authority_mutation_paths(name, input)? {
471 if paths.is_empty() {
472 return Err(ToolError::permission_denied(format!(
473 "worker '{}' mutation through {name} did not expose a bounded file target",
474 authority.owner
475 )));
476 }
477 for path in paths {
478 if !authority.permits_mutation_path(context, &path)? {
479 return Err(ToolError::permission_denied(format!(
480 "worker '{}' cannot mutate '{path}' outside its machine-readable authority envelope",
481 authority.owner
482 )));
483 }
484 }
485 return Ok(());
486 }
487 Err(ToolError::permission_denied(format!(
488 "worker '{}' cannot run mutating tool {name}: the call has no authorized file target",
489 authority.owner
490 )))
491 }
492
493 fn authority_mutation_paths(name: &str, input: &Value) -> Result<Option<Vec<String>>, ToolError> {
494 let is_patch = name == "apply_patch"
495 || (name == "File" && input.get("action").and_then(Value::as_str) == Some("patch"));
496 if is_patch {
497 let mut patch_input = input.clone();
498 if let Some(object) = patch_input.as_object_mut() {
499 object.remove("action");
500 }
501 let paths = crate::tools::apply_patch::preflight_apply_patch(&patch_input)
502 .map_err(|error| ToolError::invalid_input(error.to_string()))?
503 .touched_files;
504 return Ok(Some(paths));
505 }
506 let path_bound = matches!(name, "write_file" | "edit_file" | "fim_edit")
507 || (name == "File"
508 && input
509 .get("action")
510 .and_then(Value::as_str)
511 .is_some_and(|action| matches!(action, "write" | "edit")))
512 || (name == "pandoc_convert" && input.get("output_path").is_some());
513 if !path_bound {
514 return Ok(None);
515 }
516 Ok(Some(
517 input
518 .get("path")
519 .or_else(|| input.get("output_path"))
520 .and_then(Value::as_str)
521 .map(|path| vec![path.to_string()])
522 .unwrap_or_default(),
523 ))
524 }
525
526 /// Builder for constructing a `ToolRegistry` with common tools.
527 pub struct ToolRegistryBuilder {
528 tools: Vec<Arc<dyn ToolSpec>>,
529 }
530
531 /// Feature/config-dependent native Agent-mode tool surface.
532 ///
533 /// Parent Agent/Yolo turns and default child sub-agents both build through this
534 /// options object so the catalog does not drift as new first-party tools are
535 /// gated behind feature flags or config state.
536 #[derive(Clone)]
537 pub struct AgentToolSurfaceOptions {
538 pub shell_policy: crate::worker_profile::ShellPolicy,
539 pub apply_patch_enabled: bool,
540 pub web_search_enabled: bool,
541 pub memory_tool_enabled: bool,
542 pub vision_config: Option<crate::config::VisionModelConfig>,
543 pub speech_output_dir: Option<PathBuf>,
544 pub goal_state: Option<SharedGoalState>,
545 /// Register the agent-callable `verify` self-critique tool (#4196).
546 /// Gated by `Feature::Verify` (`[features] verify_tool`), default on.
547 pub verify_tool_enabled: bool,
548 }
549
550 impl AgentToolSurfaceOptions {
551 #[must_use]
552 pub fn new(shell_policy: crate::worker_profile::ShellPolicy) -> Self {
553 Self {
554 shell_policy,
555 apply_patch_enabled: false,
556 web_search_enabled: false,
557 memory_tool_enabled: false,
558 vision_config: None,
559 speech_output_dir: None,
560 goal_state: None,
561 verify_tool_enabled: true,
562 }
563 }
564 }
565
566 impl ToolRegistryBuilder {
567 /// Create a new builder.
568 #[must_use]
569 pub fn new() -> Self {
570 Self { tools: Vec::new() }
571 }
572
573 /// Add a custom tool.
574 #[must_use]
575 pub fn with_tool(mut self, tool: Arc<dyn ToolSpec>) -> Self {
576 self.tools.push(tool);
577 self
578 }
579
580 #[must_use]
581 pub fn with_dynamic_tools(mut self, dynamic_tools: &[DynamicToolSpec]) -> Self {
582 for tool in dynamic_tools {
583 self = self.with_tool(Arc::new(super::dynamic::RuntimeDynamicTool::new(
584 tool.clone(),
585 )));
586 }
587 self
588 }
589
590 /// Include file tools (read, write, edit, list).
591 #[must_use]
592 pub fn with_file_tools(self) -> Self {
593 use super::file_tool::FileTool;
594 self.with_tool(Arc::new(FileTool::new("File")))
595 }
596
597 /// Include only read-only file tools (read, list).
598 #[must_use]
599 pub fn with_read_only_file_tools(self) -> Self {
600 use super::file_tool::FileTool;
601 self.with_tool(Arc::new(FileTool::read_only("File")))
602 .with_tool(Arc::new(
603 super::tool_result_retrieval::RetrieveToolResultTool,
604 ))
605 }
606
607 /// Include shell execution tools.
608 ///
609 /// Model and execution surfaces expose only `Bash` (#4625). Per-action
610 /// `exec_shell*` spellings were removed in v0.9.3.
611 #[must_use]
612 pub fn with_shell_tools(self) -> Self {
613 use super::shell::BashTool;
614 self.with_tool(Arc::new(BashTool::new("Bash")))
615 .with_terminal_tools()
616 }
617
618 /// Include the stateful PTY terminal tools. Like `exec_shell`, these are
619 /// only exposed when the active shell policy allows shell access.
620 #[cfg(not(target_env = "ohos"))]
621 #[must_use]
622 pub fn with_terminal_tools(self) -> Self {
623 use super::terminal_session::{
624 TerminalCancelTool, TerminalResetTool, TerminalRunTool, TerminalSendTool,
625 TerminalWaitTool,
626 };
627 self.with_tool(Arc::new(TerminalRunTool))
628 .with_tool(Arc::new(TerminalSendTool))
629 .with_tool(Arc::new(TerminalWaitTool))
630 .with_tool(Arc::new(TerminalCancelTool))
631 .with_tool(Arc::new(TerminalResetTool))
632 }
633
634 /// OpenHarmony does not include the `portable-pty` dependency, so keep the
635 /// ordinary shell tools without advertising unavailable persistent PTYs.
636 #[cfg(target_env = "ohos")]
637 #[must_use]
638 pub fn with_terminal_tools(self) -> Self {
639 self
640 }
641
642 /// Search is part of the canonical `File` action surface.
643 #[must_use]
644 pub fn with_search_tools(self) -> Self {
645 self
646 }
647
648 /// Include the canonical `Git` inspection/history surface.
649 #[must_use]
650 pub fn with_git_tools(self) -> Self {
651 use super::git_tool::GitTool;
652 self.with_tool(Arc::new(GitTool::new("Git")))
653 }
654
655 /// Git history is part of the canonical `Git` action surface.
656 #[must_use]
657 pub fn with_git_history_tools(self) -> Self {
658 self
659 }
660
661 /// Include workspace diagnostics tool.
662 #[must_use]
663 pub fn with_diagnostics_tool(self) -> Self {
664 use super::diagnostics::DiagnosticsTool;
665 self.with_tool(Arc::new(DiagnosticsTool))
666 }
667
668 /// Include the `pandoc_convert` tool only when the `pandoc`
669 /// binary is present on this host. Same probe-then-decide
670 /// pattern v0.8.31 introduced for Python — when pandoc is
671 /// missing the tool is not registered, so the model never
672 /// sees a binary it can't actually use.
673 #[must_use]
674 pub fn with_pandoc_tools(self) -> Self {
675 if crate::dependencies::resolve_pandoc().is_some() {
676 use super::pandoc::PandocConvertTool;
677 self.with_tool(Arc::new(PandocConvertTool))
678 } else {
679 self
680 }
681 }
682
683 /// Include the `image_ocr` tool only when a local OCR backend is present.
684 /// macOS uses the built-in Vision framework, while other platforms use
685 /// Tesseract when installed.
686 #[must_use]
687 pub fn with_image_ocr_tools(self) -> Self {
688 if super::image_ocr::ocr_available() {
689 use super::image_ocr::ImageOcrTool;
690 self.with_tool(Arc::new(ImageOcrTool))
691 } else {
692 self
693 }
694 }
695
696 /// Include the `load_skill` tool (#434) so the model can pull a
697 /// SKILL.md body + companion file list into context with one
698 /// call instead of `read_file` + `list_dir` against the path
699 /// shown in the system prompt's `## Skills` section.
700 #[must_use]
701 pub fn with_skill_tools(self) -> Self {
702 use super::skill::LoadSkillTool;
703 self.with_tool(Arc::new(LoadSkillTool))
704 }
705
706 /// Include project mapping tools.
707 #[must_use]
708 pub fn with_project_tools(self) -> Self {
709 use super::project::ProjectMapTool;
710 self.with_tool(Arc::new(ProjectMapTool))
711 }
712
713 /// Include cargo test runner tool.
714 #[must_use]
715 pub fn with_test_runner_tool(self) -> Self {
716 use super::run_tool::RunTool;
717 self.with_tool(Arc::new(RunTool::new("Run")))
718 }
719
720 /// Include structured data validation tool (`validate_data`).
721 #[must_use]
722 pub fn with_validation_tools(self) -> Self {
723 use super::validate_data::ValidateDataTool;
724 self.with_tool(Arc::new(ValidateDataTool))
725 }
726
727 /// Include retrieval for spilled historical tool results.
728 #[must_use]
729 pub fn with_tool_result_retrieval_tool(self) -> Self {
730 use super::tool_result_retrieval::RetrieveToolResultTool;
731 self.with_tool(Arc::new(RetrieveToolResultTool))
732 }
733
734 /// Include durable task, gate, PR-attempt, GitHub, and automation tools.
735 ///
736 /// Each family is one tool with an `action` parameter (`tasks`, `github`,
737 /// `automation`). Per-action execution aliases were removed in v0.9.3.
738 ///
739 /// Shell-related task tools (`task_shell_start`, `task_shell_wait`) are
740 /// *not* included here — use `with_runtime_task_shell_tools` to register
741 /// them when `allow_shell` is true.
742 #[must_use]
743 pub fn with_runtime_task_tools(self) -> Self {
744 use super::automation::AutomationTool;
745 use super::github::GithubTool;
746 use super::send_later::SendLaterTool;
747 use super::tasks::TasksTool;
748
749 self.with_tool(Arc::new(TasksTool::new("tasks")))
750 .with_tool(Arc::new(GithubTool::new("github")))
751 .with_tool(Arc::new(AutomationTool::new("automation")))
752 .with_tool(Arc::new(SendLaterTool::new("send_later")))
753 }
754
755 /// Include shell-related task tools (`task_shell_start`, `task_shell_wait`).
756 ///
757 /// These are gated behind `allow_shell` because `task_shell_start`
758 /// delegates directly to `BashTool`, providing the same shell
759 /// execution capability as `Bash`.
760 #[must_use]
761 pub fn with_runtime_task_shell_tools(self) -> Self {
762 use super::tasks::{TaskShellStartTool, TaskShellWaitTool};
763 self.with_tool(Arc::new(TaskShellStartTool))
764 .with_tool(Arc::new(TaskShellWaitTool))
765 }
766
767 /// Include only read-only durable task, PR-attempt, GitHub, and automation
768 /// inspection tools. Plan mode uses this surface so it can observe state
769 /// without starting work, changing remotes, or mutating automation config.
770 ///
771 /// The model sees the same canonical `tasks` / `github` / `automation` /
772 /// `send_later` tools as the full surface, restricted to their read-only
773 /// actions.
774 #[must_use]
775 pub fn with_runtime_read_only_task_tools(self) -> Self {
776 use super::automation::AutomationTool;
777 use super::github::GithubTool;
778 use super::send_later::SendLaterTool;
779 use super::tasks::TasksTool;
780
781 self.with_tool(Arc::new(TasksTool::read_only("tasks")))
782 .with_tool(Arc::new(GithubTool::read_only("github")))
783 .with_tool(Arc::new(AutomationTool::read_only("automation")))
784 .with_tool(Arc::new(SendLaterTool::read_only("send_later")))
785 }
786
787 /// Include web search and fetch tools.
788 ///
789 /// These are feature-gated behind `Feature::WebSearch` in `tool_setup.rs`.
790 /// `finance` is registered separately via `with_finance_tool()` and is
791 /// NOT gated behind the web-search feature.
792 #[must_use]
793 pub fn with_web_tools(self) -> Self {
794 use super::web_run::WebRunTool;
795 use super::web_tool::WebTool;
796 self.with_tool(Arc::new(WebTool::new("Web")))
797 .with_tool(Arc::new(WebRunTool))
798 }
799
800 /// Include the `finance` market-data tool.
801 ///
802 /// This tool is registered unconditionally for agent modes and is NOT
803 /// gated behind `Feature::WebSearch` (it fetches financial data, not
804 /// web search results).
805 #[must_use]
806 pub fn with_finance_tool(self) -> Self {
807 use super::finance::FinanceTool;
808 self.with_tool(Arc::new(FinanceTool::new()))
809 }
810
811 /// Register the `image_analyze` vision tool.
812 /// Only registered when `[vision_model]` is configured in config.toml.
813 #[must_use]
814 pub fn with_vision_tools(self, config: crate::config::VisionModelConfig) -> Self {
815 use crate::vision::tools::ImageAnalyzeTool;
816 self.with_tool(Arc::new(ImageAnalyzeTool::new(config)))
817 }
818
819 /// Include request_user_input tool.
820 #[must_use]
821 pub fn with_user_input_tool(self) -> Self {
822 use super::user_input::RequestUserInputTool;
823 self.with_tool(Arc::new(RequestUserInputTool))
824 }
825
826 /// Include patch tools (`apply_patch`).
827 #[must_use]
828 pub fn with_patch_tools(self) -> Self {
829 use super::file_tool::FileTool;
830 self.with_tool(Arc::new(FileTool::with_patch("File")))
831 .with_tool(Arc::new(FileTool::alias("apply_patch", "patch")))
832 }
833
834 /// Include the `revert_turn` tool. Approval-gated since it mutates
835 /// the workspace; the model uses it when the user asks to "undo my
836 /// last edit". Backed by the per-workspace snapshot side-repo
837 /// (`crate::snapshot`).
838 #[must_use]
839 pub fn with_revert_turn_tool(self) -> Self {
840 use super::revert_turn::RevertTurnTool;
841 self.with_tool(Arc::new(RevertTurnTool))
842 }
843
844 /// Include Xiaomi MiMo speech/TTS tools (`speech`, `tts`).
845 #[must_use]
846 pub fn with_speech_tools(
847 self,
848 client: Option<DeepSeekClient>,
849 output_dir: Option<PathBuf>,
850 ) -> Self {
851 use super::speech::SpeechTool;
852 self.with_tool(Arc::new(SpeechTool::new(
853 "speech",
854 client.clone(),
855 output_dir.clone(),
856 )))
857 .with_tool(Arc::new(SpeechTool::new("tts", client, output_dir)))
858 }
859
860 /// Include the canonical persistent RLM session tool.
861 #[must_use]
862 pub fn with_rlm_tool(self, client: Option<DeepSeekClient>, root_model: String) -> Self {
863 use super::rlm::RlmTool;
864 self.with_tool(Arc::new(
865 RlmTool::new("rlm", client).with_root_model(root_model),
866 ))
867 }
868
869 /// Include the persistent, project-scoped continual-harness controller.
870 #[must_use]
871 pub fn with_harness_tool(self) -> Self {
872 use super::harness::HarnessTool;
873 self.with_tool(Arc::new(HarnessTool))
874 }
875
876 /// Include `handle_read`, the bounded projection reader for symbolic
877 /// `var_handle` payloads.
878 #[must_use]
879 pub fn with_handle_tools(self) -> Self {
880 use super::handle::HandleReadTool;
881 self.with_tool(Arc::new(HandleReadTool))
882 }
883
884 /// Include the review tool.
885 #[must_use]
886 pub fn with_review_tool(self, client: Option<DeepSeekClient>, model: String) -> Self {
887 use super::review::ReviewTool;
888 self.with_tool(Arc::new(ReviewTool::new(client, model)))
889 }
890
891 /// Include the agent-callable `verify` self-critique tool (#4196). The
892 /// critic runs at elevated reasoning (default `Max`) independent of the
893 /// session tier and is given no tools, so it cannot recurse into `verify`.
894 #[must_use]
895 pub fn with_verify_tool(self, client: Option<DeepSeekClient>, model: String) -> Self {
896 use super::verify::VerifyTool;
897 self.with_tool(Arc::new(VerifyTool::new(client, model)))
898 }
899
900 /// Include note tool.
901 #[must_use]
902 pub fn with_note_tool(self) -> Self {
903 use super::shell::NoteTool;
904 self.with_tool(Arc::new(NoteTool))
905 }
906
907 /// Include the FIM (Fill-in-the-Middle) edit tool.
908 #[must_use]
909 pub fn with_fim_tool(self, client: Option<DeepSeekClient>, model: String) -> Self {
910 use super::fim::FimEditTool;
911 self.with_tool(Arc::new(FimEditTool::new(client, model)))
912 }
913
914 /// Include the `remember` tool — model-callable bullet-add into the
915 /// user memory file (#489). Only register when the user has opted
916 /// in to the memory feature; without that, the tool would surface
917 /// in the model's catalog but always fail with "memory disabled".
918 #[must_use]
919 pub fn with_remember_tool(self) -> Self {
920 use super::remember::RememberTool;
921 self.with_tool(Arc::new(RememberTool))
922 }
923
924 /// Include the native-memory retrieval tools alongside reviewed capture.
925 #[must_use]
926 pub fn with_native_memory_tools(self) -> Self {
927 use super::native_memory::{MemoryGetTool, MemorySearchTool};
928 self.with_tool(Arc::new(MemorySearchTool))
929 .with_tool(Arc::new(MemoryGetTool))
930 }
931
932 /// Include the model-facing `lsp` intelligence tool. Reuses the session
933 /// [`crate::lsp::LspManager`] attached to `ToolContext` — never spawns a
934 /// second server lifecycle.
935 #[must_use]
936 pub fn with_lsp_tool(self) -> Self {
937 use super::lsp::LspTool;
938 self.with_tool(Arc::new(LspTool))
939 }
940
941 /// Include the `notify` tool — model-callable desktop notification
942 /// (#1322). Routes through the existing `tui::notifications` OSC 9 /
943 /// BEL pipeline so the user's `[notifications].method` config is
944 /// honoured automatically (including `off`). Always safe to register
945 /// because the tool has no side effects beyond a single terminal
946 /// escape write.
947 #[must_use]
948 pub fn with_notify_tool(self) -> Self {
949 use super::notify::NotifyTool;
950 self.with_tool(Arc::new(NotifyTool))
951 }
952
953 /// Include MCP tools from a connected pool as first-class registry
954 /// citizens. Each MCP tool is wrapped in a lightweight adapter that
955 /// implements `ToolSpec`, so the unified `ToolRegistryBuilder` flow
956 /// handles them alongside native tools.
957 ///
958 /// MCP tools are marked `defer_loading` by default (except discovery
959 /// helpers) to keep the model-visible catalog compact.
960 #[must_use]
961 pub fn with_mcp_tools(
962 mut self,
963 mcp_pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
964 ) -> Self {
965 // Snapshot the current tool list from the pool (non-blocking).
966 // The adapter lazily resolves at execution time via the pool.
967 if let Ok(pool) = mcp_pool.try_lock() {
968 for (name, tool) in pool.all_tools() {
969 let adapter = Arc::new(McpToolAdapter {
970 name: name.clone(),
971 tool: tool.clone(),
972 pool: mcp_pool.clone(),
973 });
974 self.tools.push(adapter);
975 }
976 }
977 self
978 }
979
980 /// Register the `start_mcp_server` tool for dynamically adding MCP servers
981 /// from conversation context. Does not register MCP tool adapters — those
982 /// are returned by `pool.to_api_tools()` in `engine.mcp_tools()`.
983 #[must_use]
984 pub fn with_runtime_mcp_tool(
985 mut self,
986 mcp_pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
987 ) -> Self {
988 self.tools
989 .push(Arc::new(super::runtime_mcp::StartRuntimeMcpServer::new(
990 mcp_pool,
991 )));
992 self
993 }
994
995 /// Register the `registry_sync` tool for fetching and caching
996 /// MCP Registry server metadata.
997 #[must_use]
998 pub fn with_registry_mcp_sync_tool(mut self) -> Self {
999 self.tools
1000 .push(Arc::new(super::mcp_registry::McpSyncRegistry));
1001 self
1002 }
1003
1004 /// Register the structured Registry launcher. Unlike `start_mcp_server`,
1005 /// this accepts no free-form command and can only launch cached,
1006 /// zero-environment stdio candidates.
1007 #[must_use]
1008 pub fn with_registry_mcp_start_tool(
1009 mut self,
1010 mcp_pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
1011 ) -> Self {
1012 self.tools
1013 .push(Arc::new(super::mcp_registry::StartRegistryMcpServer::new(
1014 mcp_pool,
1015 )));
1016 self
1017 }
1018
1019 /// Include all agent tools under a typed shell policy.
1020 #[must_use]
1021 pub fn with_agent_tools_policy(self, shell_policy: crate::worker_profile::ShellPolicy) -> Self {
1022 let builder = self
1023 .with_file_tools()
1024 .with_note_tool()
1025 .with_search_tools()
1026 .with_user_input_tool()
1027 .with_git_tools()
1028 .with_git_history_tools()
1029 .with_diagnostics_tool()
1030 .with_lsp_tool()
1031 .with_project_tools()
1032 .with_skill_tools()
1033 .with_test_runner_tool()
1034 .with_validation_tools()
1035 .with_tool_result_retrieval_tool()
1036 .with_handle_tools()
1037 .with_runtime_task_tools()
1038 .with_revert_turn_tool()
1039 .with_pandoc_tools()
1040 .with_image_ocr_tools()
1041 .with_finance_tool();
1042
1043 if shell_policy.allows_shell() {
1044 builder.with_shell_tools().with_runtime_task_shell_tools()
1045 } else {
1046 builder
1047 }
1048 }
1049
1050 /// Include the native Agent-mode surface shared by the parent runtime and
1051 /// default child sub-agents, excluding the `agent` launcher itself.
1052 #[must_use]
1053 pub fn with_agent_runtime_surface(
1054 self,
1055 client: Option<DeepSeekClient>,
1056 model: String,
1057 options: AgentToolSurfaceOptions,
1058 todo_list: super::todo::SharedTodoList,
1059 plan_state: super::plan::SharedPlanState,
1060 ) -> Self {
1061 let speech_client = client.clone();
1062 let verify_client = client.clone();
1063 let verify_model = model.clone();
1064 let mut builder = self
1065 .with_agent_tools_policy(options.shell_policy)
1066 .with_todo_tool(todo_list)
1067 .with_plan_tool(plan_state)
1068 .with_review_tool(client.clone(), model.clone())
1069 .with_rlm_tool(client.clone(), model.clone())
1070 .with_harness_tool()
1071 .with_fim_tool(client, model)
1072 .with_speech_tools(speech_client, options.speech_output_dir.clone());
1073
1074 if options.verify_tool_enabled {
1075 builder = builder.with_verify_tool(verify_client, verify_model);
1076 }
1077 if let Some(goal_state) = options.goal_state {
1078 builder = builder.with_goal_tools(goal_state);
1079 }
1080 if options.apply_patch_enabled {
1081 builder = builder.with_patch_tools();
1082 }
1083 if options.web_search_enabled {
1084 builder = builder.with_web_tools();
1085 }
1086 if options.memory_tool_enabled {
1087 builder = builder.with_remember_tool().with_native_memory_tools();
1088 }
1089 if let Some(vision_config) = options.vision_config {
1090 builder = builder.with_vision_tools(vision_config);
1091 }
1092
1093 builder.with_notify_tool()
1094 }
1095
1096 /// Include the full child-inherited Agent surface under resolved
1097 /// feature/config options.
1098 #[must_use]
1099 #[allow(clippy::too_many_arguments)]
1100 pub fn with_full_agent_surface_options(
1101 self,
1102 client: Option<DeepSeekClient>,
1103 model: String,
1104 manager: super::subagent::SharedSubAgentManager,
1105 runtime: super::subagent::SubAgentRuntime,
1106 options: AgentToolSurfaceOptions,
1107 todo_list: super::todo::SharedTodoList,
1108 plan_state: super::plan::SharedPlanState,
1109 ) -> Self {
1110 self.with_agent_runtime_surface(client, model, options, todo_list, plan_state)
1111 .with_subagent_tools(manager, runtime)
1112 }
1113
1114 /// Include the canonical work-progress tool with a shared `TodoList`.
1115 #[must_use]
1116 pub fn with_todo_tool(self, todo_list: super::todo::SharedTodoList) -> Self {
1117 use super::todo::TodoWriteTool;
1118 self.with_tool(Arc::new(TodoWriteTool::work_update(todo_list)))
1119 }
1120
1121 /// Include the plan tool with a shared `PlanState`.
1122 #[must_use]
1123 pub fn with_plan_tool(self, plan_state: super::plan::SharedPlanState) -> Self {
1124 use super::plan::UpdatePlanTool;
1125 self.with_tool(Arc::new(UpdatePlanTool::new(plan_state)))
1126 }
1127
1128 /// Include runtime goal tools (`create_goal`, `get_goal`, `update_goal`).
1129 #[must_use]
1130 pub fn with_goal_tools(self, goal_state: super::goal::SharedGoalState) -> Self {
1131 use super::goal::{CreateGoalTool, GetGoalTool, UpdateGoalTool};
1132 self.with_tool(Arc::new(CreateGoalTool::new(goal_state.clone())))
1133 .with_tool(Arc::new(GetGoalTool::new(goal_state.clone())))
1134 .with_tool(Arc::new(UpdateGoalTool::new(goal_state)))
1135 }
1136
1137 /// Include sub-agent management tools.
1138 #[must_use]
1139 pub fn with_subagent_tools(
1140 self,
1141 manager: super::subagent::SharedSubAgentManager,
1142 runtime: super::subagent::SubAgentRuntime,
1143 ) -> Self {
1144 use super::subagent::AgentTool;
1145 use super::subagent::register_coordination_tools;
1146 use super::workflow::WorkflowTool;
1147 use super::workflow_trigger::soft_auto_policy_is_linked;
1148
1149 // Keep soft-auto trigger policy linked in release builds (#4127).
1150 debug_assert!(
1151 soft_auto_policy_is_linked(),
1152 "workflow soft-auto policy must stay linked"
1153 );
1154
1155 let builder = self
1156 .with_tool(Arc::new(WorkflowTool::new(
1157 Arc::clone(&manager),
1158 runtime.clone(),
1159 )))
1160 .with_tool(Arc::new(AgentTool::new(
1161 Arc::clone(&manager),
1162 runtime.clone(),
1163 )));
1164 register_coordination_tools(builder, manager, runtime)
1165 }
1166
1167 /// Build the registry with the given context.
1168 #[must_use]
1169 pub fn build(self, context: ToolContext) -> ToolRegistry {
1170 let mut registry = ToolRegistry::new(context);
1171 registry.register_all(self.tools);
1172 registry
1173 }
1174 }
1175
1176 impl Default for ToolRegistryBuilder {
1177 fn default() -> Self {
1178 Self::new()
1179 }
1180 }
1181
1182 /// Convert CamelCase to snake_case.
1183 fn to_snake_case(s: &str) -> String {
1184 let mut out = String::with_capacity(s.len() + 4);
1185 for (i, ch) in s.chars().enumerate() {
1186 if ch.is_uppercase() {
1187 if i > 0 {
1188 out.push('_');
1189 }
1190 out.push(ch.to_ascii_lowercase());
1191 } else {
1192 out.push(ch);
1193 }
1194 }
1195 out
1196 }
1197
1198 /// Adapter that wraps an MCP tool definition so it can live in the
1199 /// unified `ToolRegistry` alongside native tools (§5.B).
1200 struct McpToolAdapter {
1201 name: String,
1202 tool: crate::mcp::McpTool,
1203 pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
1204 }
1205
1206 fn is_mcp_read_helper(name: &str) -> bool {
1207 matches!(
1208 name,
1209 "list_mcp_resources"
1210 | "list_mcp_resource_templates"
1211 | "mcp_read_resource"
1212 | "read_mcp_resource"
1213 | "mcp_get_prompt"
1214 )
1215 }
1216
1217 #[async_trait::async_trait]
1218 impl ToolSpec for McpToolAdapter {
1219 fn name(&self) -> &str {
1220 &self.name
1221 }
1222
1223 fn description(&self) -> &str {
1224 // McpTool.description is Option<String>; fall back to the
1225 // prefixed name when absent.
1226 self.tool.description.as_deref().unwrap_or(&self.name)
1227 }
1228
1229 fn input_schema(&self) -> Value {
1230 self.tool.input_schema.clone()
1231 }
1232
1233 fn capabilities(&self) -> Vec<ToolCapability> {
1234 // Conservatively treat MCP tools as requiring approval and
1235 // network access unless they're known discovery helpers.
1236 if is_mcp_read_helper(&self.name) {
1237 vec![ToolCapability::ReadOnly]
1238 } else {
1239 vec![ToolCapability::Network, ToolCapability::RequiresApproval]
1240 }
1241 }
1242
1243 fn approval_requirement(&self) -> ApprovalRequirement {
1244 if is_mcp_read_helper(&self.name) {
1245 ApprovalRequirement::Auto
1246 } else {
1247 ApprovalRequirement::Required
1248 }
1249 }
1250
1251 fn defer_loading(&self) -> bool {
1252 // Discovery helpers stay loaded; everything else is deferred.
1253 !is_mcp_read_helper(&self.name)
1254 }
1255
1256 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
1257 let mut pool = self.pool.lock().await;
1258 let result = pool
1259 .call_tool(&self.name, input)
1260 .await
1261 .map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?;
1262 Ok(mcp_result_to_tool_result(&result))
1263 }
1264 }
1265
1266 /// Map an MCP `tools/call` result to a `ToolResult`. MCP servers signal tool
1267 /// failure with `isError: true` on an otherwise successful JSON-RPC response;
1268 /// wrapping that in `ToolResult::success` tells the model a rejected call
1269 /// worked (#5123-class). Error results keep their text payload verbatim so
1270 /// the model still sees the server's message.
1271 fn mcp_result_to_tool_result(result: &Value) -> ToolResult {
1272 let content = serde_json::to_string(result).unwrap_or_else(|_| result.to_string());
1273 let is_error = result
1274 .get("isError")
1275 .and_then(Value::as_bool)
1276 .unwrap_or(false);
1277 if !is_error {
1278 return ToolResult::success(content);
1279 }
1280 let text = result
1281 .get("content")
1282 .and_then(Value::as_array)
1283 .map(|items| {
1284 items
1285 .iter()
1286 .filter_map(|item| item.get("text").and_then(Value::as_str))
1287 .collect::<Vec<_>>()
1288 .join("\n")
1289 })
1290 .filter(|text| !text.is_empty())
1291 .unwrap_or(content);
1292 ToolResult::error(text)
1293 }
1294
1295 #[cfg(test)]
1296 pub(super) fn mcp_tool_adapter_for_test(name: &str) -> Arc<dyn ToolSpec> {
1297 Arc::new(McpToolAdapter {
1298 name: name.to_string(),
1299 tool: crate::mcp::McpTool {
1300 name: name.to_string(),
1301 description: None,
1302 input_schema: serde_json::json!({"type": "object"}),
1303 },
1304 pool: Arc::new(tokio::sync::Mutex::new(crate::mcp::McpPool::new(
1305 crate::mcp::McpConfig::default(),
1306 ))),
1307 })
1308 }
1309
1310 // === Unit Tests ===
1311
1312 #[cfg(test)]
1313 mod tests {
1314 use std::collections::HashMap;
1315 use std::sync::Arc;
1316
1317 use serde_json::{Value, json};
1318 use tempfile::tempdir;
1319
1320 use crate::config::ToolOverride;
1321 use crate::tools::ToolRegistryBuilder;
1322 use crate::tools::spec::{
1323 ApprovalRequirement, ToolAuthorityEnvelope, ToolCapability, ToolContext, ToolError,
1324 ToolMutationAuthority, ToolResult, ToolSpec, required_str,
1325 };
1326
1327 use super::{ToolRegistry, mcp_result_to_tool_result, mcp_tool_adapter_for_test};
1328
1329 #[test]
1330 fn mcp_iserror_result_maps_to_tool_error_preserving_text() {
1331 // #5123-class: MCP servers report tool failure via isError on an
1332 // otherwise successful response; the model must see a failure, not a
1333 // success carrying an error message body.
1334 let error_payload = json!({
1335 "content": [
1336 {"type": "text", "text": "delete failed: permission denied"}
1337 ],
1338 "isError": true
1339 });
1340 let result = mcp_result_to_tool_result(&error_payload);
1341 assert!(!result.success, "isError must not be reported as success");
1342 assert_eq!(result.content, "delete failed: permission denied");
1343
1344 let ok_payload = json!({
1345 "content": [{"type": "text", "text": "wrote 3 rows"}]
1346 });
1347 let result = mcp_result_to_tool_result(&ok_payload);
1348 assert!(result.success);
1349 assert!(result.content.contains("wrote 3 rows"));
1350
1351 // isError without text content falls back to the serialized payload.
1352 let bare_error = json!({"isError": true, "content": []});
1353 let result = mcp_result_to_tool_result(&bare_error);
1354 assert!(!result.success);
1355 assert!(result.content.contains("isError"));
1356 }
1357
1358 /// A simple test tool for unit testing
1359 struct TestTool {
1360 name: String,
1361 description: String,
1362 }
1363
1364 #[async_trait::async_trait]
1365 impl ToolSpec for TestTool {
1366 fn name(&self) -> &str {
1367 &self.name
1368 }
1369
1370 fn description(&self) -> &str {
1371 &self.description
1372 }
1373
1374 fn input_schema(&self) -> Value {
1375 json!({
1376 "type": "object",
1377 "properties": {
1378 "message": { "type": "string" }
1379 },
1380 "required": ["message"]
1381 })
1382 }
1383
1384 fn capabilities(&self) -> Vec<ToolCapability> {
1385 vec![ToolCapability::ReadOnly]
1386 }
1387
1388 async fn execute(
1389 &self,
1390 input: Value,
1391 _context: &ToolContext,
1392 ) -> Result<ToolResult, ToolError> {
1393 let message = required_str(&input, "message")?;
1394 Ok(ToolResult::success(format!("Echo: {message}")))
1395 }
1396 }
1397
1398 fn make_test_tool(name: &str) -> Arc<TestTool> {
1399 Arc::new(TestTool {
1400 name: name.to_string(),
1401 description: "A test tool".to_string(),
1402 })
1403 }
1404
1405 #[test]
1406 fn mcp_read_helpers_remain_auto_and_eagerly_loaded() {
1407 for name in [
1408 "list_mcp_resources",
1409 "list_mcp_resource_templates",
1410 "mcp_read_resource",
1411 "read_mcp_resource",
1412 "mcp_get_prompt",
1413 ] {
1414 let adapter = mcp_tool_adapter_for_test(name);
1415 assert_eq!(
1416 adapter.approval_requirement(),
1417 ApprovalRequirement::Auto,
1418 "{name} should remain an automatic read helper"
1419 );
1420 assert!(adapter.is_read_only(), "{name} should remain read-only");
1421 assert!(!adapter.defer_loading(), "{name} should remain loaded");
1422 }
1423 }
1424
1425 #[test]
1426 fn mcp_actions_require_approval_with_exact_helper_matching() {
1427 for name in [
1428 "mcp_github_create_pull_request",
1429 "mcp_github_list_mcp_resources_export",
1430 "read_mcp_resource_and_delete",
1431 ] {
1432 let adapter = mcp_tool_adapter_for_test(name);
1433 assert_eq!(
1434 adapter.approval_requirement(),
1435 ApprovalRequirement::Required,
1436 "{name} must not inherit read-helper approval"
1437 );
1438 assert!(
1439 adapter
1440 .capabilities()
1441 .contains(&ToolCapability::RequiresApproval),
1442 "{name} should advertise approval gating"
1443 );
1444 assert!(adapter.defer_loading(), "{name} should remain deferred");
1445 }
1446 }
1447
1448 #[test]
1449 fn test_registry_register_and_get() {
1450 let tmp = tempdir().expect("tempdir");
1451 let ctx = ToolContext::new(tmp.path().to_path_buf());
1452 let mut registry = ToolRegistry::new(ctx);
1453
1454 let tool = make_test_tool("test_tool");
1455 registry.register(tool);
1456
1457 assert!(registry.contains("test_tool"));
1458 assert!(!registry.contains("nonexistent"));
1459 assert_eq!(registry.all().len(), 1);
1460 }
1461
1462 #[test]
1463 fn resolve_exact_match_is_ascii_case_insensitive() {
1464 let tmp = tempdir().expect("tempdir");
1465 let ctx = ToolContext::new(tmp.path().to_path_buf());
1466 let mut registry = ToolRegistry::new(ctx);
1467
1468 registry.register(make_test_tool("read_file"));
1469
1470 assert_eq!(registry.resolve("READ_FILE"), Some("read_file"));
1471 }
1472
1473 #[test]
1474 fn resolve_never_executes_a_fuzzy_prefix_guess() {
1475 // #5123-class: a hallucinated name that merely shares a prefix with a
1476 // real tool must NOT resolve — executing a prefix guess dispatched an
1477 // arbitrary sibling tool ("agents" -> "agents/interrupt"). Exact and
1478 // lossless normalizations still resolve; guesses return None so the
1479 // caller can surface "unknown tool, did you mean: …".
1480 let tmp = tempdir().expect("tempdir");
1481 let ctx = ToolContext::new(tmp.path().to_path_buf());
1482 let mut registry = ToolRegistry::new(ctx);
1483
1484 registry.register(make_test_tool("agents/interrupt"));
1485 registry.register(make_test_tool("read_file"));
1486
1487 // Prefix guesses in both directions are rejected.
1488 assert_eq!(registry.resolve("agents"), None);
1489 assert_eq!(registry.resolve("agents/int"), None);
1490 assert_eq!(registry.resolve("read"), None);
1491 assert_eq!(registry.resolve("read_file_extra"), None);
1492
1493 // Lossless normalizations still resolve.
1494 let mut hyphen_registry = ToolRegistry::new(ToolContext::new(tmp.path().to_path_buf()));
1495 hyphen_registry.register(make_test_tool("read_file"));
1496 assert_eq!(hyphen_registry.resolve("read-file"), Some("read_file"));
1497 assert_eq!(hyphen_registry.resolve("ReadFile"), Some("read_file"));
1498 assert_eq!(hyphen_registry.resolve("read_file_tool"), Some("read_file"));
1499 }
1500
1501 #[test]
1502 fn work_update_is_the_only_registered_progress_surface() {
1503 let tmp = tempdir().expect("tempdir");
1504 let ctx = ToolContext::new(tmp.path().to_path_buf());
1505 let registry = ToolRegistryBuilder::new()
1506 .with_todo_tool(crate::tools::todo::new_shared_todo_list())
1507 .build(ctx);
1508
1509 assert!(registry.contains("work_update"));
1510 for retired in [
1511 "checklist_write",
1512 "checklist_add",
1513 "checklist_update",
1514 "checklist_list",
1515 "todo_write",
1516 "todo_add",
1517 "todo_update",
1518 "todo_list",
1519 ] {
1520 assert!(
1521 !registry.contains(retired),
1522 "{retired} must no longer be callable"
1523 );
1524 }
1525
1526 let api_names = registry
1527 .to_api_tools()
1528 .into_iter()
1529 .map(|tool| tool.name)
1530 .collect::<Vec<_>>();
1531
1532 assert!(
1533 api_names.iter().any(|name| name == "work_update"),
1534 "work_update should be the sole model-visible progress surface"
1535 );
1536 for retired in [
1537 "checklist_write",
1538 "checklist_add",
1539 "checklist_update",
1540 "checklist_list",
1541 "todo_write",
1542 "todo_add",
1543 "todo_update",
1544 "todo_list",
1545 ] {
1546 assert!(
1547 api_names.iter().all(|name| name != retired),
1548 "{retired} must not appear in the model catalog"
1549 );
1550 }
1551 }
1552
1553 #[test]
1554 fn rlm_is_the_only_registered_session_surface() {
1555 let tmp = tempdir().expect("tempdir");
1556 let ctx = ToolContext::new(tmp.path().to_path_buf());
1557 let registry = ToolRegistryBuilder::new()
1558 .with_rlm_tool(None, "test-model".to_string())
1559 .with_harness_tool()
1560 .build(ctx);
1561
1562 assert!(registry.contains("rlm"));
1563 assert!(
1564 registry.contains("harness"),
1565 "the durable continual harness must accompany the persistent RLM surface"
1566 );
1567 for retired in [
1568 "rlm_session_objects",
1569 "rlm_open",
1570 "rlm_eval",
1571 "rlm_configure",
1572 "rlm_close",
1573 ] {
1574 assert!(
1575 !registry.contains(retired),
1576 "{retired} must no longer be callable"
1577 );
1578 }
1579 }
1580
1581 #[test]
1582 fn apply_overrides_removes_original_when_replacement_is_missing() {
1583 let tmp = tempdir().expect("tempdir");
1584 let ctx = ToolContext::new(tmp.path().to_path_buf());
1585 let mut registry = ToolRegistryBuilder::new().with_file_tools().build(ctx);
1586
1587 assert!(registry.contains("File"));
1588
1589 let mut overrides = HashMap::new();
1590 overrides.insert(
1591 "File".to_string(),
1592 ToolOverride::Script {
1593 path: "missing-wrapper.sh".to_string(),
1594 args: None,
1595 },
1596 );
1597
1598 registry.apply_overrides(&overrides, tmp.path());
1599
1600 assert!(!registry.contains("File"));
1601 }
1602
1603 #[test]
1604 fn builder_registers_speech_alias_tools() {
1605 let tmp = tempdir().expect("tempdir");
1606 let ctx = ToolContext::new(tmp.path().to_path_buf());
1607 let registry = ToolRegistryBuilder::new()
1608 .with_speech_tools(None, None)
1609 .build(ctx);
1610
1611 assert!(registry.contains("speech"));
1612 assert!(registry.contains("tts"));
1613 }
1614
1615 #[test]
1616 fn test_registry_names() {
1617 let tmp = tempdir().expect("tempdir");
1618 let ctx = ToolContext::new(tmp.path().to_path_buf());
1619 let mut registry = ToolRegistry::new(ctx);
1620
1621 registry.register(make_test_tool("tool_a"));
1622 registry.register(make_test_tool("tool_b"));
1623
1624 let names = registry.names();
1625 assert_eq!(names.len(), 2);
1626 assert!(names.contains(&"tool_a"));
1627 assert!(names.contains(&"tool_b"));
1628 }
1629
1630 #[test]
1631 fn test_registry_to_api_tools() {
1632 let tmp = tempdir().expect("tempdir");
1633 let ctx = ToolContext::new(tmp.path().to_path_buf());
1634 let mut registry = ToolRegistry::new(ctx);
1635
1636 registry.register(make_test_tool("my_tool"));
1637
1638 let api_tools = registry.to_api_tools();
1639 assert_eq!(api_tools.len(), 1);
1640 assert_eq!(api_tools[0].name, "my_tool");
1641 assert_eq!(api_tools[0].description, "A test tool");
1642 }
1643
1644 #[test]
1645 fn api_tools_with_cache_marks_last_tool_ephemeral() {
1646 let tmp = tempdir().expect("tempdir");
1647 let ctx = ToolContext::new(tmp.path().to_path_buf());
1648 let mut registry = ToolRegistry::new(ctx);
1649
1650 registry.register(make_test_tool("tool_a"));
1651 registry.register(make_test_tool("tool_b"));
1652
1653 let api_tools = registry.to_api_tools_with_cache(true);
1654 assert_eq!(api_tools.len(), 2);
1655 assert!(api_tools[0].cache_control.is_none());
1656 assert_eq!(
1657 api_tools[1]
1658 .cache_control
1659 .as_ref()
1660 .map(|c| c.cache_type.as_str()),
1661 Some("ephemeral")
1662 );
1663 }
1664
1665 /// Tool whose `description()` advances through a script of pre-built
1666 /// strings, one per call. Used to demonstrate that the api-tools cache
1667 /// pins the description bytes on first read instead of re-sampling them
1668 /// each turn (#263 follow-up; mirrors reference-cc's `getToolSchemaCache`).
1669 struct VaryingDescriptionTool {
1670 name: String,
1671 descriptions: Vec<String>,
1672 next: std::sync::atomic::AtomicUsize,
1673 }
1674
1675 impl VaryingDescriptionTool {
1676 fn new(name: &str, descriptions: &[&str]) -> Self {
1677 Self {
1678 name: name.to_string(),
1679 descriptions: descriptions.iter().map(|s| (*s).to_string()).collect(),
1680 next: std::sync::atomic::AtomicUsize::new(0),
1681 }
1682 }
1683 }
1684
1685 #[async_trait::async_trait]
1686 impl ToolSpec for VaryingDescriptionTool {
1687 fn name(&self) -> &str {
1688 &self.name
1689 }
1690
1691 fn description(&self) -> &str {
1692 let idx = self
1693 .next
1694 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
1695 .min(self.descriptions.len() - 1);
1696 &self.descriptions[idx]
1697 }
1698
1699 fn input_schema(&self) -> Value {
1700 json!({"type": "object", "properties": {}, "required": []})
1701 }
1702
1703 fn capabilities(&self) -> Vec<ToolCapability> {
1704 vec![ToolCapability::ReadOnly]
1705 }
1706
1707 async fn execute(
1708 &self,
1709 _input: Value,
1710 _context: &ToolContext,
1711 ) -> Result<ToolResult, ToolError> {
1712 Ok(ToolResult::success("ok".to_string()))
1713 }
1714 }
1715
1716 #[test]
1717 fn to_api_tools_pins_description_bytes_across_calls() {
1718 // Regression for the cache-stability follow-up: an MCP adapter that
1719 // returns a different `description()` on reconnect (or any other
1720 // tool whose description isn't a `&'static str`) would otherwise
1721 // rewrite the catalog bytes mid-session and miss the prefix cache.
1722 // The registry pins the first call's value until it's mutated.
1723 let tmp = tempdir().expect("tempdir");
1724 let ctx = ToolContext::new(tmp.path().to_path_buf());
1725 let mut registry = ToolRegistry::new(ctx);
1726 registry.register(Arc::new(VaryingDescriptionTool::new(
1727 "varying",
1728 &["first description", "second description"],
1729 )));
1730
1731 let first = registry.to_api_tools();
1732 let second = registry.to_api_tools();
1733
1734 assert_eq!(first.len(), 1);
1735 assert_eq!(first[0].description, "first description");
1736 assert_eq!(
1737 first, second,
1738 "api-tools catalog must be byte-identical across reads with no mutation in between"
1739 );
1740 }
1741
1742 #[test]
1743 fn register_invalidates_api_tools_cache() {
1744 // Counter-test: when a real change happens (a new tool registers,
1745 // an existing one is removed, or `clear` is called), the cache must
1746 // be discarded so the next read reflects the live registry.
1747 let tmp = tempdir().expect("tempdir");
1748 let ctx = ToolContext::new(tmp.path().to_path_buf());
1749 let mut registry = ToolRegistry::new(ctx);
1750 registry.register(Arc::new(VaryingDescriptionTool::new(
1751 "varying",
1752 &["first description", "second description"],
1753 )));
1754
1755 let before = registry.to_api_tools();
1756 assert_eq!(before.len(), 1);
1757
1758 registry.register(make_test_tool("late_arrival"));
1759
1760 let after = registry.to_api_tools();
1761 assert_eq!(after.len(), 2, "cache must rebuild after register");
1762 assert!(after.iter().any(|t| t.name == "varying"));
1763 assert!(after.iter().any(|t| t.name == "late_arrival"));
1764 // The varying tool's description advances on cache rebuild — the
1765 // first read above sampled `first description`; this rebuild samples
1766 // `second description`. The point is just that the bytes *can*
1767 // change after a real mutation, not that they always do.
1768 let varying_after = after
1769 .iter()
1770 .find(|t| t.name == "varying")
1771 .expect("varying tool present");
1772 assert_eq!(varying_after.description, "second description");
1773 }
1774
1775 #[test]
1776 fn remove_tool_invalidates_api_tools_cache() {
1777 let tmp = tempdir().expect("tempdir");
1778 let ctx = ToolContext::new(tmp.path().to_path_buf());
1779 let mut registry = ToolRegistry::new(ctx);
1780 registry.register(make_test_tool("alpha"));
1781 registry.register(make_test_tool("beta"));
1782
1783 let before = registry.to_api_tools();
1784 assert_eq!(before.len(), 2);
1785
1786 assert!(registry.remove_tool("alpha"));
1787 let after_remove = registry.to_api_tools();
1788 assert_eq!(after_remove.len(), 1);
1789 assert_eq!(after_remove[0].name, "beta");
1790 }
1791
1792 #[test]
1793 fn to_api_tools_emits_alphabetical_order_regardless_of_registration_order() {
1794 // Regression for #263: HashMap iteration is non-deterministic across
1795 // process launches, which busts DeepSeek's KV prefix cache for every
1796 // cross-session resume. `to_api_tools` must emit by name regardless
1797 // of registration order so two consecutive calls (and two distinct
1798 // launches) produce byte-identical output.
1799 let tmp = tempdir().expect("tempdir");
1800 let ctx = ToolContext::new(tmp.path().to_path_buf());
1801
1802 let order_a = {
1803 let mut registry = ToolRegistry::new(ctx.clone());
1804 registry.register(make_test_tool("zebra"));
1805 registry.register(make_test_tool("alpha"));
1806 registry.register(make_test_tool("mango"));
1807 registry
1808 .to_api_tools()
1809 .iter()
1810 .map(|t| t.name.clone())
1811 .collect::<Vec<_>>()
1812 };
1813
1814 let order_b = {
1815 let mut registry = ToolRegistry::new(ctx.clone());
1816 registry.register(make_test_tool("alpha"));
1817 registry.register(make_test_tool("mango"));
1818 registry.register(make_test_tool("zebra"));
1819 registry
1820 .to_api_tools()
1821 .iter()
1822 .map(|t| t.name.clone())
1823 .collect::<Vec<_>>()
1824 };
1825
1826 assert_eq!(order_a, vec!["alpha", "mango", "zebra"]);
1827 assert_eq!(order_a, order_b);
1828 }
1829
1830 fn scoped_context(workspace: &std::path::Path) -> ToolContext {
1831 ToolContext::new(workspace.to_path_buf())
1832 .with_tool_authority(
1833 ToolAuthorityEnvelope {
1834 schema_version: 1,
1835 owner: "fleet-worker-1".to_string(),
1836 authority: ToolMutationAuthority::ScopedWrite,
1837 network_access: None,
1838 writable_roots: vec!["src".to_string()],
1839 writable_files: Vec::new(),
1840 coordination_contracts: Vec::new(),
1841 }
1842 .normalized()
1843 .expect("test authority"),
1844 )
1845 .expect("test context authority")
1846 }
1847
1848 #[tokio::test]
1849 async fn fleet_authority_allows_scoped_file_writes_and_rejects_outside_paths() {
1850 let tmp = tempdir().expect("tempdir");
1851 std::fs::create_dir(tmp.path().join("src")).expect("src");
1852 std::fs::create_dir(tmp.path().join("docs")).expect("docs");
1853 let registry = ToolRegistryBuilder::new()
1854 .with_file_tools()
1855 .with_patch_tools()
1856 .build(scoped_context(tmp.path()));
1857
1858 registry
1859 .execute_full(
1860 "File",
1861 json!({"action": "write", "path": "src/ok.txt", "content": "ok\n"}),
1862 )
1863 .await
1864 .expect("scoped File write");
1865 assert_eq!(
1866 std::fs::read_to_string(tmp.path().join("src/ok.txt")).expect("written file"),
1867 "ok\n"
1868 );
1869
1870 let error = registry
1871 .execute_full(
1872 "File",
1873 json!({"action": "write", "path": "docs/no.txt", "content": "no\n"}),
1874 )
1875 .await
1876 .expect_err("out-of-scope File write")
1877 .to_string();
1878 assert!(error.contains("outside its machine-readable"), "{error}");
1879 assert!(!tmp.path().join("docs/no.txt").exists());
1880 }
1881
1882 #[tokio::test]
1883 async fn fleet_authority_denies_bash_even_when_command_classifier_calls_it_read_only() {
1884 let tmp = tempdir().expect("tempdir");
1885 std::fs::create_dir(tmp.path().join("src")).expect("src");
1886 let registry = ToolRegistryBuilder::new()
1887 .with_shell_tools()
1888 .build(scoped_context(tmp.path()));
1889
1890 let error = registry
1891 .execute_full("Bash", json!({"action": "run", "command": "git status"}))
1892 .await
1893 .expect_err("Bash remains unprovable under a file scope")
1894 .to_string();
1895 assert!(error.contains("arbitrary command execution"), "{error}");
1896 }
1897
1898 #[tokio::test]
1899 async fn fleet_authority_denies_git_even_when_the_action_is_nominally_read_only() {
1900 let tmp = tempdir().expect("tempdir");
1901 std::fs::create_dir(tmp.path().join("src")).expect("src");
1902 let registry = ToolRegistryBuilder::new()
1903 .with_git_tools()
1904 .with_git_history_tools()
1905 .with_review_tool(None, "fixture-model".to_string())
1906 .build(scoped_context(tmp.path()));
1907
1908 for (name, input) in [
1909 ("Git", json!({"action": "status"})),
1910 ("Git", json!({"action": "diff"})),
1911 ("Git", json!({"action": "show", "revision": "HEAD"})),
1912 ("Git", json!({"action": "blame", "path": "src/lib.rs"})),
1913 ("review", json!({"target": "diff"})),
1914 ] {
1915 let error = registry
1916 .execute_full(name, input)
1917 .await
1918 .expect_err("Git subprocesses remain unprovable under Fleet authority")
1919 .to_string();
1920 assert!(error.contains("Git helpers"), "{name}: {error}");
1921 }
1922 }
1923
1924 #[tokio::test]
1925 async fn fleet_authority_rejects_fim_edit_outside_its_write_scope() {
1926 let tmp = tempdir().expect("tempdir");
1927 std::fs::create_dir(tmp.path().join("src")).expect("src");
1928 std::fs::create_dir(tmp.path().join("docs")).expect("docs");
1929 std::fs::write(tmp.path().join("docs/outside.txt"), "before\nafter\n").expect("fixture");
1930 let registry = ToolRegistryBuilder::new()
1931 .with_fim_tool(None, "fixture-model".to_string())
1932 .build(scoped_context(tmp.path()));
1933
1934 let error = registry
1935 .execute_full(
1936 "fim_edit",
1937 json!({
1938 "path": "docs/outside.txt",
1939 "prefix_anchor": "before\n",
1940 "suffix_anchor": "after\n"
1941 }),
1942 )
1943 .await
1944 .expect_err("FIM mutation must be checked before model execution")
1945 .to_string();
1946 assert!(error.contains("outside its machine-readable"), "{error}");
1947 assert_eq!(
1948 std::fs::read_to_string(tmp.path().join("docs/outside.txt")).unwrap(),
1949 "before\nafter\n"
1950 );
1951 }
1952
1953 struct MixedExecutionTool;
1954
1955 #[async_trait::async_trait]
1956 impl ToolSpec for MixedExecutionTool {
1957 fn name(&self) -> &str {
1958 "mixed_execution"
1959 }
1960
1961 fn description(&self) -> &str {
1962 "inspect or start a child"
1963 }
1964
1965 fn input_schema(&self) -> Value {
1966 json!({"type": "object"})
1967 }
1968
1969 fn capabilities(&self) -> Vec<ToolCapability> {
1970 vec![ToolCapability::ExecutesCode]
1971 }
1972
1973 fn is_read_only_for(&self, input: &Value) -> bool {
1974 input.get("action").and_then(Value::as_str) == Some("inspect")
1975 }
1976
1977 async fn execute(
1978 &self,
1979 _input: Value,
1980 _context: &ToolContext,
1981 ) -> Result<ToolResult, ToolError> {
1982 Ok(ToolResult::success("observed"))
1983 }
1984 }
1985
1986 #[tokio::test]
1987 async fn fleet_authority_allows_read_only_actions_but_denies_mixed_family_starts() {
1988 let tmp = tempdir().expect("tempdir");
1989 std::fs::create_dir(tmp.path().join("src")).expect("src");
1990 let registry = ToolRegistryBuilder::new()
1991 .with_tool(Arc::new(MixedExecutionTool))
1992 .build(scoped_context(tmp.path()));
1993
1994 registry
1995 .execute_full("mixed_execution", json!({"action": "inspect"}))
1996 .await
1997 .expect("read-only status/inspect actions remain usable");
1998 let error = registry
1999 .execute_full("mixed_execution", json!({"action": "start"}))
2000 .await
2001 .expect_err("child/code starts remain denied")
2002 .to_string();
2003 assert!(error.contains("child execution"), "{error}");
2004 }
2005
2006 struct UnscopedMutator;
2007
2008 #[async_trait::async_trait]
2009 impl ToolSpec for UnscopedMutator {
2010 fn name(&self) -> &str {
2011 "unscoped_mutator"
2012 }
2013
2014 fn description(&self) -> &str {
2015 "mutates state without a file target"
2016 }
2017
2018 fn input_schema(&self) -> Value {
2019 json!({"type": "object"})
2020 }
2021
2022 fn capabilities(&self) -> Vec<ToolCapability> {
2023 Vec::new()
2024 }
2025
2026 fn is_read_only_for(&self, _input: &Value) -> bool {
2027 false
2028 }
2029
2030 async fn execute(
2031 &self,
2032 _input: Value,
2033 _context: &ToolContext,
2034 ) -> Result<ToolResult, ToolError> {
2035 Ok(ToolResult::success("mutated"))
2036 }
2037 }
2038
2039 #[tokio::test]
2040 async fn fleet_authority_denies_every_unscoped_mutator_not_only_file_capabilities() {
2041 let tmp = tempdir().expect("tempdir");
2042 std::fs::create_dir(tmp.path().join("src")).expect("src");
2043 let registry = ToolRegistryBuilder::new()
2044 .with_tool(Arc::new(UnscopedMutator))
2045 .build(scoped_context(tmp.path()));
2046
2047 let error = registry
2048 .execute_full("unscoped_mutator", json!({}))
2049 .await
2050 .expect_err("unscoped mutation must fail closed")
2051 .to_string();
2052 assert!(error.contains("mutating tool"), "{error}");
2053 }
2054
2055 #[test]
2056 fn test_builder_basic() {
2057 let tmp = tempdir().expect("tempdir");
2058 let ctx = ToolContext::new(tmp.path().to_path_buf());
2059
2060 let registry = ToolRegistryBuilder::new()
2061 .with_tool(make_test_tool("custom"))
2062 .build(ctx);
2063
2064 assert!(registry.contains("custom"));
2065 }
2066
2067 #[test]
2068 fn test_builder_with_web_tools_no_longer_includes_finance() {
2069 let tmp = tempdir().expect("tempdir");
2070 let ctx = ToolContext::new(tmp.path().to_path_buf());
2071
2072 let registry = ToolRegistryBuilder::new().with_web_tools().build(ctx);
2073
2074 // The model-facing web surface is the canonical action-dispatched tool.
2075 assert!(registry.contains("Web"));
2076 assert!(registry.contains("web.run"));
2077 for retired in ["web_search", "fetch_url", "wait_for_dev_server"] {
2078 assert!(!registry.contains(retired), "{retired} must stay removed");
2079 }
2080 assert!(!registry.contains("finance"));
2081 }
2082
2083 #[test]
2084 fn canonical_runtime_tools_remove_legacy_aliases() {
2085 let tmp = tempdir().expect("tempdir");
2086 let ctx = ToolContext::new(tmp.path().to_path_buf());
2087 let registry = ToolRegistryBuilder::new()
2088 .with_file_tools()
2089 .with_search_tools()
2090 .with_git_tools()
2091 .with_git_history_tools()
2092 .with_test_runner_tool()
2093 .with_web_tools()
2094 .with_patch_tools()
2095 .build(ctx);
2096
2097 let api_names = registry
2098 .to_api_tools()
2099 .into_iter()
2100 .map(|tool| tool.name)
2101 .collect::<Vec<_>>();
2102 for canonical in ["File", "Git", "Run", "Web"] {
2103 assert!(api_names.iter().any(|name| name == canonical));
2104 }
2105 for retired in [
2106 "read_file",
2107 "write_file",
2108 "edit_file",
2109 "list_dir",
2110 "file_search",
2111 "grep_files",
2112 "git_status",
2113 "git_diff",
2114 "git_log",
2115 "git_show",
2116 "git_blame",
2117 "run_tests",
2118 "run_verifiers",
2119 "web_search",
2120 "fetch_url",
2121 "wait_for_dev_server",
2122 ] {
2123 assert!(!registry.contains(retired), "{retired} must stay removed");
2124 assert!(
2125 api_names.iter().all(|name| name != retired),
2126 "{retired} must not be advertised"
2127 );
2128 }
2129 // DeepSeek Responses exposes apply_patch as its one custom tool, so it
2130 // remains callable but is not duplicated in the ordinary API catalog.
2131 assert!(registry.contains("apply_patch"));
2132 assert!(api_names.iter().all(|name| name != "apply_patch"));
2133 }
2134
2135 #[tokio::test]
2136 async fn canonical_file_actions_share_read_before_edit_state() {
2137 let tmp = tempdir().expect("tempdir");
2138 std::fs::write(tmp.path().join("sample.txt"), "before\n").expect("fixture");
2139 let ctx = ToolContext::new(tmp.path().to_path_buf());
2140 let registry = ToolRegistryBuilder::new().with_file_tools().build(ctx);
2141
2142 registry
2143 .execute_full("File", json!({"action": "read", "path": "sample.txt"}))
2144 .await
2145 .expect("canonical read should execute");
2146 registry
2147 .execute_full(
2148 "File",
2149 json!({
2150 "action": "edit",
2151 "path": "sample.txt",
2152 "search": "before",
2153 "replace": "after"
2154 }),
2155 )
2156 .await
2157 .expect("canonical edit should execute after the read");
2158
2159 assert_eq!(
2160 std::fs::read_to_string(tmp.path().join("sample.txt")).expect("edited file"),
2161 "after\n"
2162 );
2163 }
2164
2165 #[test]
2166 fn read_only_file_surface_does_not_advertise_write_actions() {
2167 let tmp = tempdir().expect("tempdir");
2168 let ctx = ToolContext::new(tmp.path().to_path_buf());
2169 let registry = ToolRegistryBuilder::new()
2170 .with_read_only_file_tools()
2171 .with_search_tools()
2172 .build(ctx);
2173 let file = registry
2174 .to_api_tools()
2175 .into_iter()
2176 .find(|tool| tool.name == "File")
2177 .expect("canonical File tool");
2178 let actions = file.input_schema["properties"]["action"]["enum"]
2179 .as_array()
2180 .expect("action enum");
2181
2182 for blocked in ["write", "edit", "patch"] {
2183 assert!(actions.iter().all(|action| action != blocked));
2184 }
2185 }
2186
2187 #[test]
2188 fn test_builder_with_finance_tool() {
2189 let tmp = tempdir().expect("tempdir");
2190 let ctx = ToolContext::new(tmp.path().to_path_buf());
2191
2192 let registry = ToolRegistryBuilder::new().with_finance_tool().build(ctx);
2193
2194 assert!(registry.contains("finance"));
2195 }
2196
2197 #[test]
2198 fn with_verify_tool_registers_and_exposes_verify() {
2199 let tmp = tempdir().expect("tempdir");
2200 let ctx = ToolContext::new(tmp.path().to_path_buf());
2201
2202 let registry = ToolRegistryBuilder::new()
2203 .with_verify_tool(None, "test-model".to_string())
2204 .build(ctx);
2205
2206 assert!(
2207 registry.contains("verify"),
2208 "verify tool should be registered"
2209 );
2210 let api_names = registry
2211 .to_api_tools()
2212 .into_iter()
2213 .map(|tool| tool.name)
2214 .collect::<Vec<_>>();
2215 assert!(
2216 api_names.iter().any(|name| name == "verify"),
2217 "verify tool should be model-visible"
2218 );
2219 }
2220
2221 #[test]
2222 fn agent_runtime_surface_gates_verify_on_option() {
2223 use super::AgentToolSurfaceOptions;
2224 use crate::worker_profile::ShellPolicy;
2225
2226 let build_surface = |verify_enabled: bool| {
2227 let tmp = tempdir().expect("tempdir");
2228 let ctx = ToolContext::new(tmp.path().to_path_buf());
2229 let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full);
2230 options.verify_tool_enabled = verify_enabled;
2231 ToolRegistryBuilder::new()
2232 .with_agent_runtime_surface(
2233 None,
2234 "test-model".to_string(),
2235 options,
2236 crate::tools::todo::new_shared_todo_list(),
2237 crate::tools::plan::new_shared_plan_state(),
2238 )
2239 .build(ctx)
2240 };
2241
2242 assert!(
2243 build_surface(true).contains("verify"),
2244 "verify should register when enabled"
2245 );
2246 assert!(
2247 !build_surface(false).contains("verify"),
2248 "verify should be absent when the opt-out disables it"
2249 );
2250 }
2251
2252 #[test]
2253 fn test_builder_with_agent_tools_policy_includes_finance() {
2254 let tmp = tempdir().expect("tempdir");
2255 let ctx = ToolContext::new(tmp.path().to_path_buf());
2256
2257 let registry = ToolRegistryBuilder::new()
2258 .with_agent_tools_policy(crate::worker_profile::ShellPolicy::None)
2259 .build(ctx);
2260
2261 assert!(registry.contains("finance"));
2262 }
2263
2264 #[test]
2265 fn agent_tools_with_shell_policy_none_excludes_shell_tools() {
2266 let tmp = tempdir().expect("tempdir");
2267 let ctx = ToolContext::new(tmp.path().to_path_buf());
2268
2269 let registry = ToolRegistryBuilder::new()
2270 .with_agent_tools_policy(crate::worker_profile::ShellPolicy::None)
2271 .build(ctx);
2272
2273 assert!(
2274 !registry.contains("Bash"),
2275 "Bash should be excluded when the shell policy is None"
2276 );
2277 assert!(
2278 !registry.contains("exec_shell"),
2279 "retired exec_shell must remain absent"
2280 );
2281 assert!(
2282 !registry.contains("task_shell_start"),
2283 "task_shell_start should be excluded when the shell policy is None"
2284 );
2285 assert!(
2286 !registry.contains("task_shell_wait"),
2287 "task_shell_wait should be excluded when the shell policy is None"
2288 );
2289 }
2290
2291 #[test]
2292 fn agent_tools_with_shell_policy_readonly_includes_shell_tools() {
2293 let tmp = tempdir().expect("tempdir");
2294 let ctx = ToolContext::new(tmp.path().to_path_buf());
2295
2296 let registry = ToolRegistryBuilder::new()
2297 .with_agent_tools_policy(crate::worker_profile::ShellPolicy::ReadOnly)
2298 .build(ctx);
2299
2300 assert!(registry.contains("Bash"));
2301 assert!(!registry.contains("exec_shell"));
2302 assert!(registry.contains("task_shell_start"));
2303 assert!(registry.contains("task_shell_wait"));
2304 }
2305
2306 #[test]
2307 fn agent_tools_with_shell_policy_full_includes_shell_tools() {
2308 let tmp = tempdir().expect("tempdir");
2309 let ctx = ToolContext::new(tmp.path().to_path_buf());
2310
2311 let registry = ToolRegistryBuilder::new()
2312 .with_agent_tools_policy(crate::worker_profile::ShellPolicy::Full)
2313 .build(ctx);
2314
2315 assert!(registry.contains("Bash"));
2316 assert!(!registry.contains("exec_shell"));
2317 assert!(
2318 registry.contains("task_shell_start"),
2319 "task_shell_start should be included when the shell policy is Full"
2320 );
2321 assert!(
2322 registry.contains("task_shell_wait"),
2323 "task_shell_wait should be included when the shell policy is Full"
2324 );
2325 }
2326
2327 /// v0.9.3 removes the per-action shell aliases entirely.
2328 #[test]
2329 fn shell_surface_contains_only_the_canonical_bash_tool() {
2330 let tmp = tempdir().expect("tempdir");
2331 let ctx = ToolContext::new(tmp.path().to_path_buf());
2332 let registry = ToolRegistryBuilder::new().with_shell_tools().build(ctx);
2333
2334 for alias in [
2335 "exec_shell",
2336 "exec_wait",
2337 "exec_interact",
2338 "exec_shell_wait",
2339 "exec_shell_interact",
2340 "exec_shell_cancel",
2341 ] {
2342 assert!(!registry.contains(alias), "{alias} must be removed");
2343 }
2344
2345 let api_names: Vec<String> = registry
2346 .to_api_tools()
2347 .into_iter()
2348 .map(|tool| tool.name)
2349 .collect();
2350
2351 // Only Bash is model-visible.
2352 assert!(
2353 api_names.iter().any(|n| n == "Bash"),
2354 "Bash should be model-visible"
2355 );
2356
2357 // Removed names also cannot leak back into the model catalog.
2358 for alias in [
2359 "exec_shell",
2360 "exec_wait",
2361 "exec_interact",
2362 "exec_shell_wait",
2363 "exec_shell_interact",
2364 "exec_shell_cancel",
2365 ] {
2366 assert!(
2367 api_names.iter().all(|n| n != alias),
2368 "{alias} should be hidden from the model catalog"
2369 );
2370 }
2371 }
2372
2373 /// Each durable-work family exposes one canonical action tool; v0.9.3
2374 /// removes the per-action execution aliases.
2375 #[test]
2376 fn runtime_task_families_expose_only_canonical_tools() {
2377 let tmp = tempdir().expect("tempdir");
2378 let ctx = ToolContext::new(tmp.path().to_path_buf());
2379 let registry = ToolRegistryBuilder::new()
2380 .with_runtime_task_tools()
2381 .build(ctx);
2382
2383 let legacy_aliases = [
2384 "task_create",
2385 "task_list",
2386 "task_read",
2387 "task_cancel",
2388 "task_gate_run",
2389 "pr_attempt_record",
2390 "pr_attempt_list",
2391 "pr_attempt_read",
2392 "pr_attempt_preflight",
2393 "github_issue_context",
2394 "github_pr_context",
2395 "github_comment",
2396 "github_close_issue",
2397 "github_close_pr",
2398 "automation_create",
2399 "automation_list",
2400 "automation_read",
2401 "automation_update",
2402 "automation_pause",
2403 "automation_resume",
2404 "automation_delete",
2405 "automation_run",
2406 ];
2407 for alias in legacy_aliases {
2408 assert!(!registry.contains(alias), "{alias} must be removed");
2409 }
2410
2411 let api_names: Vec<String> = registry
2412 .to_api_tools()
2413 .into_iter()
2414 .map(|tool| tool.name)
2415 .collect();
2416
2417 // Only the canonical tools are model-visible.
2418 for canonical in ["tasks", "github", "automation"] {
2419 assert!(
2420 api_names.iter().any(|n| n == canonical),
2421 "{canonical} should be model-visible"
2422 );
2423 }
2424 // Removed aliases also cannot leak back into the model catalog.
2425 for alias in legacy_aliases {
2426 assert!(
2427 api_names.iter().all(|n| n != alias),
2428 "{alias} should be hidden from the model catalog"
2429 );
2430 }
2431 }
2432
2433 /// The Plan-mode read-only surface registers only the canonical families,
2434 /// restricted to their read actions.
2435 #[test]
2436 fn read_only_task_surface_contains_no_per_action_aliases() {
2437 let tmp = tempdir().expect("tempdir");
2438 let ctx = ToolContext::new(tmp.path().to_path_buf());
2439 let registry = ToolRegistryBuilder::new()
2440 .with_runtime_read_only_task_tools()
2441 .build(ctx);
2442
2443 for name in [
2444 "task_list",
2445 "task_read",
2446 "pr_attempt_list",
2447 "pr_attempt_read",
2448 "github_issue_context",
2449 "github_pr_context",
2450 "automation_list",
2451 "automation_read",
2452 "task_create",
2453 "task_cancel",
2454 "task_gate_run",
2455 "pr_attempt_record",
2456 "pr_attempt_preflight",
2457 "github_comment",
2458 "github_close_issue",
2459 "github_close_pr",
2460 "automation_create",
2461 "automation_update",
2462 "automation_pause",
2463 "automation_resume",
2464 "automation_delete",
2465 "automation_run",
2466 ] {
2467 assert!(!registry.contains(name), "{name} must be removed");
2468 }
2469
2470 let api_names: Vec<String> = registry
2471 .to_api_tools()
2472 .into_iter()
2473 .map(|tool| tool.name)
2474 .collect();
2475 assert_eq!(api_names.len(), 4);
2476 for canonical in ["tasks", "github", "automation", "send_later"] {
2477 assert!(
2478 api_names.iter().any(|n| n == canonical),
2479 "{canonical} should be model-visible on the read-only surface"
2480 );
2481 }
2482 // Every registered tool stays read-only (Plan-mode invariant).
2483 for tool in registry.all() {
2484 let caps = tool.capabilities();
2485 assert!(
2486 !caps.contains(&ToolCapability::WritesFiles)
2487 && !caps.contains(&ToolCapability::ExecutesCode),
2488 "read-only surface must not register write/exec tools: {}",
2489 tool.name()
2490 );
2491 }
2492 }
2493
2494 /// The action-shaped RLM family is registered only for compatibility.
2495 #[test]
2496 fn rlm_family_removes_legacy_aliases() {
2497 let tmp = tempdir().expect("tempdir");
2498 let ctx = ToolContext::new(tmp.path().to_path_buf());
2499 let registry = ToolRegistryBuilder::new()
2500 .with_rlm_tool(None, "deepseek-v4-pro".to_string())
2501 .build(ctx);
2502
2503 for alias in [
2504 "rlm_session_objects",
2505 "rlm_open",
2506 "rlm_eval",
2507 "rlm_configure",
2508 "rlm_close",
2509 ] {
2510 assert!(!registry.contains(alias), "{alias} must stay removed");
2511 }
2512
2513 let api_names: Vec<String> = registry
2514 .to_api_tools()
2515 .into_iter()
2516 .map(|tool| tool.name)
2517 .collect();
2518 assert!(
2519 api_names.iter().all(|n| n != "rlm"),
2520 "the compatibility RLM surface must not be advertised to new model turns"
2521 );
2522 for retired in [
2523 "rlm_session_objects",
2524 "rlm_open",
2525 "rlm_eval",
2526 "rlm_configure",
2527 "rlm_close",
2528 ] {
2529 assert!(
2530 api_names.iter().all(|n| n != retired),
2531 "{retired} must not be advertised"
2532 );
2533 }
2534 }
2535 }
2536
2536 lines RUST