返回 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::CodewhaleClient;
18 use crate::tools::goal::SharedGoalState;
19 use codewhale_models::Tool;
20
21 use super::schema_canonicalize;
22 use super::schema_sanitize;
23 use super::spec::{
24 ApprovalRequirement, RichToolResult, ToolCapability, ToolContext, ToolError, ToolResult,
25 ToolResultContentBlock, ToolSpec,
26 };
27
28 // === Types ===
29
30 /// Registry that holds all available tools.
31 pub struct ToolRegistry {
32 tools: HashMap<String, Arc<dyn ToolSpec>>,
33 context: ToolContext,
34 /// Memoised serialised tool catalog. Rebuilt lazily on first
35 /// `to_api_tools` call after a mutation; pinned across reads so the
36 /// description and schema bytes stay byte-stable for DeepSeek's KV
37 /// prefix cache. Invalidated on `register` / `remove_tool`.
38 api_cache: OnceLock<Vec<Tool>>,
39 }
40
41 impl ToolRegistry {
42 /// Create a new empty registry with the given context.
43 #[must_use]
44 pub fn new(context: ToolContext) -> Self {
45 Self {
46 tools: HashMap::new(),
47 context,
48 api_cache: OnceLock::new(),
49 }
50 }
51
52 /// Register a tool in the registry.
53 pub fn register(&mut self, tool: Arc<dyn ToolSpec>) {
54 let name = tool.name().to_string();
55 if let Some(previous) = self.tools.get(&name) {
56 tracing::warn!(
57 previous_origin = ?previous.registration_origin(),
58 replacement_origin = ?tool.registration_origin(),
59 "Overwriting existing tool: {}", crate::safe_label::SafeLabel::identifier(&name)
60 );
61 }
62 self.tools.insert(name, tool);
63 self.invalidate_api_cache();
64 }
65
66 /// Register multiple tools at once.
67 pub fn register_all(&mut self, tools: Vec<Arc<dyn ToolSpec>>) {
68 for tool in tools {
69 self.register(tool);
70 }
71 }
72
73 /// Get a tool by name.
74 #[must_use]
75 pub fn get(&self, name: &str) -> Option<Arc<dyn ToolSpec>> {
76 self.tools.get(name).cloned()
77 }
78
79 /// Check if a tool exists.
80 #[must_use]
81 pub fn contains(&self, name: &str) -> bool {
82 self.tools.contains_key(name)
83 }
84
85 /// Get all registered tool names.
86 #[must_use]
87 pub fn names(&self) -> Vec<&str> {
88 self.tools.keys().map(std::string::String::as_str).collect()
89 }
90
91 /// Get all registered tools.
92 #[must_use]
93 pub fn all(&self) -> Vec<Arc<dyn ToolSpec>> {
94 self.tools.values().cloned().collect()
95 }
96
97 /// Execute a tool by name, returning the full `ToolResult`.
98 pub async fn execute_full(&self, name: &str, input: Value) -> Result<ToolResult, ToolError> {
99 self.execute_rich_full(name, input)
100 .await
101 .map(RichToolResult::into_result)
102 }
103
104 pub(crate) async fn execute_rich_full(
105 &self,
106 name: &str,
107 input: Value,
108 ) -> Result<RichToolResult, ToolError> {
109 let tool = self
110 .get(name)
111 .ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?;
112
113 enforce_tool_authority(name, &input, tool.as_ref(), &self.context)?;
114 tool.execute_rich(input, &self.context)
115 .await
116 .map(crate::image_attach::bound_rich_tool_result)
117 }
118
119 pub(crate) async fn execute_rich_full_with_context(
120 &self,
121 name: &str,
122 input: Value,
123 context_override: Option<&ToolContext>,
124 ) -> Result<RichToolResult, ToolError> {
125 let tool = self
126 .get(name)
127 .ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?;
128
129 let ctx = context_override.unwrap_or(&self.context);
130 enforce_tool_authority(name, &input, tool.as_ref(), ctx)?;
131 let mut rich = crate::image_attach::bound_rich_tool_result(
132 tool.execute_rich(input.clone(), ctx).await?,
133 );
134 let result = &mut rich.result;
135
136 // Adaptive evidence routing (#4619) is an explicit opt-in
137 // (`CODEWHALE_ADAPTIVE_OUTPUT_ROUTING`) and is storage-free here
138 // because this layer does not own a call id. The engine/subagent
139 // completion boundary publishes the exact artifact. Under the default
140 // classic lane nothing happens at this layer — the same boundary owns
141 // the bounded spillover preview.
142 if crate::tools::large_output_router::adaptive_output_routing_enabled()
143 && let Some(router) = ctx.large_output_router.as_ref()
144 {
145 use crate::tools::large_output_router::EvidenceRouting;
146 let raw_bypass = input.get("raw").and_then(|v| v.as_bool()).unwrap_or(false);
147 let (estimated_routing, estimated_tokens, threshold) =
148 router.evidence_routing(name, result, raw_bypass);
149 let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
150 if let Some(object) = metadata.as_object_mut() {
151 // A tool that self-bounds its output behind its own
152 // recovery contract (e.g. read_file's `next_start_line`
153 // paging) declares its routing itself; the size estimate
154 // must not override that and double-wrap the result.
155 let routing = object
156 .get("evidence_routing")
157 .cloned()
158 .and_then(|value| serde_json::from_value::<EvidenceRouting>(value).ok())
159 .unwrap_or(estimated_routing);
160 object.insert(
161 "evidence_routing".to_string(),
162 serde_json::to_value(routing).unwrap_or_else(|_| serde_json::json!("inline")),
163 );
164 object.insert(
165 "evidence_estimated_tokens".to_string(),
166 estimated_tokens.into(),
167 );
168 object.insert("evidence_threshold_tokens".to_string(), threshold.into());
169 }
170 }
171
172 Ok(rich)
173 }
174
175 /// Get the current tool context.
176 #[must_use]
177 pub fn context(&self) -> &ToolContext {
178 &self.context
179 }
180
181 /// Convert all tools to API Tool format for sending to the model.
182 ///
183 /// Output is sorted by tool name for **prefix-cache stability** (#263).
184 /// Rust's `HashMap` uses a randomly-seeded hasher per process, so a raw
185 /// `self.tools.values()` iteration emits tools in a different order on
186 /// every `deepseek` launch, invalidating DeepSeek's KV prefix cache for
187 /// every cross-session resume. Sorting here matches the way Claude Code
188 /// stabilises its tool array (`assembleToolPool` in their reference).
189 ///
190 /// The serialised catalog is memoised on first call and pinned across
191 /// reads so each tool's `description()` and `input_schema()` are sampled
192 /// exactly once per registration. MCP adapters whose upstream description
193 /// drifts on reconnect would otherwise rewrite the catalog mid-session
194 /// and bust the prefix cache. The cache is invalidated on `register`,
195 /// `remove`, and `clear`.
196 #[must_use]
197 pub fn to_api_tools(&self) -> Vec<Tool> {
198 self.api_cache
199 .get_or_init(|| self.build_api_tools())
200 .clone()
201 }
202
203 fn build_api_tools(&self) -> Vec<Tool> {
204 let read_only_authority = self.context.tool_authority.as_deref().filter(|authority| {
205 authority.authority == super::spec::ToolMutationAuthority::ReadOnly
206 });
207 let evidence_only = read_only_authority.is_some();
208 let evidence_network = self
209 .context
210 .tool_authority
211 .as_ref()
212 .is_none_or(|authority| authority.network_access == Some(true));
213 let mut tools: Vec<&Arc<dyn ToolSpec>> = self.tools.values().collect();
214 tools.sort_by(|a, b| a.name().cmp(b.name()));
215 tools
216 .into_iter()
217 .filter(|tool| tool.model_visible())
218 .filter(|tool| {
219 read_only_authority.is_none_or(|authority| {
220 readonly_evidence_tool(tool.as_ref())
221 || (tool.name() == "Run"
222 && authority.verification
223 == super::spec::ToolVerificationAuthority::Bounded)
224 })
225 })
226 .filter(|tool| evidence_network || !matches!(tool.name(), "Web" | "web.run"))
227 .map(|tool| {
228 let mut schema = tool.input_schema();
229 if evidence_only {
230 project_readonly_evidence_schema(tool.name(), &mut schema);
231 }
232 schema_sanitize::sanitize(&mut schema);
233 schema_canonicalize::canonicalize_schema(&mut schema);
234 Tool {
235 tool_type: None,
236 name: tool.name().to_string(),
237 description: if evidence_only
238 && matches!(tool.name(), "bash" | "Bash" | "exec_shell")
239 {
240 format!(
241 "{} {}",
242 tool.description(),
243 codewhale_execpolicy::command_safety::readonly_command_help()
244 )
245 } else {
246 tool.description().to_string()
247 },
248 input_schema: schema,
249 allowed_callers: Some(vec!["direct".to_string()]),
250 defer_loading: Some(tool.defer_loading()),
251 input_examples: None,
252 strict: None,
253 cache_control: None,
254 }
255 })
256 .collect()
257 }
258
259 fn invalidate_api_cache(&mut self) {
260 self.api_cache = OnceLock::new();
261 }
262
263 /// Convert tools to API Tool format with optional cache control on the last tool.
264 #[must_use]
265 pub fn to_api_tools_with_cache(&self, enable_cache: bool) -> Vec<Tool> {
266 let mut tools = self.to_api_tools();
267 if enable_cache && let Some(last) = tools.last_mut() {
268 last.cache_control = Some(codewhale_models::CacheControl {
269 cache_type: "ephemeral".to_string(),
270 });
271 }
272 tools
273 }
274
275 /// Flatten every registered tool into the exact facts the read-only
276 /// request projection is allowed to report: name, description, model
277 /// visibility, declared capabilities, declared approval requirement, and
278 /// whether the tool came from the plugin surface.
279 ///
280 /// This hands out *data*, never tool objects, so the projection layer
281 /// cannot execute anything. Output is sorted by name and does not touch the
282 /// registry's own ordering or the memoised API catalog.
283 #[must_use]
284 pub fn registry_facts(
285 &self,
286 plugin_names: &std::collections::HashSet<String>,
287 ) -> Vec<crate::tool_inspection::RegistryFacts> {
288 let mut facts: Vec<crate::tool_inspection::RegistryFacts> = self
289 .tools
290 .values()
291 .map(|tool| crate::tool_inspection::RegistryFacts {
292 name: tool.name().to_string(),
293 description: tool.description().to_string(),
294 model_visible: tool.model_visible(),
295 capabilities: tool
296 .capabilities()
297 .iter()
298 .map(|capability| format!("{capability:?}"))
299 .collect(),
300 approval: format!("{:?}", tool.approval_requirement()),
301 plugin: plugin_names.contains(tool.name()),
302 })
303 .collect();
304 facts.sort_by(|a, b| a.name.cmp(&b.name));
305 facts
306 }
307
308 /// Resolve a non-canonical tool name to a registered canonical name.
309 ///
310 /// Runs a deterministic ladder against the registered tool names:
311 /// 1. Lowercase exact match.
312 /// 2. Hyphens/spaces → underscores (read-file → read_file).
313 /// 3. CamelCase → snake_case (ReadFile → read_file).
314 /// 4. Strip trailing `_tool` / `-tool` suffix (twice).
315 ///
316 /// Returns `None` when no normalization matches (the caller surfaces
317 /// "Unknown tool … did you mean: …"). There is deliberately **no fuzzy
318 /// step**: a prefix guess over the registry would execute an arbitrary
319 /// sibling tool the model never asked for (#5123-class) — a hallucinated
320 /// name must fail, never dispatch.
321 #[must_use]
322 pub fn resolve(&self, requested: &str) -> Option<&str> {
323 let names: Vec<&str> = self.tools.keys().map(String::as_str).collect();
324 let lower = requested.to_lowercase();
325
326 // 1. ASCII case-insensitive exact
327 if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {
328 return Some(n);
329 }
330 // 2. hyphen/space → underscore
331 let snaked = lower.replace(['-', ' '], "_");
332 if let Some(n) = names.iter().find(|n| **n == snaked) {
333 return Some(n);
334 }
335 // 3. CamelCase → snake_case
336 let cc = to_snake_case(requested);
337 if let Some(n) = names.iter().find(|n| **n == cc) {
338 return Some(n);
339 }
340 // 4. strip _tool/-tool/tool suffix, twice
341 let mut stripped = cc.clone();
342 for _ in 0..2 {
343 for suf in ["_tool", "-tool", "tool"] {
344 if let Some(s) = stripped.strip_suffix(suf) {
345 stripped = s.to_string();
346 break;
347 }
348 }
349 }
350 if !stripped.is_empty()
351 && let Some(n) = names.iter().find(|n| **n == stripped)
352 {
353 return Some(n);
354 }
355 None
356 }
357
358 /// Remove a tool from the registry by name. Returns `true` if the tool
359 /// was present and removed, `false` if no tool with that name existed.
360 pub fn remove_tool(&mut self, name: &str) -> bool {
361 let existed = self.tools.remove(name).is_some();
362 if existed {
363 self.invalidate_api_cache();
364 }
365 existed
366 }
367
368 /// Apply config.toml tool overrides to this registry.
369 ///
370 /// For each entry in `overrides`:
371 /// - `Disabled` removes the tool.
372 /// - `Script` / `Command` replaces the tool with the user's implementation.
373 ///
374 /// `plugin_dir` is used as the base for relative script paths.
375 pub fn apply_overrides(
376 &mut self,
377 overrides: &std::collections::HashMap<String, crate::config::ToolOverride>,
378 plugin_dir: &Path,
379 ) {
380 for (tool_name, override_cfg) in overrides {
381 match override_cfg {
382 crate::config::ToolOverride::Disabled => {
383 if self.remove_tool(tool_name) {
384 tracing::info!("Tool '{}' disabled via config override", tool_name);
385 } else {
386 tracing::warn!("Cannot disable tool '{}': not registered", tool_name);
387 }
388 }
389 _ => {
390 // Script and Command overrides create replacement tools.
391 use crate::tools::plugin::tool_from_override;
392 match tool_from_override(tool_name, override_cfg, plugin_dir) {
393 Some(replacement) => {
394 self.register(replacement);
395 tracing::info!("Tool '{}' replaced via config override", tool_name);
396 }
397 None => {
398 if self.remove_tool(tool_name) {
399 tracing::warn!(
400 "Tool '{}' override did not create a replacement; removed the original tool to avoid override fallthrough",
401 tool_name
402 );
403 } else {
404 tracing::warn!(
405 "Tool '{}' override did not create a replacement and no registered tool existed",
406 tool_name
407 );
408 }
409 }
410 }
411 }
412 }
413 }
414 }
415
416 /// Load and register plugin tools from a directory.
417 ///
418 /// Each script with valid frontmatter (`# name:`, `# description:`, etc.)
419 /// becomes a registered `ScriptPluginTool`. Tools whose name matches an
420 /// already-registered tool will overwrite it.
421 pub fn load_plugins(&mut self, plugin_dir: &Path) {
422 if !plugin_dir.exists() {
423 tracing::debug!(
424 "Plugin directory {} does not exist, skipping",
425 plugin_dir.display()
426 );
427 return;
428 }
429 let plugins = crate::tools::plugin::load_plugin_tools(plugin_dir);
430 let count = plugins.len();
431 for tool in plugins {
432 self.register(tool);
433 }
434 if count > 0 {
435 tracing::info!(
436 "Loaded {count} plugin tool(s) from {}",
437 plugin_dir.display()
438 );
439 }
440 }
441 }
442
443 /// The complete model-visible and dispatchable surface for a machine or role
444 /// whose contract is evidence collection without project/process mutation.
445 pub(crate) fn readonly_evidence_tool_name(name: &str) -> bool {
446 matches!(
447 name,
448 "read"
449 | "bash"
450 | "File"
451 | "Bash"
452 | "Web"
453 | "web.run"
454 | "load_skill"
455 | "handle_read"
456 | "retrieve_tool_result"
457 | "todo_write"
458 )
459 }
460
461 /// True when a concrete registered tool is safe on the read-only evidence
462 /// surface. Static read-only capability is sufficient except for Git/review:
463 /// those may invoke repository-configured helpers, so their safety cannot be
464 /// proven from the tool declaration alone. Scouts can still inspect Git through
465 /// classifier-bounded lowercase `bash` commands.
466 pub(crate) fn readonly_evidence_tool(tool: &dyn ToolSpec) -> bool {
467 readonly_evidence_tool_name(tool.name())
468 || !matches!(tool.name(), "Git" | "review") && tool.is_read_only()
469 }
470
471 fn project_readonly_evidence_schema(name: &str, schema: &mut Value) {
472 if name == "Bash" {
473 *schema = super::shell::readonly_bash_input_schema();
474 return;
475 }
476 if name == "Run" {
477 // The shared classifier remains authoritative for `args`; the schema
478 // removes the only field that can name verifier programs.
479 if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
480 properties.remove("commands");
481 }
482 return;
483 }
484 // Probe with `pointer_mut`, never `schema["properties"]["action"]["enum"]`:
485 // serde_json's IndexMut auto-vivifies missing keys by inserting Null, so
486 // the old probe left `properties.action = {"enum": null}` inside schemas
487 // that have no action property (e.g. lowercase `bash`). Strict
488 // OpenAI-compatible validators then reject the whole request with
489 // `Invalid schema for function 'bash': null is not of type "array"`
490 // (observed on Fleet read-only workers; see registry tests).
491 // The same probe idiom lives in `tools/subagent` (grep `pointer_mut(
492 // "/properties/action/enum")`); keep the two sites greppable as one.
493 let Some(actions) = schema
494 .pointer_mut("/properties/action/enum")
495 .and_then(Value::as_array_mut)
496 else {
497 return;
498 };
499 match name {
500 "File" => actions.retain(|action| {
501 action.as_str().is_some_and(|action| {
502 matches!(action, "read" | "list" | "search_name" | "search_content")
503 })
504 }),
505 "Web" => actions.retain(|action| {
506 action
507 .as_str()
508 .is_some_and(|action| matches!(action, "search" | "fetch"))
509 }),
510 _ => {}
511 }
512 }
513
514 pub(crate) fn enforce_tool_authority(
515 name: &str,
516 input: &Value,
517 tool: &dyn ToolSpec,
518 context: &ToolContext,
519 ) -> Result<(), ToolError> {
520 crate::core::engine::tool_catalog::enforce_tool_denial(context, name, input)?;
521 let Some(authority) = context.tool_authority.as_ref() else {
522 return Ok(());
523 };
524 let evidence_only = authority.authority == super::spec::ToolMutationAuthority::ReadOnly;
525 let bounded_verifier = evidence_only
526 && name == "Run"
527 && authority.verification == super::spec::ToolVerificationAuthority::Bounded;
528 if evidence_only && !readonly_evidence_tool(tool) && !bounded_verifier {
529 return Err(ToolError::permission_denied(format!(
530 "worker '{}' cannot run {name}: it is outside the read-only evidence tool profile",
531 authority.owner
532 )));
533 }
534 if evidence_only && matches!(name, "Web" | "web.run") && authority.network_access != Some(true)
535 {
536 return Err(ToolError::permission_denied(format!(
537 "worker '{}' cannot run {name}: its authority envelope does not grant network access",
538 authority.owner
539 )));
540 }
541 let capabilities = tool.capabilities();
542 if matches!(name, "bash" | "Bash" | "exec_shell") {
543 // Numeric sed inspection already has an execution-time read-only
544 // grammar. Reuse it here without promoting the broader child shell
545 // surface (including pipelines/network reads) into machine authority,
546 // or changing the parent's parallel/approval classification (#6015).
547 let bounded_sed = context.shell_policy == crate::worker_profile::ShellPolicy::ReadOnly
548 && input
549 .get("command")
550 .and_then(Value::as_str)
551 .is_some_and(|command| {
552 command.split_whitespace().next() == Some("sed") && !command.contains('|')
553 })
554 && super::shell::agent_readonly_bash_input(input);
555 if tool.is_read_only_for(input) || bounded_sed {
556 if authority.shell != crate::tools::spec::ToolShellAuthority::ReadOnly {
557 return Err(ToolError::permission_denied(format!(
558 "worker '{}' cannot run {name}: its machine-readable authority envelope does not grant read-only shell access",
559 authority.owner
560 )));
561 }
562 let networked_read = input
563 .get("command")
564 .and_then(Value::as_str)
565 .is_some_and(codewhale_execpolicy::command_safety::is_github_readonly_command);
566 if networked_read && authority.network_access != Some(true) {
567 return Err(ToolError::permission_denied(format!(
568 "worker '{}' cannot use read-only GitHub CLI access: its machine-readable authority envelope does not grant network access",
569 authority.owner
570 )));
571 }
572 return Ok(());
573 }
574 return Err(ToolError::permission_denied(format!(
575 "worker '{}' cannot run {name}: arbitrary command execution is outside its machine-readable authority envelope. {}",
576 authority.owner,
577 codewhale_execpolicy::command_safety::readonly_command_help()
578 )));
579 }
580 if name == "Run" {
581 if bounded_verifier {
582 use crate::tools::execution_envelope::{VerificationBound, classify_verification};
583
584 let canonical = crate::tools::canonical_action::canonical_action_alias(name, input);
585 if matches!(
586 classify_verification(canonical, input),
587 Some(VerificationBound::Default | VerificationBound::Filter)
588 ) {
589 return Ok(());
590 }
591 return Err(ToolError::permission_denied(format!(
592 "worker '{}' cannot run unbounded verification arguments or commands. Re-run the default gate instead: drop `commands` (run_verifiers) and any flag that can redirect what runs (run_tests `args` may only select tests), and report the blocked probe to the parent rather than working around it.",
593 authority.owner
594 )));
595 }
596 return Err(ToolError::permission_denied(format!(
597 "worker '{}' cannot run {name}: arbitrary command execution is outside its machine-readable authority envelope. {}",
598 authority.owner,
599 codewhale_execpolicy::command_safety::readonly_command_help()
600 )));
601 }
602 if name == "Git" || name.starts_with("git_") || name == "review" {
603 return Err(ToolError::permission_denied(format!(
604 "worker '{}' cannot run {name}: repository-configured Git helpers cannot prove read-only execution under its machine-readable authority envelope",
605 authority.owner
606 )));
607 }
608 if tool.is_read_only_for(input) {
609 return Ok(());
610 }
611 if capabilities.contains(&ToolCapability::ExecutesCode) {
612 return Err(ToolError::permission_denied(format!(
613 "worker '{}' cannot run {name}: code or child execution is outside its machine-readable authority envelope",
614 authority.owner
615 )));
616 }
617 if let Some(paths) = authority_mutation_paths(name, input)? {
618 if paths.is_empty() {
619 return Err(ToolError::permission_denied(format!(
620 "worker '{}' mutation through {name} did not expose a bounded file target",
621 authority.owner
622 )));
623 }
624 for path in paths {
625 if !authority.permits_mutation_path(context, &path)? {
626 return Err(ToolError::permission_denied(format!(
627 "worker '{}' cannot mutate '{path}' outside its machine-readable authority envelope",
628 authority.owner
629 )));
630 }
631 }
632 return Ok(());
633 }
634 Err(ToolError::permission_denied(format!(
635 "worker '{}' cannot run mutating tool {name}: the call has no authorized file target",
636 authority.owner
637 )))
638 }
639
640 fn authority_mutation_paths(name: &str, input: &Value) -> Result<Option<Vec<String>>, ToolError> {
641 let canonical = crate::tools::canonical_action::canonical_action_alias(name, input);
642 let is_patch = canonical == "apply_patch"
643 || (name == "File" && input.get("action").and_then(Value::as_str) == Some("patch"));
644 if is_patch {
645 let mut patch_input = input.clone();
646 if let Some(object) = patch_input.as_object_mut() {
647 object.remove("action");
648 }
649 let paths = crate::tools::apply_patch::preflight_apply_patch(&patch_input)
650 .map_err(|error| ToolError::invalid_input(error.to_string()))?
651 .touched_files;
652 return Ok(Some(paths));
653 }
654 let path_bound = matches!(canonical, "write_file" | "edit_file" | "fim_edit")
655 || (name == "File"
656 && input
657 .get("action")
658 .and_then(Value::as_str)
659 .is_some_and(|action| matches!(action, "write" | "edit")))
660 || (name == "pandoc_convert" && input.get("output_path").is_some());
661 if !path_bound {
662 return Ok(None);
663 }
664 Ok(Some(
665 input
666 .get("path")
667 .or_else(|| input.get("output_path"))
668 .and_then(Value::as_str)
669 .map(|path| vec![path.to_string()])
670 .unwrap_or_default(),
671 ))
672 }
673
674 /// Builder for constructing a `ToolRegistry` with common tools.
675 pub struct ToolRegistryBuilder {
676 tools: Vec<Arc<dyn ToolSpec>>,
677 }
678
679 /// Feature/config-dependent native Agent-mode tool surface.
680 ///
681 /// Parent Agent/Yolo turns and default child sub-agents both build through this
682 /// options object so the catalog does not drift as new first-party tools are
683 /// gated behind feature flags or config state.
684 #[derive(Clone)]
685 pub struct AgentToolSurfaceOptions {
686 pub shell_policy: crate::worker_profile::ShellPolicy,
687 pub apply_patch_enabled: bool,
688 pub web_search_enabled: bool,
689 pub memory_tool_enabled: bool,
690 pub vision_config: Option<crate::config::VisionModelConfig>,
691 pub speech_output_dir: Option<PathBuf>,
692 pub goal_state: Option<SharedGoalState>,
693 /// Register the agent-callable `verify` self-critique tool (#4196).
694 /// Gated by `Feature::Verify` (`[features] verify_tool`), default on.
695 pub verify_tool_enabled: bool,
696 /// `request_user_input` payload ceilings from `[tools]` (#5949). Carried on
697 /// the surface options so model-spawned children inherit the parent's
698 /// configured limits instead of silently falling back to the defaults.
699 pub user_input_limits: super::user_input::UserInputLimits,
700 }
701
702 impl AgentToolSurfaceOptions {
703 #[must_use]
704 pub fn new(shell_policy: crate::worker_profile::ShellPolicy) -> Self {
705 Self {
706 shell_policy,
707 apply_patch_enabled: false,
708 web_search_enabled: false,
709 memory_tool_enabled: false,
710 vision_config: None,
711 speech_output_dir: None,
712 goal_state: None,
713 verify_tool_enabled: true,
714 user_input_limits: super::user_input::UserInputLimits::default(),
715 }
716 }
717 }
718
719 impl ToolRegistryBuilder {
720 /// Create a new builder.
721 #[must_use]
722 pub fn new() -> Self {
723 Self { tools: Vec::new() }
724 }
725
726 /// Add a custom tool.
727 #[must_use]
728 pub fn with_tool(mut self, tool: Arc<dyn ToolSpec>) -> Self {
729 // A later builder step that supplies an existing name is an intended
730 // upgrade (`with_patch_tools` swaps the default `File` for the
731 // patch-capable one), so replace in place. `ToolRegistry::register`
732 // keeps warning about the collisions that are not planned (#5934).
733 let name = tool.name().to_string();
734 if let Some(slot) = self
735 .tools
736 .iter_mut()
737 .find(|existing| existing.name() == name)
738 {
739 *slot = tool;
740 } else {
741 self.tools.push(tool);
742 }
743 self
744 }
745
746 #[must_use]
747 pub fn with_dynamic_tools(mut self, dynamic_tools: &[DynamicToolSpec]) -> Self {
748 for tool in dynamic_tools {
749 self = self.with_tool(Arc::new(super::dynamic::RuntimeDynamicTool::new(
750 tool.clone(),
751 )));
752 }
753 self
754 }
755
756 /// Include file tools (read, write, edit, list).
757 #[must_use]
758 pub fn with_file_tools(self) -> Self {
759 use super::file::{EditFileTool, ListDirTool, ReadFileTool, WriteFileTool};
760 use super::file_tool::{EditTool, FileTool, ReadTool, WriteTool};
761 self.with_tool(Arc::new(ReadTool))
762 .with_tool(Arc::new(WriteTool))
763 .with_tool(Arc::new(EditTool))
764 // Compatibility-only execution names for saved transcripts and
765 // protocol clients. `model_visible=false` keeps them out of new
766 // catalogs.
767 .with_tool(Arc::new(FileTool::new("File")))
768 .with_tool(Arc::new(ReadFileTool))
769 .with_tool(Arc::new(WriteFileTool))
770 .with_tool(Arc::new(EditFileTool))
771 .with_tool(Arc::new(ListDirTool))
772 }
773
774 /// Include only read-only file tools (read, list).
775 #[must_use]
776 #[cfg_attr(not(test), expect(dead_code))]
777 pub fn with_read_only_file_tools(self) -> Self {
778 use super::file::{ListDirTool, ReadFileTool};
779 use super::file_tool::FileTool;
780 use super::file_tool::ReadTool;
781 self.with_tool(Arc::new(ReadTool))
782 .with_tool(Arc::new(FileTool::read_only("File")))
783 .with_tool(Arc::new(ReadFileTool))
784 .with_tool(Arc::new(ListDirTool))
785 .with_tool(Arc::new(
786 super::tool_result_retrieval::RetrieveToolResultTool,
787 ))
788 }
789
790 /// Include shell execution tools.
791 ///
792 /// New turns expose lowercase `bash`; uppercase `Bash` remains a hidden
793 /// compatibility name for saved v0.9.x transcripts.
794 #[must_use]
795 pub fn with_shell_tools(self) -> Self {
796 self.with_foreground_shell_tools().with_terminal_tools()
797 }
798
799 /// Include only the cancellable foreground shell tool.
800 ///
801 /// Protocol hosts that cannot safely own a persistent PTY session use
802 /// this surface instead of [`Self::with_shell_tools`].
803 #[must_use]
804 pub fn with_foreground_shell_tools(self) -> Self {
805 use super::shell::{BashTool, LowercaseBashTool};
806 self.with_tool(Arc::new(LowercaseBashTool))
807 .with_tool(Arc::new(BashTool::new("Bash")))
808 }
809
810 /// Include only the foreground, direct-argv read-only shell surface.
811 #[must_use]
812 pub fn with_read_only_shell_tool(self) -> Self {
813 use super::shell::{BashTool, LowercaseBashTool};
814 self.with_tool(Arc::new(LowercaseBashTool))
815 .with_tool(Arc::new(BashTool::read_only("Bash")))
816 }
817
818 /// Include the stateful PTY terminal tools. Like `exec_shell`, these are
819 /// only exposed when the active shell policy allows shell access.
820 #[cfg(not(target_env = "ohos"))]
821 #[must_use]
822 pub fn with_terminal_tools(self) -> Self {
823 use super::terminal_session::{
824 TerminalCancelTool, TerminalResetTool, TerminalRunTool, TerminalSendTool,
825 TerminalWaitTool,
826 };
827 self.with_tool(Arc::new(TerminalRunTool))
828 .with_tool(Arc::new(TerminalSendTool))
829 .with_tool(Arc::new(TerminalWaitTool))
830 .with_tool(Arc::new(TerminalCancelTool))
831 .with_tool(Arc::new(TerminalResetTool))
832 }
833
834 /// OpenHarmony does not include the `portable-pty` dependency, so keep the
835 /// ordinary shell tools without advertising unavailable persistent PTYs.
836 #[cfg(target_env = "ohos")]
837 #[must_use]
838 pub fn with_terminal_tools(self) -> Self {
839 self
840 }
841
842 /// Search is part of the canonical `File` action surface.
843 #[must_use]
844 pub fn with_search_tools(self) -> Self {
845 self.with_tool(Arc::new(super::file_search::FileSearchTool))
846 .with_tool(Arc::new(super::search::GrepFilesTool))
847 }
848
849 /// Include the canonical `Git` inspection/history surface.
850 #[must_use]
851 pub fn with_git_tools(self) -> Self {
852 use super::git_tool::GitTool;
853 self.with_tool(Arc::new(GitTool::new("Git")))
854 }
855
856 /// Git history is part of the canonical `Git` action surface.
857 #[must_use]
858 pub fn with_git_history_tools(self) -> Self {
859 self
860 }
861
862 /// Include workspace diagnostics tool.
863 #[must_use]
864 pub fn with_diagnostics_tool(self) -> Self {
865 use super::diagnostics::DiagnosticsTool;
866 self.with_tool(Arc::new(DiagnosticsTool))
867 }
868
869 /// Include the `tui_help` command/keybinding reference (#1708). The
870 /// catalog it reads is compiled in, so there is nothing to probe.
871 #[must_use]
872 pub fn with_tui_help_tool(self) -> Self {
873 use super::tui_help::TuiHelpTool;
874 self.with_tool(Arc::new(TuiHelpTool))
875 }
876
877 /// Include the `pandoc_convert` tool only when the `pandoc`
878 /// binary is present on this host. Same probe-then-decide
879 /// pattern v0.8.31 introduced for Python — when pandoc is
880 /// missing the tool is not registered, so the model never
881 /// sees a binary it can't actually use.
882 #[must_use]
883 pub fn with_pandoc_tools(self) -> Self {
884 if crate::dependencies::resolve_pandoc().is_some() {
885 use super::pandoc::PandocConvertTool;
886 self.with_tool(Arc::new(PandocConvertTool))
887 } else {
888 self
889 }
890 }
891
892 /// Include the `image_ocr` tool only when a local OCR backend is present.
893 /// macOS uses the built-in Vision framework, while other platforms use
894 /// Tesseract when installed.
895 #[must_use]
896 pub fn with_image_ocr_tools(self) -> Self {
897 if super::image_ocr::ocr_available() {
898 use super::image_ocr::ImageOcrTool;
899 self.with_tool(Arc::new(ImageOcrTool))
900 } else {
901 self
902 }
903 }
904
905 /// Include the `read_media` tool for safe multimodal media inspection.
906 #[must_use]
907 pub fn with_read_media_tool(self) -> Self {
908 use super::read_media::ReadMediaTool;
909 self.with_tool(Arc::new(ReadMediaTool))
910 }
911
912 /// Include the `load_skill` tool (#434) so the model can pull a
913 /// SKILL.md body + companion file list into context with one
914 /// call instead of `read_file` + `list_dir` against the path
915 /// shown in the system prompt's `## Skills` section.
916 #[must_use]
917 pub fn with_skill_tools(self) -> Self {
918 use super::skill::LoadSkillTool;
919 self.with_tool(Arc::new(LoadSkillTool))
920 }
921
922 /// Include project mapping tools.
923 #[must_use]
924 pub fn with_project_tools(self) -> Self {
925 use super::project::ProjectMapTool;
926 self.with_tool(Arc::new(ProjectMapTool))
927 }
928
929 /// Include cargo test runner tool.
930 #[must_use]
931 pub fn with_test_runner_tool(self) -> Self {
932 use super::run_tool::RunTool;
933 self.with_tool(Arc::new(RunTool::new("Run")))
934 }
935
936 /// Include structured data validation tool (`validate_data`).
937 #[must_use]
938 pub fn with_validation_tools(self) -> Self {
939 use super::validate_data::ValidateDataTool;
940 self.with_tool(Arc::new(ValidateDataTool))
941 }
942
943 /// Include retrieval for spilled historical tool results.
944 #[must_use]
945 pub fn with_tool_result_retrieval_tool(self) -> Self {
946 use super::tool_result_retrieval::RetrieveToolResultTool;
947 self.with_tool(Arc::new(RetrieveToolResultTool))
948 }
949
950 /// Include durable task, gate, PR-attempt, GitHub, and automation tools.
951 ///
952 /// Each family is one tool with an `action` parameter (`tasks`, `github`,
953 /// `automation`). Per-action execution aliases were removed in v0.9.3.
954 ///
955 /// Shell-related task tools (`task_shell_start`, `task_shell_wait`) are
956 /// *not* included here — use `with_runtime_task_shell_tools` to register
957 /// them when `allow_shell` is true.
958 #[must_use]
959 pub fn with_runtime_task_tools(self) -> Self {
960 use super::automation::AutomationTool;
961 use super::github::GithubTool;
962 use super::send_later::SendLaterTool;
963 use super::tasks::TasksTool;
964
965 self.with_tool(Arc::new(TasksTool::new("tasks")))
966 .with_tool(Arc::new(GithubTool::new("github")))
967 .with_tool(Arc::new(AutomationTool::new("automation")))
968 .with_tool(Arc::new(SendLaterTool::new("send_later")))
969 }
970
971 /// Include shell-related task tools (`task_shell_start`, `task_shell_wait`).
972 ///
973 /// These are gated behind `allow_shell` because `task_shell_start`
974 /// delegates directly to `BashTool`, providing the same shell
975 /// execution capability as `Bash`.
976 #[must_use]
977 pub fn with_runtime_task_shell_tools(self) -> Self {
978 use super::tasks::{TaskShellStartTool, TaskShellWaitTool};
979 self.with_tool(Arc::new(TaskShellStartTool))
980 .with_tool(Arc::new(TaskShellWaitTool))
981 }
982
983 /// Include only read-only durable task, PR-attempt, GitHub, and automation
984 /// inspection tools. Plan mode uses this surface so it can observe state
985 /// without starting work, changing remotes, or mutating automation config.
986 ///
987 /// The model sees the same canonical `tasks` / `github` / `automation` /
988 /// `send_later` tools as the full surface, restricted to their read-only
989 /// actions.
990 #[must_use]
991 pub fn with_runtime_read_only_task_tools(self) -> Self {
992 use super::automation::AutomationTool;
993 use super::github::GithubTool;
994 use super::send_later::SendLaterTool;
995 use super::tasks::TasksTool;
996
997 self.with_tool(Arc::new(TasksTool::read_only("tasks")))
998 .with_tool(Arc::new(GithubTool::read_only("github")))
999 .with_tool(Arc::new(AutomationTool::read_only("automation")))
1000 .with_tool(Arc::new(SendLaterTool::read_only("send_later")))
1001 }
1002
1003 /// Include web search and fetch tools.
1004 ///
1005 /// These are feature-gated behind `Feature::WebSearch` in `tool_setup.rs`.
1006 /// `finance` is registered separately via `with_finance_tool()` and is
1007 /// NOT gated behind the web-search feature.
1008 #[must_use]
1009 pub fn with_web_tools(self) -> Self {
1010 use super::web_run::WebRunTool;
1011 use super::web_tool::WebTool;
1012 self.with_tool(Arc::new(WebTool::new("Web")))
1013 .with_tool(Arc::new(WebRunTool))
1014 }
1015
1016 /// Include the `finance` market-data tool.
1017 ///
1018 /// This tool is registered unconditionally for agent modes and is NOT
1019 /// gated behind `Feature::WebSearch` (it fetches financial data, not
1020 /// web search results).
1021 #[must_use]
1022 pub fn with_finance_tool(self) -> Self {
1023 use super::finance::FinanceTool;
1024 self.with_tool(Arc::new(FinanceTool::new()))
1025 }
1026
1027 /// Register the `image_analyze` vision tool.
1028 /// Only registered when `[vision_model]` is configured in config.toml.
1029 #[must_use]
1030 pub fn with_vision_tools(
1031 self,
1032 config: crate::config::VisionModelConfig,
1033 route_client: Option<CodewhaleClient>,
1034 ) -> Self {
1035 use crate::vision::tools::ImageAnalyzeTool;
1036 self.with_tool(Arc::new(ImageAnalyzeTool::new_with_route_client(
1037 config,
1038 route_client,
1039 )))
1040 }
1041
1042 /// Include request_user_input tool under the session's configured payload
1043 /// ceilings (`[tools] user_input_max_questions` / `user_input_max_options`,
1044 /// #5949). The limits ride the tool instance so the validator, the JSON
1045 /// schema, and the model-visible description cannot drift apart.
1046 #[must_use]
1047 pub fn with_user_input_tool(self, limits: super::user_input::UserInputLimits) -> Self {
1048 use super::user_input::RequestUserInputTool;
1049 self.with_tool(Arc::new(RequestUserInputTool::new(limits)))
1050 }
1051
1052 /// Include patch tools (`apply_patch`).
1053 #[must_use]
1054 pub fn with_patch_tools(self) -> Self {
1055 use super::file_tool::FileTool;
1056 self.with_tool(Arc::new(FileTool::with_patch("File")))
1057 .with_tool(Arc::new(super::apply_patch::ApplyPatchTool))
1058 }
1059
1060 /// Include the `revert_turn` tool. Approval-gated since it mutates
1061 /// the workspace; the model uses it when the user asks to "undo my
1062 /// last edit". Backed by the per-workspace snapshot side-repo
1063 /// (`crate::snapshot`).
1064 #[must_use]
1065 pub fn with_revert_turn_tool(self) -> Self {
1066 use super::revert_turn::RevertTurnTool;
1067 self.with_tool(Arc::new(RevertTurnTool))
1068 }
1069
1070 /// Include the speech/TTS tool: `speech` is model-visible, `tts` is a
1071 /// hidden compat alias for saved-transcript replay (#5941).
1072 #[must_use]
1073 pub fn with_speech_tools(
1074 self,
1075 client: Option<CodewhaleClient>,
1076 output_dir: Option<PathBuf>,
1077 ) -> Self {
1078 use super::speech::SpeechTool;
1079 self.with_tool(Arc::new(SpeechTool::new(
1080 "speech",
1081 client.clone(),
1082 output_dir.clone(),
1083 )))
1084 .with_tool(Arc::new(SpeechTool::alias("tts", client, output_dir)))
1085 }
1086
1087 /// Include the canonical persistent RLM session tool.
1088 #[must_use]
1089 pub fn with_rlm_tool(self, client: Option<CodewhaleClient>, root_model: String) -> Self {
1090 use super::rlm::RlmTool;
1091 self.with_tool(Arc::new(
1092 RlmTool::new("rlm", client).with_root_model(root_model),
1093 ))
1094 }
1095
1096 /// Include the persistent, project-scoped continual-harness controller.
1097 #[must_use]
1098 pub fn with_harness_tool(self) -> Self {
1099 use super::harness::HarnessTool;
1100 self.with_tool(Arc::new(HarnessTool))
1101 }
1102
1103 /// Include `handle_read`, the bounded projection reader for symbolic
1104 /// `var_handle` payloads.
1105 #[must_use]
1106 pub fn with_handle_tools(self) -> Self {
1107 use super::handle::HandleReadTool;
1108 self.with_tool(Arc::new(HandleReadTool))
1109 }
1110
1111 /// Include the review tool.
1112 #[must_use]
1113 pub fn with_review_tool(self, client: Option<CodewhaleClient>, model: String) -> Self {
1114 use super::review::ReviewTool;
1115 self.with_tool(Arc::new(ReviewTool::new(client, model)))
1116 }
1117
1118 /// Include the agent-callable `verify` self-critique tool (#4196). The
1119 /// critic runs at elevated reasoning (default `Max`) independent of the
1120 /// session tier and is given no tools, so it cannot recurse into `verify`.
1121 #[must_use]
1122 pub fn with_verify_tool(self, client: Option<CodewhaleClient>, model: String) -> Self {
1123 use super::verify::VerifyTool;
1124 self.with_tool(Arc::new(VerifyTool::new(client, model)))
1125 }
1126
1127 /// Include note tool.
1128 #[must_use]
1129 pub fn with_note_tool(self) -> Self {
1130 use super::shell::NoteTool;
1131 self.with_tool(Arc::new(NoteTool))
1132 }
1133
1134 /// Include the FIM (Fill-in-the-Middle) edit tool.
1135 #[must_use]
1136 pub fn with_fim_tool(self, client: Option<CodewhaleClient>, model: String) -> Self {
1137 use super::fim::FimEditTool;
1138 self.with_tool(Arc::new(FimEditTool::new(client, model)))
1139 }
1140
1141 /// Include the `remember` tool — model-callable bullet-add into the
1142 /// user memory file (#489). Only register when the user has opted
1143 /// in to the memory feature; without that, the tool would surface
1144 /// in the model's catalog but always fail with "memory disabled".
1145 #[must_use]
1146 pub fn with_remember_tool(self) -> Self {
1147 use super::remember::RememberTool;
1148 self.with_tool(Arc::new(RememberTool))
1149 }
1150
1151 /// Include the native-memory retrieval tools alongside reviewed capture.
1152 #[must_use]
1153 pub fn with_native_memory_tools(self) -> Self {
1154 use super::native_memory::{MemoryGetTool, MemorySearchTool};
1155 self.with_tool(Arc::new(MemorySearchTool))
1156 .with_tool(Arc::new(MemoryGetTool))
1157 }
1158
1159 /// Include the prior-session recall tools (#5715). Always-on: they are
1160 /// read-only and workspace-scoped, so there is no opt-in to honor.
1161 #[must_use]
1162 pub fn with_session_recall_tools(self) -> Self {
1163 use super::session::{SessionGetTool, SessionSearchTool};
1164 self.with_tool(Arc::new(SessionSearchTool))
1165 .with_tool(Arc::new(SessionGetTool))
1166 }
1167
1168 /// Include the model-facing LSP intelligence tools. They reuse the
1169 /// session [`crate::lsp::LspManager`] attached to `ToolContext` and never
1170 /// spawn a second server lifecycle.
1171 #[must_use]
1172 pub fn with_lsp_tool(self) -> Self {
1173 use super::lsp::LspTool;
1174 self.with_tool(Arc::new(LspTool))
1175 }
1176
1177 /// Include the `notify` tool — model-callable desktop notification
1178 /// (#1322). Routes through the existing `tui::notifications` OSC 9 /
1179 /// BEL pipeline so the user's `[notifications].method` config is
1180 /// honoured automatically (including `off`). Always safe to register
1181 /// because the tool has no side effects beyond a single terminal
1182 /// escape write.
1183 #[must_use]
1184 pub fn with_notify_tool(self) -> Self {
1185 use super::notify::NotifyTool;
1186 self.with_tool(Arc::new(NotifyTool))
1187 }
1188
1189 /// Include `request_plugin_install` — model-callable review request.
1190 /// Never installs; the TUI surfaces `/plugin trust` or catalog install
1191 /// for the human.
1192 #[must_use]
1193 pub fn with_request_plugin_install_tool(self) -> Self {
1194 use super::request_plugin_install::RequestPluginInstallTool;
1195 self.with_tool(Arc::new(RequestPluginInstallTool))
1196 }
1197
1198 /// Include MCP tools from a connected pool as first-class registry
1199 /// citizens. Each MCP tool is wrapped in a lightweight adapter that
1200 /// implements `ToolSpec`, so the unified `ToolRegistryBuilder` flow
1201 /// handles them alongside native tools.
1202 ///
1203 /// MCP tools are marked `defer_loading` by default (except discovery
1204 /// helpers) to keep the model-visible catalog compact.
1205 #[must_use]
1206 pub fn with_mcp_tools(
1207 mut self,
1208 mcp_pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
1209 ) -> Self {
1210 // Snapshot the current tool list from the pool (non-blocking).
1211 // The adapter lazily resolves at execution time via the pool.
1212 if let Ok(pool) = mcp_pool.try_lock() {
1213 let tool_servers = pool.resolved_tool_servers();
1214 for (name, tool) in pool.all_tools() {
1215 let adapter = Arc::new(McpToolAdapter {
1216 server_name: tool_servers.get(&name).cloned(),
1217 name: name.clone(),
1218 tool: tool.clone(),
1219 pool: mcp_pool.clone(),
1220 });
1221 self.tools.push(adapter);
1222 }
1223 }
1224 self
1225 }
1226
1227 /// Register the `start_mcp_server` tool for dynamically adding MCP servers
1228 /// from conversation context. Does not register MCP tool adapters — those
1229 /// are returned by `pool.to_api_tools()` in `engine.mcp_tools()`.
1230 #[must_use]
1231 pub fn with_runtime_mcp_tool(
1232 mut self,
1233 mcp_pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
1234 ) -> Self {
1235 self.tools
1236 .push(Arc::new(super::runtime_mcp::StartRuntimeMcpServer::new(
1237 mcp_pool,
1238 )));
1239 self
1240 }
1241
1242 /// Register the `registry_sync` tool for fetching and caching
1243 /// MCP Registry server metadata.
1244 #[must_use]
1245 pub fn with_registry_mcp_sync_tool(mut self) -> Self {
1246 self.tools
1247 .push(Arc::new(super::mcp_registry::McpSyncRegistry::new()));
1248 self
1249 }
1250
1251 /// Register the structured Registry launcher. Unlike `start_mcp_server`,
1252 /// this accepts no free-form command and can only launch cached,
1253 /// zero-environment stdio candidates.
1254 #[must_use]
1255 pub fn with_registry_mcp_start_tool(
1256 mut self,
1257 mcp_pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
1258 ) -> Self {
1259 self.tools
1260 .push(Arc::new(super::mcp_registry::StartRegistryMcpServer::new(
1261 mcp_pool,
1262 )));
1263 self
1264 }
1265
1266 /// Include all agent tools under a typed shell policy.
1267 #[must_use]
1268 pub fn with_agent_tools_policy(
1269 self,
1270 shell_policy: crate::worker_profile::ShellPolicy,
1271 user_input_limits: super::user_input::UserInputLimits,
1272 ) -> Self {
1273 let builder = self
1274 .with_file_tools()
1275 .with_note_tool()
1276 .with_search_tools()
1277 .with_user_input_tool(user_input_limits)
1278 .with_git_tools()
1279 .with_git_history_tools()
1280 .with_diagnostics_tool()
1281 .with_tui_help_tool()
1282 .with_lsp_tool()
1283 .with_project_tools()
1284 .with_skill_tools()
1285 .with_test_runner_tool()
1286 .with_validation_tools()
1287 .with_tool_result_retrieval_tool()
1288 .with_handle_tools()
1289 .with_runtime_task_tools()
1290 .with_revert_turn_tool()
1291 .with_pandoc_tools()
1292 .with_image_ocr_tools()
1293 .with_read_media_tool()
1294 .with_finance_tool();
1295
1296 match shell_policy {
1297 crate::worker_profile::ShellPolicy::Full => {
1298 builder.with_shell_tools().with_runtime_task_shell_tools()
1299 }
1300 crate::worker_profile::ShellPolicy::ReadOnly => builder.with_read_only_shell_tool(),
1301 crate::worker_profile::ShellPolicy::None => builder,
1302 }
1303 }
1304
1305 /// Include the native Agent-mode surface shared by the parent runtime and
1306 /// default child sub-agents, excluding the `agent` launcher itself.
1307 #[must_use]
1308 pub fn with_agent_runtime_surface(
1309 self,
1310 client: Option<CodewhaleClient>,
1311 model: String,
1312 options: AgentToolSurfaceOptions,
1313 todo_list: super::todo::SharedTodoList,
1314 plan_state: super::plan::SharedPlanState,
1315 ) -> Self {
1316 let speech_client = client.clone();
1317 let vision_client = client.clone();
1318 let verify_client = client.clone();
1319 let verify_model = model.clone();
1320 let mut builder = self
1321 .with_agent_tools_policy(options.shell_policy, options.user_input_limits)
1322 .with_todo_tool(todo_list)
1323 .with_plan_tool(plan_state)
1324 .with_review_tool(client.clone(), model.clone())
1325 .with_rlm_tool(client.clone(), model.clone())
1326 .with_harness_tool()
1327 .with_fim_tool(client, model);
1328
1329 // No client means no speech provider to call: do not advertise a
1330 // capability the session cannot deliver (#5941).
1331 if speech_client.is_some() {
1332 builder = builder.with_speech_tools(speech_client, options.speech_output_dir.clone());
1333 }
1334
1335 if options.verify_tool_enabled {
1336 builder = builder.with_verify_tool(verify_client, verify_model);
1337 }
1338 if let Some(goal_state) = options.goal_state {
1339 builder = builder.with_goal_tools(goal_state);
1340 }
1341 if options.apply_patch_enabled {
1342 builder = builder.with_patch_tools();
1343 }
1344 if options.web_search_enabled {
1345 builder = builder.with_web_tools();
1346 }
1347 if options.memory_tool_enabled {
1348 builder = builder.with_remember_tool().with_native_memory_tools();
1349 }
1350 if let Some(vision_config) = options.vision_config {
1351 builder = builder.with_vision_tools(vision_config, vision_client);
1352 }
1353
1354 builder
1355 .with_notify_tool()
1356 .with_request_plugin_install_tool()
1357 .with_session_recall_tools()
1358 }
1359
1360 /// Include the full child-inherited Agent surface under resolved
1361 /// feature/config options.
1362 #[must_use]
1363 #[allow(clippy::too_many_arguments)]
1364 pub fn with_full_agent_surface_options(
1365 self,
1366 client: Option<CodewhaleClient>,
1367 model: String,
1368 manager: super::subagent::SharedSubAgentManager,
1369 runtime: super::subagent::SubAgentRuntime,
1370 options: AgentToolSurfaceOptions,
1371 todo_list: super::todo::SharedTodoList,
1372 plan_state: super::plan::SharedPlanState,
1373 ) -> Self {
1374 self.with_agent_runtime_surface(client, model, options, todo_list, plan_state)
1375 .with_subagent_tools(manager, runtime)
1376 }
1377
1378 /// Include the canonical work-progress tool with a shared `TodoList`.
1379 /// Canonical is `todo_write`; `work_update`/`TodoWrite`/`todo` are hidden
1380 /// compat aliases (not model-visible) for saved-transcript replay.
1381 #[must_use]
1382 pub fn with_todo_tool(self, todo_list: super::todo::SharedTodoList) -> Self {
1383 use super::todo::TodoWriteTool;
1384 self.with_tool(Arc::new(TodoWriteTool::new(todo_list.clone())))
1385 .with_tool(Arc::new(TodoWriteTool::alias(
1386 "work_update",
1387 todo_list.clone(),
1388 )))
1389 .with_tool(Arc::new(TodoWriteTool::alias(
1390 "TodoWrite",
1391 todo_list.clone(),
1392 )))
1393 .with_tool(Arc::new(TodoWriteTool::alias("todo", todo_list.clone())))
1394 .with_tool(Arc::new(TodoWriteTool::alias(
1395 "checklist_write",
1396 todo_list.clone(),
1397 )))
1398 .with_tool(Arc::new(TodoWriteTool::alias(
1399 "checklist_update",
1400 todo_list,
1401 )))
1402 }
1403
1404 /// Include the plan tool with a shared `PlanState`.
1405 #[must_use]
1406 pub fn with_plan_tool(self, plan_state: super::plan::SharedPlanState) -> Self {
1407 use super::plan::UpdatePlanTool;
1408 self.with_tool(Arc::new(UpdatePlanTool::new(plan_state)))
1409 }
1410
1411 /// Include runtime goal tools (`create_goal`, `get_goal`, `update_goal`).
1412 #[must_use]
1413 pub fn with_goal_tools(self, goal_state: super::goal::SharedGoalState) -> Self {
1414 use super::goal::{CreateGoalTool, GetGoalTool, UpdateGoalTool};
1415 self.with_tool(Arc::new(CreateGoalTool::new(goal_state.clone())))
1416 .with_tool(Arc::new(GetGoalTool::new(goal_state.clone())))
1417 .with_tool(Arc::new(UpdateGoalTool::new(goal_state)))
1418 }
1419
1420 /// Include sub-agent management tools.
1421 #[must_use]
1422 pub fn with_subagent_tools(
1423 self,
1424 manager: super::subagent::SharedSubAgentManager,
1425 runtime: super::subagent::SubAgentRuntime,
1426 ) -> Self {
1427 use super::subagent::AgentTool;
1428 use super::subagent::register_coordination_tools;
1429 use super::workflow::WorkflowTool;
1430
1431 let builder = self
1432 .with_tool(Arc::new(WorkflowTool::new(
1433 Arc::clone(&manager),
1434 runtime.clone(),
1435 )))
1436 .with_tool(Arc::new(AgentTool::new(
1437 Arc::clone(&manager),
1438 runtime.clone(),
1439 )));
1440 register_coordination_tools(builder, manager, runtime)
1441 }
1442
1443 /// Build the registry with the given context.
1444 #[must_use]
1445 pub fn build(self, context: ToolContext) -> ToolRegistry {
1446 let mut registry = ToolRegistry::new(context);
1447 registry.register_all(self.tools);
1448 registry
1449 }
1450 }
1451
1452 impl Default for ToolRegistryBuilder {
1453 fn default() -> Self {
1454 Self::new()
1455 }
1456 }
1457
1458 /// Convert CamelCase to snake_case.
1459 fn to_snake_case(s: &str) -> String {
1460 let mut out = String::with_capacity(s.len() + 4);
1461 for (i, ch) in s.chars().enumerate() {
1462 if ch.is_uppercase() {
1463 if i > 0 {
1464 out.push('_');
1465 }
1466 out.push(ch.to_ascii_lowercase());
1467 } else {
1468 out.push(ch);
1469 }
1470 }
1471 out
1472 }
1473
1474 /// Adapter that wraps an MCP tool definition so it can live in the
1475 /// unified `ToolRegistry` alongside native tools (§5.B).
1476 struct McpToolAdapter {
1477 name: String,
1478 /// Diagnostic snapshot from the pool's exact route projection.
1479 server_name: Option<String>,
1480 tool: crate::mcp::McpTool,
1481 pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
1482 }
1483
1484 fn is_mcp_read_helper(name: &str) -> bool {
1485 matches!(
1486 name,
1487 "list_mcp_resources"
1488 | "list_mcp_resource_templates"
1489 | "mcp_read_resource"
1490 | "read_mcp_resource"
1491 | "mcp_get_prompt"
1492 )
1493 }
1494
1495 #[async_trait::async_trait]
1496 impl ToolSpec for McpToolAdapter {
1497 fn name(&self) -> &str {
1498 &self.name
1499 }
1500
1501 fn registration_origin(&self) -> std::borrow::Cow<'_, str> {
1502 use crate::safe_label::SafeLabel;
1503 match &self.server_name {
1504 Some(server) => format!(
1505 "MCP server {}, tool {}",
1506 SafeLabel::identifier(server),
1507 SafeLabel::identifier(&self.tool.name)
1508 )
1509 .into(),
1510 None => format!(
1511 "MCP tool {} (server unknown)",
1512 SafeLabel::identifier(&self.name)
1513 )
1514 .into(),
1515 }
1516 }
1517
1518 fn description(&self) -> &str {
1519 // McpTool.description is Option<String>; fall back to the
1520 // prefixed name when absent.
1521 self.tool.description.as_deref().unwrap_or(&self.name)
1522 }
1523
1524 fn input_schema(&self) -> Value {
1525 self.tool.input_schema.clone()
1526 }
1527
1528 fn capabilities(&self) -> Vec<ToolCapability> {
1529 // Conservatively treat MCP tools as requiring approval and
1530 // network access unless they're known discovery helpers.
1531 if is_mcp_read_helper(&self.name) {
1532 vec![ToolCapability::ReadOnly]
1533 } else {
1534 vec![ToolCapability::Network, ToolCapability::RequiresApproval]
1535 }
1536 }
1537
1538 fn approval_requirement(&self) -> ApprovalRequirement {
1539 if is_mcp_read_helper(&self.name) {
1540 ApprovalRequirement::Auto
1541 } else {
1542 ApprovalRequirement::Required
1543 }
1544 }
1545
1546 fn defer_loading(&self) -> bool {
1547 // Discovery helpers stay loaded; everything else is deferred.
1548 !is_mcp_read_helper(&self.name)
1549 }
1550
1551 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
1552 self.execute_rich(input, context)
1553 .await
1554 .map(RichToolResult::into_result)
1555 }
1556
1557 async fn execute_rich(
1558 &self,
1559 input: Value,
1560 context: &ToolContext,
1561 ) -> Result<RichToolResult, ToolError> {
1562 let mut pool = self.pool.lock().await;
1563 let result = pool
1564 .call_tool_with_disallowed(&self.name, input, &context.disallowed_tools)
1565 .await
1566 .map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?;
1567 Ok(mcp_result_to_bounded_rich_tool_result(result))
1568 }
1569 }
1570
1571 const MCP_IMAGE_TEXT_PLACEHOLDER: &str = "[MCP image payload removed from text output]";
1572
1573 /// Map an MCP `tools/call` result to the provider-neutral rich result used by
1574 /// native tools. Image payloads travel as typed blocks instead of being
1575 /// duplicated into the JSON text as multi-megabyte base64 strings.
1576 ///
1577 /// MCP servers signal tool failure with `isError: true` on an otherwise
1578 /// successful JSON-RPC response. Error results keep their text payload
1579 /// verbatim so the model still sees the server's message (#5123-class).
1580 fn mcp_result_to_rich_tool_result(mut result: Value) -> RichToolResult {
1581 let mut content_blocks = Vec::new();
1582 if let Some(items) = result.get_mut("content").and_then(Value::as_array_mut) {
1583 for item in items {
1584 let Some(object) = item.as_object_mut() else {
1585 continue;
1586 };
1587 if object.get("type").and_then(Value::as_str) != Some("image") {
1588 continue;
1589 }
1590
1591 let mime_type = object
1592 .get("mimeType")
1593 .and_then(Value::as_str)
1594 .map(str::to_owned);
1595 let data = object.remove("data");
1596 if data.is_some() {
1597 object.insert(
1598 "data".to_string(),
1599 Value::String(MCP_IMAGE_TEXT_PLACEHOLDER.to_string()),
1600 );
1601 }
1602 // Keep malformed image entries in the typed stream with empty
1603 // fields so the shared rich-result boundary rejects them and
1604 // emits the same visible omission receipt as invalid base64,
1605 // unsupported MIME types, oversized images, and extra images.
1606 // Dropping them here would silently remove the payload before the
1607 // boundary had anything to count.
1608 let (mime_type, data) = match (mime_type, data) {
1609 (Some(mime_type), Some(Value::String(data))) => (mime_type, data),
1610 _ => (String::new(), String::new()),
1611 };
1612 content_blocks.push(ToolResultContentBlock::Image { mime_type, data });
1613 }
1614 }
1615
1616 let content = serde_json::to_string(&result).unwrap_or_else(|_| result.to_string());
1617 let is_error = result
1618 .get("isError")
1619 .and_then(Value::as_bool)
1620 .unwrap_or(false);
1621 let result = if is_error {
1622 let text = result
1623 .get("content")
1624 .and_then(Value::as_array)
1625 .map(|items| {
1626 items
1627 .iter()
1628 .filter_map(|item| item.get("text").and_then(Value::as_str))
1629 .collect::<Vec<_>>()
1630 .join("\n")
1631 })
1632 .filter(|text| !text.is_empty())
1633 .unwrap_or(content);
1634 ToolResult::error(text)
1635 } else {
1636 ToolResult::success(content)
1637 };
1638 RichToolResult::with_content_blocks(result, content_blocks)
1639 }
1640
1641 /// Convert and bound an MCP result at the shared direct/parallel execution
1642 /// seam. The registry applies the same boundary to every rich tool; keeping it
1643 /// here too protects the engine's MCP fast path and text-only adapter callers.
1644 pub(crate) fn mcp_result_to_bounded_rich_tool_result(result: Value) -> RichToolResult {
1645 crate::image_attach::bound_rich_tool_result(mcp_result_to_rich_tool_result(result))
1646 }
1647
1648 #[cfg(test)]
1649 pub(super) fn mcp_tool_adapter_for_test(name: &str) -> Arc<dyn ToolSpec> {
1650 Arc::new(McpToolAdapter {
1651 name: name.to_string(),
1652 server_name: None,
1653 tool: crate::mcp::McpTool {
1654 name: name.to_string(),
1655 description: None,
1656 input_schema: serde_json::json!({"type": "object"}),
1657 },
1658 pool: Arc::new(tokio::sync::Mutex::new(crate::mcp::McpPool::new(
1659 crate::mcp::McpConfig::default(),
1660 ))),
1661 })
1662 }
1663
1664 // === Unit Tests ===
1665
1666 #[cfg(test)]
1667 mod tests;
1668
1668 lines RUST