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