Skip to main content

zeroclaw_runtime/tools/
mod.rs

1//! Tool subsystem for agent-callable capabilities.
2//!
3//! This module implements the tool execution surface exposed to the LLM during
4//! agentic loops. Each tool implements the [`Tool`] trait defined in the
5//! `traits` submodule, which requires a name, description, JSON parameter
6//! schema, and an async `execute` method returning a structured [`ToolResult`].
7//!
8//! Tools are assembled into registries by [`default_tools`] (shell, file read/write)
9//! and [`all_tools`] (full set including memory, browser, cron, HTTP, delegation,
10//! and optional integrations). Security policy enforcement is injected via
11//! [`SecurityPolicy`] at construction time.
12//!
13//! # Extension
14//!
15//! To add a new tool, implement [`Tool`] in a new submodule and register it in
16//! [`all_tools_with_runtime`]. See `AGENTS.md` §7.3 for the full change playbook.
17
18pub mod attribution;
19pub mod cron_add;
20pub(crate) mod cron_common;
21pub mod cron_list;
22pub mod cron_remove;
23pub mod cron_run;
24pub mod cron_runs;
25pub mod cron_update;
26pub mod delegate;
27pub mod file_read;
28pub mod model_switch;
29pub mod read_skill;
30pub mod schedule;
31pub mod security_ops;
32pub mod send_message_to_peer;
33pub mod shell;
34pub mod skill_http;
35pub mod skill_tool;
36pub mod sop_advance;
37pub mod sop_approve;
38pub mod sop_execute;
39pub mod sop_list;
40pub mod sop_status;
41pub mod spawn_subagent;
42pub mod verifiable_intent;
43
44// Tool types from zeroclaw-tools (direct imports, no shims)
45pub use zeroclaw_tools::ask_user::AskUserTool;
46pub use zeroclaw_tools::ask_user::ChannelMapHandle;
47pub use zeroclaw_tools::backup_tool::BackupTool;
48pub use zeroclaw_tools::browser::{BrowserTool, ComputerUseConfig};
49pub use zeroclaw_tools::browser_delegate::BrowserDelegateTool;
50pub use zeroclaw_tools::browser_open::BrowserOpenTool;
51pub use zeroclaw_tools::calculator::CalculatorTool;
52pub use zeroclaw_tools::canvas::{ALLOWED_CONTENT_TYPES, MAX_CONTENT_SIZE};
53pub use zeroclaw_tools::canvas::{CanvasStore, CanvasTool};
54pub use zeroclaw_tools::claude_code::ClaudeCodeTool;
55pub use zeroclaw_tools::claude_code_runner::ClaudeCodeRunnerTool;
56pub use zeroclaw_tools::cli_discovery::{DiscoveredCli, discover_cli_tools};
57pub use zeroclaw_tools::cloud_ops::CloudOpsTool;
58pub use zeroclaw_tools::cloud_patterns::CloudPatternsTool;
59pub use zeroclaw_tools::codex_cli::CodexCliTool;
60pub use zeroclaw_tools::composio::ComposioTool;
61pub use zeroclaw_tools::content_search::ContentSearchTool;
62pub use zeroclaw_tools::data_management::DataManagementTool;
63pub use zeroclaw_tools::discord_search::DiscordSearchTool;
64pub use zeroclaw_tools::escalate::EscalateToHumanTool;
65pub use zeroclaw_tools::file_download::FileDownloadTool;
66pub use zeroclaw_tools::file_edit::FileEditTool;
67pub use zeroclaw_tools::file_upload::FileUploadTool;
68pub use zeroclaw_tools::file_upload_bundle::FileUploadBundleTool;
69pub use zeroclaw_tools::file_write::FileWriteTool;
70pub use zeroclaw_tools::gemini_cli::GeminiCliTool;
71pub use zeroclaw_tools::git_operations::GitOperationsTool;
72pub use zeroclaw_tools::glob_search::GlobSearchTool;
73pub use zeroclaw_tools::google_workspace::GoogleWorkspaceTool;
74pub use zeroclaw_tools::hardware_board_info::HardwareBoardInfoTool;
75pub use zeroclaw_tools::hardware_memory_map::HardwareMemoryMapTool;
76pub use zeroclaw_tools::hardware_memory_read::HardwareMemoryReadTool;
77pub use zeroclaw_tools::http_request::HttpRequestTool;
78pub use zeroclaw_tools::image_gen::ImageGenTool;
79pub use zeroclaw_tools::image_info::ImageInfoTool;
80pub use zeroclaw_tools::jira_tool::JiraTool;
81pub use zeroclaw_tools::knowledge_tool::KnowledgeTool;
82pub use zeroclaw_tools::linkedin::LinkedInTool;
83pub use zeroclaw_tools::llm_task::LlmTaskTool;
84pub use zeroclaw_tools::mcp_client::McpRegistry;
85pub use zeroclaw_tools::mcp_deferred::{
86    ActivatedToolSet, DeferredMcpToolSet, build_deferred_tools_section,
87    build_deferred_tools_section_filtered,
88};
89pub use zeroclaw_tools::mcp_tool::McpToolWrapper;
90pub use zeroclaw_tools::memory_export::MemoryExportTool;
91pub use zeroclaw_tools::memory_forget::MemoryForgetTool;
92pub use zeroclaw_tools::memory_purge::MemoryPurgeTool;
93pub use zeroclaw_tools::memory_recall::MemoryRecallTool;
94pub use zeroclaw_tools::memory_store::MemoryStoreTool;
95pub use zeroclaw_tools::microsoft365::Microsoft365Tool;
96pub use zeroclaw_tools::model_routing_config::ModelRoutingConfigTool;
97pub use zeroclaw_tools::notion_tool::NotionTool;
98pub use zeroclaw_tools::opencode_cli::OpenCodeCliTool;
99#[cfg(feature = "rag-pdf")]
100pub use zeroclaw_tools::pdf_read::PdfReadTool;
101pub use zeroclaw_tools::pipeline::PipelineTool;
102pub use zeroclaw_tools::poll::PollTool;
103pub use zeroclaw_tools::project_intel::ProjectIntelTool;
104pub use zeroclaw_tools::proxy_config::ProxyConfigTool;
105pub use zeroclaw_tools::pushover::PushoverTool;
106pub use zeroclaw_tools::reaction::ReactionTool;
107pub use zeroclaw_tools::report_template_tool::ReportTemplateTool;
108pub use zeroclaw_tools::screenshot::ScreenshotTool;
109pub use zeroclaw_tools::sessions::{
110    SessionDeleteTool, SessionResetTool, SessionsCurrentTool, SessionsHistoryTool,
111    SessionsListTool, SessionsSendTool,
112};
113pub use zeroclaw_tools::text_browser::TextBrowserTool;
114pub use zeroclaw_tools::tool_search::ToolSearchTool;
115pub use zeroclaw_tools::weather_tool::WeatherTool;
116pub use zeroclaw_tools::web_fetch::WebFetchTool;
117pub use zeroclaw_tools::web_search_tool::WebSearchTool;
118pub use zeroclaw_tools::wrappers::{PathGuardedTool, RateLimitedTool};
119
120// Traits from zeroclaw-api
121pub use zeroclaw_api::schema::{CleaningStrategy, SchemaCleanr};
122pub use zeroclaw_api::tool::{Tool, ToolResult, ToolSpec};
123
124// Local tool re-exports (tools with root deps, kept in misc)
125pub use cron_add::CronAddTool;
126pub use cron_list::CronListTool;
127pub use cron_remove::CronRemoveTool;
128pub use cron_run::CronRunTool;
129pub use cron_runs::CronRunsTool;
130pub use cron_update::CronUpdateTool;
131pub use delegate::DelegateTool;
132pub use file_read::FileReadTool;
133pub use model_switch::ModelSwitchTool;
134pub use read_skill::ReadSkillTool;
135pub use schedule::ScheduleTool;
136pub use security_ops::SecurityOpsTool;
137pub use send_message_to_peer::SendMessageToPeerTool;
138pub use shell::ShellTool;
139pub use skill_http::SkillHttpTool;
140pub use skill_tool::{SkillBuiltinTool, SkillShellTool};
141pub use sop_advance::SopAdvanceTool;
142pub use sop_approve::SopApproveTool;
143pub use sop_execute::SopExecuteTool;
144pub use sop_list::SopListTool;
145pub use sop_status::SopStatusTool;
146pub use spawn_subagent::SpawnSubagentTool;
147pub use verifiable_intent::VerifiableIntentTool;
148
149/// Re-entrant agent-spawning tools that must never be collapsed by the
150/// per-turn duplicate-call guard: launching several with the same prompt
151/// (redundancy, sampling, fan-out) is intentional, not an accidental
152/// repeat. Unioned with config-provided exemptions in the tool-call loop.
153pub const REENTRANT_AGENT_TOOLS: &[&str] = &[SpawnSubagentTool::NAME, DelegateTool::NAME];
154
155use crate::platform::{NativeRuntime, RuntimeAdapter};
156use crate::security::{SecurityPolicy, create_sandbox};
157use async_trait::async_trait;
158use parking_lot::RwLock;
159use std::collections::HashMap;
160use std::sync::Arc;
161use zeroclaw_config::schema::{AliasedAgentConfig, Config};
162use zeroclaw_memory::Memory;
163
164/// Per-tool channel-map handle — `Arc<RwLock<HashMap<channel_name, channel>>>`.
165///
166/// Each channel-driven tool owns its own handle so callers can populate it
167/// independently (late-bound registration). Shared alias of the same
168/// underlying type formerly known as `ChannelMapHandle`.
169pub type PerToolChannelHandle =
170    Arc<RwLock<HashMap<String, Arc<dyn zeroclaw_api::channel::Channel>>>>;
171
172/// Shared handle to the delegate tool's parent-tools list.
173/// Callers can push additional tools (e.g. MCP wrappers) after construction.
174pub type DelegateParentToolsHandle = Arc<RwLock<Vec<Arc<dyn Tool>>>>;
175
176/// Thin wrapper that makes an `Arc<dyn Tool>` usable as `Box<dyn Tool>`.
177pub struct ArcToolRef(pub Arc<dyn Tool>);
178// ArcToolRef is the public constructor name for ArcToolWrapper
179
180#[async_trait]
181impl Tool for ArcToolRef {
182    fn name(&self) -> &str {
183        self.0.name()
184    }
185
186    fn description(&self) -> &str {
187        self.0.description()
188    }
189
190    fn parameters_schema(&self) -> serde_json::Value {
191        self.0.parameters_schema()
192    }
193
194    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
195        self.0.execute(args).await
196    }
197}
198
199#[derive(Clone)]
200struct ArcDelegatingTool {
201    inner: Arc<dyn Tool>,
202}
203
204impl ArcDelegatingTool {
205    fn boxed(inner: Arc<dyn Tool>) -> Box<dyn Tool> {
206        Box::new(Self { inner })
207    }
208}
209
210impl ::zeroclaw_api::attribution::Attributable for ArcDelegatingTool {
211    fn role(&self) -> ::zeroclaw_api::attribution::Role {
212        self.inner.role()
213    }
214    fn alias(&self) -> &str {
215        self.inner.alias()
216    }
217}
218
219#[async_trait]
220impl Tool for ArcDelegatingTool {
221    fn name(&self) -> &str {
222        self.inner.name()
223    }
224
225    fn description(&self) -> &str {
226        self.inner.description()
227    }
228
229    fn parameters_schema(&self) -> serde_json::Value {
230        self.inner.parameters_schema()
231    }
232
233    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
234        self.inner.execute(args).await
235    }
236}
237
238fn boxed_registry_from_arcs(tools: Vec<Arc<dyn Tool>>) -> Vec<Box<dyn Tool>> {
239    tools.into_iter().map(ArcDelegatingTool::boxed).collect()
240}
241
242/// Create the default tool registry
243pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
244    default_tools_with_runtime(security, Arc::new(NativeRuntime::new()))
245}
246
247/// Create the default tool registry with explicit runtime adapter.
248pub fn default_tools_with_runtime(
249    security: Arc<SecurityPolicy>,
250    runtime: Arc<dyn RuntimeAdapter>,
251) -> Vec<Box<dyn Tool>> {
252    let persistent_writes = runtime.has_filesystem_access();
253    vec![
254        Box::new(RateLimitedTool::new(
255            PathGuardedTool::new(
256                ShellTool::new(security.clone(), runtime).with_persistent_writes(persistent_writes),
257                security.clone(),
258            ),
259            security.clone(),
260        )),
261        Box::new(RateLimitedTool::new(
262            PathGuardedTool::new(
263                FileReadTool::new_with_persistence(security.clone(), persistent_writes),
264                security.clone(),
265            ),
266            security.clone(),
267        )),
268        Box::new(RateLimitedTool::new(
269            PathGuardedTool::new(
270                FileWriteTool::new_with_persistence(security.clone(), persistent_writes),
271                security.clone(),
272            ),
273            security.clone(),
274        )),
275        Box::new(RateLimitedTool::new(
276            PathGuardedTool::new(
277                FileEditTool::new_with_persistence(security.clone(), persistent_writes),
278                security.clone(),
279            ),
280            security.clone(),
281        )),
282        Box::new(RateLimitedTool::new(
283            PathGuardedTool::new(GlobSearchTool::new(security.clone()), security.clone()),
284            security.clone(),
285        )),
286        Box::new(RateLimitedTool::new(
287            PathGuardedTool::new(ContentSearchTool::new(security.clone()), security.clone()),
288            security,
289        )),
290    ]
291}
292
293/// Register skill-defined tools into an existing tool registry.
294///
295/// Converts each skill's `[[tools]]` entries into callable `Tool` implementations
296/// and appends them to the registry. Skill tools that would shadow a built-in tool
297/// name are skipped with a warning.
298pub fn register_skill_tools(
299    tools_registry: &mut Vec<Box<dyn Tool>>,
300    skills: &[crate::skills::Skill],
301    security: Arc<SecurityPolicy>,
302) {
303    register_skill_tools_with_context(tools_registry, skills, security, &[]);
304}
305
306/// Register skill-defined tools with full context for builtin kinds.
307///
308/// `unfiltered_registry` provides the pre-policy tool list for `kind = "builtin"`
309/// delegation.
310pub fn register_skill_tools_with_context(
311    tools_registry: &mut Vec<Box<dyn Tool>>,
312    skills: &[crate::skills::Skill],
313    security: Arc<SecurityPolicy>,
314    unfiltered_registry: &[Arc<dyn Tool>],
315) {
316    if skills.is_empty() {
317        return;
318    }
319
320    let before = tools_registry.len();
321    let skill_tools =
322        crate::skills::skills_to_tools_with_context(skills, security, unfiltered_registry);
323    let existing_names: std::collections::HashSet<String> = tools_registry
324        .iter()
325        .map(|t| t.name().to_string())
326        .collect();
327    for tool in skill_tools {
328        if existing_names.contains(tool.name()) {
329            ::zeroclaw_log::record!(
330                WARN,
331                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
332                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
333                &format!(
334                    "Skill tool '{}' shadows built-in tool, skipping",
335                    tool.name()
336                )
337            );
338        } else {
339            tools_registry.push(tool);
340        }
341    }
342    let registered = tools_registry.len() - before;
343
344    // Positive-path log — matches how the rest of zeroclaw reports
345    // successful initialization (open-skills clone, daemon startup,
346    // gateway bind, etc.). Without this, a skill that audited clean,
347    // parsed cleanly, and registered N tools leaves zero signal in the
348    // log, which makes SKILL.toml / SKILL.md authoring painful to debug.
349    ::zeroclaw_log::record!(
350        INFO,
351        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
352        &format!(
353            "Registered {} skill tool(s) from {} skill(s): {}",
354            registered,
355            skills.len(),
356            skills
357                .iter()
358                .map(|s| s.name.as_str())
359                .collect::<Vec<_>>()
360                .join(", "),
361        )
362    );
363}
364
365/// Build resolution-only MCP tool wrappers for skill MCP elevation
366/// (`kind = "mcp"`).
367///
368/// These wrappers are **not** added to the model-visible tool registry — they
369/// exist solely so a skill MCP elevation can resolve its `target`
370/// (`{server}__{tool}`, e.g. `images__generate`) by name at registration time
371/// and delegate to it. Cheap: MCP tool definitions are cached at connect time,
372/// so this performs no network I/O. Returned alongside the built-in
373/// `unfiltered_tool_arcs` to form the skill resolution registry.
374pub async fn collect_mcp_elevation_arcs(registry: &Arc<McpRegistry>) -> Vec<Arc<dyn Tool>> {
375    let mut arcs: Vec<Arc<dyn Tool>> = Vec::new();
376    for name in registry.tool_names() {
377        if let Some(def) = registry.get_tool_def(&name).await {
378            arcs.push(Arc::new(McpToolWrapper::new(
379                name,
380                def,
381                Arc::clone(registry),
382            )));
383        }
384    }
385    arcs
386}
387
388/// Always-on built-in tools that surface in the integrations panel as
389/// `(display_name, description)` pairs. The integrations registry consumes
390/// this verbatim — adding a new always-on built-in is one row here, no
391/// edit to the registry. Tools with a config struct (Browser, Cron,
392/// GoogleWorkspace) declare themselves via the `#[integration(...)]`
393/// attribute on the schema struct instead.
394pub const BUILTIN_TOOL_INTEGRATIONS: &[(&str, &str)] = &[
395    ("Shell", "Terminal command execution"),
396    ("File System", "Read/write files"),
397    ("Weather", "Forecasts & conditions (wttr.in)"),
398    (
399        "Spawn SubAgent",
400        "Spawn an ephemeral SubAgent that inherits this agent's identity",
401    ),
402];
403
404/// Bundled return values from tool registry construction.
405///
406/// Named struct to avoid an ever-growing positional tuple that's painful
407/// to destructure across many callers.
408#[allow(clippy::type_complexity)]
409pub struct AllToolsResult {
410    pub tools: Vec<Box<dyn Tool>>,
411    pub delegate_handle: Option<DelegateParentToolsHandle>,
412    pub ask_user_handle: Option<PerToolChannelHandle>,
413    pub reaction_handle: PerToolChannelHandle,
414    pub poll_handle: Option<PerToolChannelHandle>,
415    pub escalate_handle: Option<PerToolChannelHandle>,
416    /// Pre-boxed Arcs of every tool (before policy filter). Used by
417    /// skill-scoped builtin elevation to resolve targets at registration.
418    pub unfiltered_tool_arcs: Vec<Arc<dyn Tool>>,
419}
420
421/// Create full tool registry including memory tools and optional Composio
422#[allow(
423    clippy::implicit_hasher,
424    clippy::too_many_arguments,
425    clippy::type_complexity
426)]
427pub fn all_tools(
428    config: Arc<Config>,
429    security: &Arc<SecurityPolicy>,
430    risk_profile: &zeroclaw_config::schema::RiskProfileConfig,
431    agent_alias: &str,
432    memory: Arc<dyn Memory>,
433    composio_key: Option<&str>,
434    composio_entity_id: Option<&str>,
435    browser_config: &zeroclaw_config::schema::BrowserConfig,
436    http_config: &zeroclaw_config::schema::HttpRequestConfig,
437    web_fetch_config: &zeroclaw_config::schema::WebFetchConfig,
438    workspace_dir: &std::path::Path,
439    agents: &HashMap<String, AliasedAgentConfig>,
440    fallback_api_key: Option<&str>,
441    root_config: &zeroclaw_config::schema::Config,
442    canvas_store: Option<CanvasStore>,
443    is_subagent_caller: bool,
444    tui_env: Option<HashMap<String, String>>,
445) -> AllToolsResult {
446    all_tools_with_runtime(
447        config,
448        security,
449        risk_profile,
450        agent_alias,
451        Arc::new(NativeRuntime::new()),
452        memory,
453        composio_key,
454        composio_entity_id,
455        browser_config,
456        http_config,
457        web_fetch_config,
458        workspace_dir,
459        agents,
460        fallback_api_key,
461        root_config,
462        canvas_store,
463        is_subagent_caller,
464        tui_env,
465    )
466}
467
468/// Create full tool registry including memory tools and optional Composio.
469#[allow(
470    clippy::implicit_hasher,
471    clippy::too_many_arguments,
472    clippy::type_complexity
473)]
474pub fn all_tools_with_runtime(
475    config: Arc<Config>,
476    security: &Arc<SecurityPolicy>,
477    risk_profile: &zeroclaw_config::schema::RiskProfileConfig,
478    agent_alias: &str,
479    runtime: Arc<dyn RuntimeAdapter>,
480    memory: Arc<dyn Memory>,
481    composio_key: Option<&str>,
482    composio_entity_id: Option<&str>,
483    browser_config: &zeroclaw_config::schema::BrowserConfig,
484    http_config: &zeroclaw_config::schema::HttpRequestConfig,
485    web_fetch_config: &zeroclaw_config::schema::WebFetchConfig,
486    workspace_dir: &std::path::Path,
487    agents: &HashMap<String, AliasedAgentConfig>,
488    fallback_api_key: Option<&str>,
489    root_config: &zeroclaw_config::schema::Config,
490    canvas_store: Option<CanvasStore>,
491    is_subagent_caller: bool,
492    tui_env: Option<HashMap<String, String>>,
493) -> AllToolsResult {
494    let has_shell_access = runtime.has_shell_access();
495    let persistent_writes = runtime.has_filesystem_access();
496    let runtime_kind = root_config.runtime.kind.as_str();
497    let sandbox_cfg = risk_profile.sandbox_config();
498    let sandbox = create_sandbox(&sandbox_cfg, runtime_kind, Some(&security.workspace_dir));
499    let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
500        Arc::new(RateLimitedTool::new(
501            PathGuardedTool::new(
502                ShellTool::new_with_sandbox(security.clone(), runtime, sandbox)
503                    .with_timeout_secs(if security.shell_timeout_secs > 0 {
504                        security.shell_timeout_secs
505                    } else {
506                        root_config.shell_tool.timeout_secs
507                    })
508                    .with_tui_env(tui_env)
509                    .with_persistent_writes(persistent_writes),
510                security.clone(),
511            ),
512            security.clone(),
513        )),
514        Arc::new(RateLimitedTool::new(
515            PathGuardedTool::new(
516                FileReadTool::new_with_persistence(security.clone(), persistent_writes),
517                security.clone(),
518            ),
519            security.clone(),
520        )),
521        Arc::new(RateLimitedTool::new(
522            PathGuardedTool::new(
523                FileWriteTool::new_with_persistence(security.clone(), persistent_writes),
524                security.clone(),
525            ),
526            security.clone(),
527        )),
528        Arc::new(RateLimitedTool::new(
529            PathGuardedTool::new(
530                FileEditTool::new_with_persistence(security.clone(), persistent_writes),
531                security.clone(),
532            ),
533            security.clone(),
534        )),
535        Arc::new(RateLimitedTool::new(
536            PathGuardedTool::new(GlobSearchTool::new(security.clone()), security.clone()),
537            security.clone(),
538        )),
539        Arc::new(RateLimitedTool::new(
540            PathGuardedTool::new(ContentSearchTool::new(security.clone()), security.clone()),
541            security.clone(),
542        )),
543        Arc::new(CronAddTool::new(
544            config.clone(),
545            security.clone(),
546            agent_alias,
547        )),
548        Arc::new(CronListTool::new(config.clone())),
549        Arc::new(CronRemoveTool::new(config.clone(), security.clone())),
550        Arc::new(CronUpdateTool::new(
551            config.clone(),
552            security.clone(),
553            agent_alias,
554        )),
555        Arc::new(CronRunTool::new(config.clone(), security.clone())),
556        Arc::new(CronRunsTool::new(config.clone())),
557        Arc::new(MemoryStoreTool::new(memory.clone(), security.clone())),
558        Arc::new(MemoryRecallTool::new(memory.clone())),
559        Arc::new(MemoryForgetTool::new(memory.clone(), security.clone())),
560        Arc::new(MemoryExportTool::new(memory.clone())),
561        Arc::new(MemoryPurgeTool::new(memory.clone(), security.clone())),
562        Arc::new(ScheduleTool::new(
563            security.clone(),
564            root_config.clone(),
565            agent_alias,
566        )),
567        Arc::new(
568            SpawnSubagentTool::new(Arc::new(root_config.clone()), agent_alias, security.clone())
569                .with_subagent_caller(is_subagent_caller),
570        ),
571        Arc::new(SendMessageToPeerTool::new(
572            Arc::new(root_config.clone()),
573            agent_alias,
574        )),
575        Arc::new(ModelRoutingConfigTool::new(
576            config.clone(),
577            security.clone(),
578        )),
579        Arc::new(ModelSwitchTool::new(security.clone(), config.clone())),
580        Arc::new(ProxyConfigTool::new(config.clone(), security.clone())),
581        Arc::new(GitOperationsTool::new(
582            security.clone(),
583            workspace_dir.to_path_buf(),
584        )),
585        Arc::new(PushoverTool::new(
586            security.clone(),
587            workspace_dir.to_path_buf(),
588        )),
589        Arc::new(CalculatorTool::new()),
590        Arc::new(WeatherTool::new()),
591        Arc::new(CanvasTool::new(canvas_store.unwrap_or_default())),
592    ];
593
594    // A SubAgent runs as an ephemeral clone of its parent and inherits the
595    // parent's model verbatim; it must not be able to switch the active
596    // model out from under the parent (the switch signal is process-wide).
597    if is_subagent_caller {
598        tool_arcs.retain(|tool| tool.name() != ModelSwitchTool::NAME);
599    }
600
601    // Register discord_search if any configured Discord alias has
602    // archive enabled. Multiple Discord aliases are supported (one per
603    // bot/server set); the search tool reads from a shared archive DB
604    // so it's enabled when at least one alias archives.
605    if root_config.channels.discord.values().any(|d| d.archive) {
606        match zeroclaw_memory::SqliteMemory::new_named("sqlite", workspace_dir, "discord") {
607            Ok(discord_mem) => {
608                tool_arcs.push(Arc::new(DiscordSearchTool::new(Arc::new(discord_mem))));
609            }
610            Err(e) => {
611                ::zeroclaw_log::record!(
612                    WARN,
613                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
614                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
615                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
616                    "discord_search: failed to open discord.db"
617                );
618            }
619        }
620    }
621
622    // LLM task tool — registered using the calling agent's provider
623    if let Some((family, alias, entry)) = root_config.resolved_model_provider_for_agent(agent_alias)
624    {
625        let llm_task_provider = family.to_string();
626        let llm_task_model = entry
627            .model
628            .clone()
629            .unwrap_or_else(|| "openai/gpt-4o-mini".to_string());
630        let llm_task_runtime_options =
631            zeroclaw_providers::provider_runtime_options_for_alias(root_config, family, alias);
632        tool_arcs.push(Arc::new(LlmTaskTool::new(
633            security.clone(),
634            llm_task_provider,
635            llm_task_model,
636            entry.temperature,
637            entry.api_key.clone(),
638            llm_task_runtime_options,
639        )));
640    }
641
642    if matches!(
643        root_config.skills.prompt_injection_mode,
644        zeroclaw_config::schema::SkillsPromptInjectionMode::Compact
645    ) {
646        tool_arcs.push(Arc::new(ReadSkillTool::new(
647            root_config.data_dir.clone(),
648            root_config.skills.open_skills_enabled,
649            root_config.skills.open_skills_dir.clone(),
650            root_config.skills.allow_scripts,
651        )));
652    }
653
654    if browser_config.enabled {
655        // Add legacy browser_open tool for simple URL opening
656        match BrowserOpenTool::new(security.clone(), browser_config.allowed_domains.clone()) {
657            Ok(tool) => {
658                tool_arcs.push(Arc::new(tool));
659            }
660            Err(e) => {
661                ::zeroclaw_log::record!(
662                    WARN,
663                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
664                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
665                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
666                    "browser_open: failed to construct tool, skipping registration"
667                );
668            }
669        }
670        // Add full browser automation tool (pluggable backend)
671        match BrowserTool::new_with_backend(
672            security.clone(),
673            browser_config.allowed_domains.clone(),
674            browser_config.session_name.clone(),
675            browser_config.backend.clone(),
676            browser_config.headed,
677            browser_config.native_headless,
678            browser_config.native_webdriver_url.clone(),
679            browser_config.native_chrome_path.clone(),
680            ComputerUseConfig {
681                endpoint: browser_config.computer_use.endpoint.clone(),
682                api_key: browser_config.computer_use.api_key.clone(),
683                timeout_ms: browser_config.computer_use.timeout_ms,
684                allow_remote_endpoint: browser_config.computer_use.allow_remote_endpoint,
685                window_allowlist: browser_config.computer_use.window_allowlist.clone(),
686                max_coordinate_x: browser_config.computer_use.max_coordinate_x,
687                max_coordinate_y: browser_config.computer_use.max_coordinate_y,
688            },
689        ) {
690            Ok(tool) => {
691                tool_arcs.push(Arc::new(RateLimitedTool::new(tool, security.clone())));
692            }
693            Err(e) => {
694                ::zeroclaw_log::record!(
695                    WARN,
696                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
697                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
698                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
699                    "browser: failed to construct tool, skipping registration"
700                );
701            }
702        }
703    }
704
705    // Browser delegation tool (conditionally registered; requires shell access)
706    if root_config.browser_delegate.enabled {
707        if has_shell_access {
708            tool_arcs.push(Arc::new(BrowserDelegateTool::new(
709                security.clone(),
710                root_config.browser_delegate.clone(),
711            )));
712        } else {
713            ::zeroclaw_log::record!(
714                WARN,
715                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
716                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
717                "browser_delegate: skipped registration because the current runtime does not allow shell access"
718            );
719        }
720    }
721
722    if http_config.enabled {
723        match HttpRequestTool::new_with_config(
724            security.clone(),
725            http_config.allowed_domains.clone(),
726            http_config.max_response_size,
727            http_config.timeout_secs,
728            http_config.allow_private_hosts,
729            http_config.allowed_private_hosts.clone(),
730            root_config.config_path.clone(),
731            root_config.secrets.encrypt,
732        ) {
733            Ok(tool) => {
734                tool_arcs.push(Arc::new(RateLimitedTool::new(tool, security.clone())));
735            }
736            Err(e) => {
737                ::zeroclaw_log::record!(
738                    WARN,
739                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
740                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
741                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
742                    "http_request: failed to construct tool, skipping registration"
743                );
744            }
745        }
746    }
747
748    if web_fetch_config.enabled {
749        match WebFetchTool::new(
750            security.clone(),
751            web_fetch_config.allowed_domains.clone(),
752            web_fetch_config.blocked_domains.clone(),
753            web_fetch_config.max_response_size,
754            web_fetch_config.timeout_secs,
755            web_fetch_config.firecrawl.clone(),
756            web_fetch_config.allowed_private_hosts.clone(),
757        ) {
758            Ok(tool) => {
759                tool_arcs.push(Arc::new(RateLimitedTool::new(tool, security.clone())));
760            }
761            Err(e) => {
762                ::zeroclaw_log::record!(
763                    WARN,
764                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
765                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
766                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
767                    "web_fetch: failed to construct tool, skipping registration"
768                );
769            }
770        }
771    }
772
773    // Text browser tool (headless text-based browser rendering)
774    if root_config.text_browser.enabled {
775        tool_arcs.push(Arc::new(TextBrowserTool::new(
776            security.clone(),
777            root_config.text_browser.preferred_browser.clone(),
778            root_config.text_browser.timeout_secs,
779        )));
780    }
781
782    // Web search tool (enabled by default for GLM and other models)
783    if root_config.web_search.enabled {
784        tool_arcs.push(Arc::new(WebSearchTool::new_with_config(
785            root_config.web_search.search_provider.clone(),
786            root_config.web_search.brave_api_key.clone(),
787            root_config.web_search.tavily_api_key.clone(),
788            root_config.web_search.jina_api_key.clone(),
789            root_config.web_search.searxng_instance_url.clone(),
790            root_config.web_search.max_results,
791            root_config.web_search.timeout_secs,
792            root_config.config_path.clone(),
793            root_config.secrets.encrypt,
794        )));
795    }
796
797    // Notion API tool (conditionally registered)
798    if root_config.notion.enabled {
799        let notion_api_key = if root_config.notion.api_key.trim().is_empty() {
800            std::env::var("NOTION_API_KEY").unwrap_or_default()
801        } else {
802            root_config.notion.api_key.trim().to_string()
803        };
804        if notion_api_key.trim().is_empty() {
805            ::zeroclaw_log::record!(
806                WARN,
807                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
808                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
809                "Notion tool enabled but no API key found (set notion.api_key or NOTION_API_KEY env var)"
810            );
811        } else {
812            tool_arcs.push(Arc::new(NotionTool::new(notion_api_key, security.clone())));
813        }
814    }
815
816    // Jira integration (config-gated)
817    if root_config.jira.enabled {
818        let api_token = if root_config.jira.api_token.trim().is_empty() {
819            std::env::var("JIRA_API_TOKEN").unwrap_or_default()
820        } else {
821            root_config.jira.api_token.trim().to_string()
822        };
823        if api_token.trim().is_empty() {
824            ::zeroclaw_log::record!(
825                WARN,
826                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
827                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
828                "Jira tool enabled but no API token found (set jira.api_token or JIRA_API_TOKEN env var)"
829            );
830        } else if root_config.jira.base_url.trim().is_empty() {
831            ::zeroclaw_log::record!(
832                WARN,
833                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
834                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
835                "Jira tool enabled but jira.base_url is empty — skipping registration"
836            );
837        } else {
838            let email = root_config
839                .jira
840                .email
841                .as_deref()
842                .map(str::trim)
843                .filter(|s| !s.is_empty())
844                .map(String::from);
845            if email.is_some() {
846                ::zeroclaw_log::record!(
847                    INFO,
848                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
849                    "Jira tool: Cloud mode (API v3, Basic auth)"
850                );
851            } else {
852                ::zeroclaw_log::record!(
853                    INFO,
854                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
855                    "Jira tool: Server/DC mode (API v2, Bearer auth)"
856                );
857            }
858            tool_arcs.push(Arc::new(JiraTool::new(
859                root_config.jira.base_url.trim().to_string(),
860                email,
861                api_token,
862                root_config.jira.allowed_actions.clone(),
863                security.clone(),
864                root_config.jira.timeout_secs,
865            )));
866        }
867    }
868
869    // Project delivery intelligence
870    if root_config.project_intel.enabled {
871        tool_arcs.push(Arc::new(ProjectIntelTool::new(
872            root_config.project_intel.default_language.clone(),
873            root_config.project_intel.risk_sensitivity.clone(),
874        )));
875        // Report template tool — direct access to template engine
876        tool_arcs.push(Arc::new(ReportTemplateTool::new()));
877    }
878
879    // MCSS Security Operations
880    if root_config.security_ops.enabled {
881        tool_arcs.push(Arc::new(SecurityOpsTool::new(
882            root_config.security_ops.clone(),
883        )));
884    }
885
886    // Backup tool (enabled by default)
887    if root_config.backup.enabled {
888        tool_arcs.push(Arc::new(BackupTool::new(
889            workspace_dir.to_path_buf(),
890            root_config.backup.include_dirs.clone(),
891            root_config.backup.max_keep,
892        )));
893    }
894
895    // Data management tool (disabled by default)
896    if root_config.data_retention.enabled {
897        tool_arcs.push(Arc::new(DataManagementTool::new(
898            workspace_dir.to_path_buf(),
899            root_config.data_retention.retention_days,
900        )));
901    }
902
903    // Cloud operations advisory tools (read-only analysis)
904    if root_config.cloud_ops.enabled {
905        tool_arcs.push(Arc::new(CloudOpsTool::new(root_config.cloud_ops.clone())));
906        tool_arcs.push(Arc::new(CloudPatternsTool::new()));
907    }
908
909    // Google Workspace CLI (gws) integration — requires shell access
910    if root_config.google_workspace.enabled && has_shell_access {
911        tool_arcs.push(Arc::new(GoogleWorkspaceTool::new(
912            security.clone(),
913            root_config.google_workspace.allowed_services.clone(),
914            root_config.google_workspace.allowed_operations.clone(),
915            root_config.google_workspace.credentials_path.clone(),
916            root_config.google_workspace.default_account.clone(),
917            root_config.google_workspace.rate_limit_per_minute,
918            root_config.google_workspace.timeout_secs,
919            root_config.google_workspace.audit_log,
920        )));
921    } else if root_config.google_workspace.enabled {
922        ::zeroclaw_log::record!(
923            WARN,
924            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
925                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
926            "google_workspace: skipped registration because shell access is unavailable"
927        );
928    }
929
930    // Claude Code delegation tool
931    if root_config.claude_code.enabled {
932        tool_arcs.push(Arc::new(RateLimitedTool::new(
933            ClaudeCodeTool::new(security.clone(), root_config.claude_code.clone()),
934            security.clone(),
935        )));
936    }
937
938    // Claude Code task runner with Slack progress and SSH handoff
939    if root_config.claude_code_runner.enabled {
940        let gateway_url = format!(
941            "http://{}:{}",
942            root_config.gateway.host, root_config.gateway.port
943        );
944        tool_arcs.push(Arc::new(RateLimitedTool::new(
945            ClaudeCodeRunnerTool::new(
946                security.clone(),
947                root_config.claude_code_runner.clone(),
948                gateway_url,
949            ),
950            security.clone(),
951        )));
952    }
953
954    // Codex CLI delegation tool
955    if root_config.codex_cli.enabled {
956        tool_arcs.push(Arc::new(RateLimitedTool::new(
957            CodexCliTool::new(security.clone(), root_config.codex_cli.clone()),
958            security.clone(),
959        )));
960    }
961
962    // Gemini CLI delegation tool
963    if root_config.gemini_cli.enabled {
964        tool_arcs.push(Arc::new(RateLimitedTool::new(
965            GeminiCliTool::new(security.clone(), root_config.gemini_cli.clone()),
966            security.clone(),
967        )));
968    }
969
970    // OpenCode CLI delegation tool
971    if root_config.opencode_cli.enabled {
972        tool_arcs.push(Arc::new(RateLimitedTool::new(
973            OpenCodeCliTool::new(security.clone(), root_config.opencode_cli.clone()),
974            security.clone(),
975        )));
976    }
977
978    // PDF extraction (feature-gated at compile time via rag-pdf)
979    #[cfg(feature = "rag-pdf")]
980    tool_arcs.push(Arc::new(RateLimitedTool::new(
981        PathGuardedTool::new(PdfReadTool::new(security.clone()), security.clone()),
982        security.clone(),
983    )));
984
985    // Vision tools are always available
986    tool_arcs.push(Arc::new(ScreenshotTool::new(security.clone())));
987    tool_arcs.push(Arc::new(RateLimitedTool::new(
988        PathGuardedTool::new(ImageInfoTool::new(security.clone()), security.clone()),
989        security.clone(),
990    )));
991
992    // Session tools share the channel orchestrator's backend via the
993    // `make_session_backend` factory, keyed off `[channels].session_backend`.
994    // Previously the tools opened the JSONL `SessionStore` while the
995    // gateway WS path opened `SqliteSessionBackend`, so any session
996    // created via /ws/chat was invisible to `sessions_list` /
997    // `sessions_history`. Routing both call sites through the factory
998    // closes that gap and honors the operator's configured backend.
999    if let Ok(backend) =
1000        zeroclaw_infra::make_session_backend(workspace_dir, &config.channels.session_backend)
1001    {
1002        tool_arcs.push(Arc::new(SessionsCurrentTool::new(backend.clone())));
1003        tool_arcs.push(Arc::new(SessionsListTool::new(backend.clone())));
1004        tool_arcs.push(Arc::new(SessionsHistoryTool::new(
1005            backend.clone(),
1006            security.clone(),
1007        )));
1008        tool_arcs.push(Arc::new(SessionsSendTool::new(backend, security.clone())));
1009        // NOTE: SessionResetTool and SessionDeleteTool are available via
1010        // zeroclaw_tools::sessions but NOT registered by default. They are
1011        // destructive operations (clear/delete conversation history) and
1012        // should only be enabled by callers that explicitly need them
1013        // (e.g., orchestration dashboards). Agent-callable registrations must
1014        // use SessionOwnershipScope so one agent cannot reset/delete another
1015        // agent's sessions. The unscoped constructors are operator/admin only.
1016    }
1017
1018    // LinkedIn integration (config-gated)
1019    if root_config.linkedin.enabled {
1020        tool_arcs.push(Arc::new(LinkedInTool::new(
1021            security.clone(),
1022            workspace_dir.to_path_buf(),
1023            root_config.linkedin.api_version.clone(),
1024            root_config.linkedin.content.clone(),
1025            root_config.linkedin.image.clone(),
1026        )));
1027    }
1028
1029    // Standalone image generation tool (config-gated)
1030    if root_config.image_gen.enabled {
1031        tool_arcs.push(Arc::new(ImageGenTool::new_with_persistence(
1032            security.clone(),
1033            workspace_dir.to_path_buf(),
1034            root_config.image_gen.default_model.clone(),
1035            root_config.image_gen.api_key_env.clone(),
1036            persistent_writes,
1037        )));
1038    }
1039
1040    // File upload tool — enabled iff [file_upload].url is set
1041    if root_config
1042        .file_upload
1043        .url
1044        .as_deref()
1045        .is_some_and(|u| !u.trim().is_empty())
1046    {
1047        tool_arcs.push(Arc::new(FileUploadTool::new(
1048            security.clone(),
1049            root_config.file_upload.clone(),
1050        )));
1051    }
1052
1053    // File upload bundle tool — enabled iff [file_upload_bundle].url is set
1054    if root_config
1055        .file_upload_bundle
1056        .url
1057        .as_deref()
1058        .is_some_and(|u| !u.trim().is_empty())
1059    {
1060        tool_arcs.push(Arc::new(FileUploadBundleTool::new(
1061            security.clone(),
1062            root_config.file_upload_bundle.clone(),
1063        )));
1064    }
1065
1066    // File download tool — enabled iff [file_download].url is set
1067    if root_config
1068        .file_download
1069        .url
1070        .as_deref()
1071        .is_some_and(|u| !u.trim().is_empty())
1072    {
1073        tool_arcs.push(Arc::new(FileDownloadTool::new_with_persistence(
1074            security.clone(),
1075            root_config.file_download.clone(),
1076            persistent_writes,
1077        )));
1078    }
1079
1080    // Poll tool — always registered; owns its own late-bound channel map.
1081    let poll_handle: PerToolChannelHandle = Arc::new(RwLock::new(HashMap::new()));
1082    tool_arcs.push(Arc::new(PollTool::new(
1083        security.clone(),
1084        Arc::clone(&poll_handle),
1085    )));
1086
1087    // SOP tools (registered when sops_dir is configured)
1088    if root_config.sop.sops_dir.is_some() {
1089        let mut engine = crate::sop::SopEngine::new(root_config.sop.clone());
1090        engine.reload(workspace_dir);
1091        let sop_engine = Arc::new(std::sync::Mutex::new(engine));
1092        tool_arcs.push(Arc::new(SopListTool::new(Arc::clone(&sop_engine))));
1093        tool_arcs.push(Arc::new(SopExecuteTool::new(Arc::clone(&sop_engine))));
1094        tool_arcs.push(Arc::new(SopAdvanceTool::new(Arc::clone(&sop_engine))));
1095        tool_arcs.push(Arc::new(SopApproveTool::new(Arc::clone(&sop_engine))));
1096        tool_arcs.push(Arc::new(SopStatusTool::new(Arc::clone(&sop_engine))));
1097    }
1098
1099    if let Some(key) = composio_key
1100        && !key.is_empty()
1101    {
1102        tool_arcs.push(Arc::new(ComposioTool::new(
1103            key,
1104            composio_entity_id,
1105            security.clone(),
1106        )));
1107    }
1108
1109    // Emoji reaction tool — always registered; owns its own late-bound channel map.
1110    let reaction_handle: PerToolChannelHandle = Arc::new(RwLock::new(HashMap::new()));
1111    let reaction_tool = ReactionTool::new(security.clone(), Arc::clone(&reaction_handle));
1112    tool_arcs.push(Arc::new(reaction_tool));
1113
1114    // Interactive ask_user tool — always registered; owns its own late-bound channel map.
1115    let ask_user_handle: Option<PerToolChannelHandle> = Some(Arc::new(RwLock::new(HashMap::new())));
1116    let ask_user_tool =
1117        AskUserTool::new(security.clone(), ask_user_handle.as_ref().cloned().unwrap());
1118    tool_arcs.push(Arc::new(ask_user_tool));
1119
1120    // Human escalation tool — always registered; owns its own late-bound channel map.
1121    let escalate_handle: Option<PerToolChannelHandle> = Some(Arc::new(RwLock::new(HashMap::new())));
1122    let escalate_tool = EscalateToHumanTool::new(
1123        security.clone(),
1124        root_config.escalation.alert_channels.clone(),
1125        escalate_handle.as_ref().cloned().unwrap(),
1126    );
1127    tool_arcs.push(Arc::new(escalate_tool));
1128
1129    // Microsoft 365 Graph API integration
1130    if root_config.microsoft365.enabled {
1131        let ms_cfg = &root_config.microsoft365;
1132        let tenant_id = ms_cfg
1133            .tenant_id
1134            .as_deref()
1135            .unwrap_or_default()
1136            .trim()
1137            .to_string();
1138        let client_id = ms_cfg
1139            .client_id
1140            .as_deref()
1141            .unwrap_or_default()
1142            .trim()
1143            .to_string();
1144        if !tenant_id.is_empty() && !client_id.is_empty() {
1145            // Fail fast: client_credentials flow requires a client_secret at registration time.
1146            if ms_cfg.auth_flow.trim() == "client_credentials"
1147                && ms_cfg
1148                    .client_secret
1149                    .as_deref()
1150                    .is_none_or(|s| s.trim().is_empty())
1151            {
1152                ::zeroclaw_log::record!(
1153                    ERROR,
1154                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1155                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
1156                    "microsoft365: client_credentials auth_flow requires a non-empty client_secret"
1157                );
1158                return AllToolsResult {
1159                    unfiltered_tool_arcs: tool_arcs.clone(),
1160                    tools: boxed_registry_from_arcs(tool_arcs),
1161                    delegate_handle: None,
1162                    ask_user_handle,
1163                    reaction_handle,
1164                    poll_handle: Some(poll_handle),
1165                    escalate_handle,
1166                };
1167            }
1168
1169            let resolved = zeroclaw_tools::microsoft365::types::Microsoft365ResolvedConfig {
1170                tenant_id,
1171                client_id,
1172                client_secret: ms_cfg.client_secret.clone(),
1173                auth_flow: ms_cfg.auth_flow.clone(),
1174                scopes: ms_cfg.scopes.clone(),
1175                token_cache_encrypted: ms_cfg.token_cache_encrypted,
1176                user_id: ms_cfg.user_id.as_deref().unwrap_or("me").to_string(),
1177            };
1178            // Store token cache in the config directory (next to config.toml),
1179            // not the workspace directory, to keep bearer tokens out of the
1180            // project tree.
1181            let cache_dir = root_config.config_path.parent().unwrap_or(workspace_dir);
1182            match Microsoft365Tool::new(resolved, security.clone(), cache_dir) {
1183                Ok(tool) => tool_arcs.push(Arc::new(tool)),
1184                Err(e) => {
1185                    ::zeroclaw_log::record!(
1186                        ERROR,
1187                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1188                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1189                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1190                        "microsoft365: failed to initialize tool"
1191                    );
1192                }
1193            }
1194        } else {
1195            ::zeroclaw_log::record!(
1196                WARN,
1197                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1198                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1199                "microsoft365: skipped registration because tenant_id or client_id is empty"
1200            );
1201        }
1202    }
1203
1204    // Knowledge graph tool
1205    if root_config.knowledge.enabled {
1206        let db_path_str = root_config.knowledge.db_path.replace(
1207            '~',
1208            &directories::UserDirs::new()
1209                .map(|u| u.home_dir().to_string_lossy().to_string())
1210                .unwrap_or_else(|| ".".to_string()),
1211        );
1212        let db_path = std::path::PathBuf::from(&db_path_str);
1213        match zeroclaw_memory::knowledge_graph::KnowledgeGraph::new(
1214            &db_path,
1215            root_config.knowledge.max_nodes,
1216        ) {
1217            Ok(graph) => {
1218                tool_arcs.push(Arc::new(KnowledgeTool::new(Arc::new(graph))));
1219            }
1220            Err(e) => {
1221                ::zeroclaw_log::record!(
1222                    WARN,
1223                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1224                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1225                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1226                    "knowledge graph disabled due to init error"
1227                );
1228            }
1229        }
1230    }
1231
1232    // Add delegation tool when agents are configured
1233    let delegate_global_credential = fallback_api_key.and_then(|value| {
1234        let trimmed_value = value.trim();
1235        (!trimmed_value.is_empty()).then(|| trimmed_value.to_owned())
1236    });
1237    let provider_runtime_options =
1238        zeroclaw_providers::provider_runtime_options_for_agent(root_config, agent_alias);
1239
1240    let delegate_handle: Option<DelegateParentToolsHandle> = if agents.is_empty() {
1241        None
1242    } else {
1243        let delegate_agents: HashMap<String, AliasedAgentConfig> = agents
1244            .iter()
1245            .map(|(name, cfg)| (name.clone(), cfg.clone()))
1246            .collect();
1247        let parent_tools = Arc::new(RwLock::new(tool_arcs.clone()));
1248        let delegate_tool = DelegateTool::new_with_options(
1249            delegate_agents,
1250            delegate_global_credential.clone(),
1251            security.clone(),
1252            provider_runtime_options.clone(),
1253        )
1254        .with_parent_tools(Arc::clone(&parent_tools))
1255        .with_multimodal_config(root_config.multimodal.clone())
1256        .with_delegate_config(root_config.delegate.clone())
1257        .with_workspace_dir(workspace_dir.to_path_buf())
1258        .with_memory(memory.clone())
1259        .with_providers_models({
1260            // DelegateTool's signature still expects the flat HashMap shape;
1261            // collapse the typed ModelProviders container down to base-config
1262            // entries here. Family-specific extras (wire_api / requires_openai_auth /
1263            // resource / etc.) aren't needed by DelegateTool — it only resolves
1264            // baseline fields (model, api_key, uri) for sub-agent dispatch.
1265            // Phase 7 will switch DelegateTool to consume Arc<ModelProviders>
1266            // directly and drop this collapse.
1267            let mut m: std::collections::HashMap<
1268                String,
1269                std::collections::HashMap<String, zeroclaw_config::schema::ModelProviderConfig>,
1270            > = std::collections::HashMap::new();
1271            for (t, a, base) in root_config.providers.models.iter_entries() {
1272                m.entry(t.to_string())
1273                    .or_default()
1274                    .insert(a.to_string(), base.clone());
1275            }
1276            m
1277        })
1278        .with_risk_profiles(root_config.risk_profiles.clone())
1279        .with_runtime_profiles(root_config.runtime_profiles.clone())
1280        .with_skill_bundles(root_config.skill_bundles.clone())
1281        .with_root_config(config.clone())
1282        .with_caller_alias(agent_alias);
1283        tool_arcs.push(Arc::new(delegate_tool));
1284        Some(parent_tools)
1285    };
1286
1287    // Verifiable Intent tool (opt-in via config)
1288    if root_config.verifiable_intent.enabled {
1289        let strictness = match root_config.verifiable_intent.strictness.as_str() {
1290            "permissive" => crate::verifiable_intent::StrictnessMode::Permissive,
1291            _ => crate::verifiable_intent::StrictnessMode::Strict,
1292        };
1293        tool_arcs.push(Arc::new(VerifiableIntentTool::new(
1294            security.clone(),
1295            strictness,
1296        )));
1297    }
1298
1299    // ── WASM plugin tools (requires plugins-wasm feature) ──
1300    #[cfg(feature = "plugins-wasm")]
1301    {
1302        let plugin_dir = config.plugins.plugins_dir.clone();
1303        let plugin_path = if plugin_dir.starts_with("~/") {
1304            let home = directories::UserDirs::new()
1305                .map(|u| u.home_dir().to_path_buf())
1306                .unwrap_or_else(|| std::path::PathBuf::from("."));
1307            home.join(plugin_dir.strip_prefix("~/").unwrap())
1308        } else {
1309            std::path::PathBuf::from(&plugin_dir)
1310        };
1311
1312        if plugin_path.exists() && config.plugins.enabled {
1313            match zeroclaw_plugins::host::PluginHost::new(
1314                plugin_path.parent().unwrap_or(&plugin_path),
1315            ) {
1316                Ok(host) => {
1317                    let details = host.tool_plugin_details();
1318                    let count = details.len();
1319                    for (manifest, wasm_path) in details {
1320                        tool_arcs.push(Arc::new(zeroclaw_plugins::wasm_tool::WasmTool::from_wasm(
1321                            wasm_path.to_path_buf(),
1322                            manifest.permissions.clone(),
1323                            manifest.name.clone(),
1324                            manifest.description.clone().unwrap_or_default(),
1325                        )));
1326                    }
1327                    ::zeroclaw_log::record!(
1328                        INFO,
1329                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1330                            .with_attrs(::serde_json::json!({"count": count})),
1331                        "Loaded  WASM plugin tools"
1332                    );
1333                }
1334                Err(e) => {
1335                    ::zeroclaw_log::record!(
1336                        WARN,
1337                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1338                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1339                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1340                        "Failed to load WASM plugins"
1341                    );
1342                }
1343            }
1344        }
1345    }
1346
1347    // Pipeline tool (execute_pipeline) — multi-step tool chaining.
1348    if root_config.pipeline.enabled {
1349        let pipeline_tools: Vec<Arc<dyn Tool>> = tool_arcs.clone();
1350        tool_arcs.push(Arc::new(PipelineTool::new(
1351            root_config.pipeline.clone(),
1352            pipeline_tools,
1353        )));
1354    }
1355
1356    AllToolsResult {
1357        unfiltered_tool_arcs: tool_arcs.clone(),
1358        tools: boxed_registry_from_arcs(tool_arcs),
1359        delegate_handle,
1360        ask_user_handle,
1361        reaction_handle,
1362        poll_handle: Some(poll_handle),
1363        escalate_handle,
1364    }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370    use tempfile::TempDir;
1371    use zeroclaw_config::schema::{BrowserConfig, Config, MemoryConfig};
1372
1373    fn test_config(tmp: &TempDir) -> Config {
1374        Config {
1375            data_dir: tmp.path().join("data"),
1376            config_path: tmp.path().join("config.toml"),
1377            ..Config::default()
1378        }
1379    }
1380
1381    #[test]
1382    fn default_tools_has_expected_count() {
1383        let security = Arc::new(SecurityPolicy::default());
1384        let tools = default_tools(security);
1385        assert_eq!(tools.len(), 6);
1386    }
1387
1388    /// A runtime that reports an ephemeral workspace (no host persistence) while
1389    /// delegating real shell execution to `NativeRuntime`. Used to exercise the
1390    /// registration wiring of `has_filesystem_access()` -> `persistent_writes`.
1391    struct EphemeralRuntime(NativeRuntime);
1392
1393    impl RuntimeAdapter for EphemeralRuntime {
1394        fn name(&self) -> &str {
1395            "ephemeral-test"
1396        }
1397        fn has_shell_access(&self) -> bool {
1398            true
1399        }
1400        fn has_filesystem_access(&self) -> bool {
1401            false
1402        }
1403        fn storage_path(&self) -> std::path::PathBuf {
1404            std::env::temp_dir()
1405        }
1406        fn supports_long_running(&self) -> bool {
1407            false
1408        }
1409        fn build_shell_command(
1410            &self,
1411            command: &str,
1412            workspace_dir: &std::path::Path,
1413        ) -> anyhow::Result<tokio::process::Command> {
1414            self.0.build_shell_command(command, workspace_dir)
1415        }
1416    }
1417
1418    /// End-to-end wiring test (issue #4627): tools registered via
1419    /// `default_tools_with_runtime` against an ephemeral runtime must surface the
1420    /// loud warning (shell/file_read/file_edit) or refuse outright (file_write).
1421    /// The per-tool unit tests construct tools directly with the flag; this is
1422    /// the only test that proves `has_filesystem_access()` is actually threaded
1423    /// through registration to all four tools.
1424    #[tokio::test]
1425    async fn registered_tools_warn_or_block_on_ephemeral_runtime() {
1426        let tmp = TempDir::new().unwrap();
1427        tokio::fs::write(tmp.path().join("notes.txt"), "data")
1428            .await
1429            .unwrap();
1430        let security = Arc::new(SecurityPolicy {
1431            autonomy: crate::security::AutonomyLevel::Supervised,
1432            max_actions_per_hour: 100,
1433            workspace_dir: tmp.path().to_path_buf(),
1434            ..SecurityPolicy::default()
1435        });
1436        let runtime: Arc<dyn RuntimeAdapter> = Arc::new(EphemeralRuntime(NativeRuntime::new()));
1437        let tools = default_tools_with_runtime(security, runtime);
1438        let by_name = |n: &str| tools.iter().find(|t| t.name() == n).unwrap();
1439
1440        // shell: warns on the executed command.
1441        let r = by_name("shell")
1442            .execute(serde_json::json!({"command": "echo hi"}))
1443            .await
1444            .unwrap();
1445        assert!(
1446            r.output.contains("EPHEMERAL WORKSPACE"),
1447            "shell must warn, got: {}",
1448            r.output
1449        );
1450
1451        // file_read: warns on a successful text read.
1452        let r = by_name("file_read")
1453            .execute(serde_json::json!({"path": "notes.txt"}))
1454            .await
1455            .unwrap();
1456        assert!(
1457            r.success && r.output.contains("EPHEMERAL WORKSPACE"),
1458            "file_read must warn, got: {r:?}"
1459        );
1460
1461        // file_edit: warns on a successful edit.
1462        let r = by_name("file_edit")
1463            .execute(
1464                serde_json::json!({"path": "notes.txt", "old_string": "data", "new_string": "x"}),
1465            )
1466            .await
1467            .unwrap();
1468        assert!(
1469            r.success && r.output.contains("EPHEMERAL WORKSPACE"),
1470            "file_edit must warn, got: {r:?}"
1471        );
1472
1473        // file_write: refuses outright (does not warn-and-write).
1474        let r = by_name("file_write")
1475            .execute(serde_json::json!({"path": "new.txt", "content": "x"}))
1476            .await
1477            .unwrap();
1478        assert!(
1479            !r.success,
1480            "file_write must refuse on ephemeral, got: {r:?}"
1481        );
1482        assert!(
1483            r.error
1484                .as_deref()
1485                .unwrap_or("")
1486                .contains("ephemeral workspace"),
1487            "file_write error must name the cause, got: {:?}",
1488            r.error
1489        );
1490        assert!(
1491            !tmp.path().join("new.txt").exists(),
1492            "file_write must not write anything on ephemeral"
1493        );
1494    }
1495
1496    #[test]
1497    fn all_tools_excludes_browser_when_disabled() {
1498        let tmp = TempDir::new().unwrap();
1499        let security = Arc::new(SecurityPolicy::default());
1500        let mem_cfg = MemoryConfig {
1501            backend: "markdown".into(),
1502            ..MemoryConfig::default()
1503        };
1504        let mem: Arc<dyn Memory> =
1505            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
1506
1507        let browser = BrowserConfig {
1508            enabled: false,
1509            allowed_domains: vec!["example.com".into()],
1510            session_name: None,
1511            ..BrowserConfig::default()
1512        };
1513        let http = zeroclaw_config::schema::HttpRequestConfig::default();
1514        let cfg = test_config(&tmp);
1515
1516        let tools = all_tools(
1517            Arc::new(Config::default()),
1518            &security,
1519            &zeroclaw_config::schema::RiskProfileConfig::default(),
1520            "test-agent",
1521            mem,
1522            None,
1523            None,
1524            &browser,
1525            &http,
1526            &zeroclaw_config::schema::WebFetchConfig::default(),
1527            tmp.path(),
1528            &HashMap::new(),
1529            None,
1530            &cfg,
1531            None,
1532            false,
1533            None,
1534        )
1535        .tools;
1536        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
1537        assert!(!names.contains(&"browser_open"));
1538        assert!(names.contains(&"schedule"));
1539        assert!(names.contains(&"model_routing_config"));
1540        assert!(names.contains(&"pushover"));
1541        assert!(names.contains(&"proxy_config"));
1542    }
1543
1544    #[test]
1545    fn all_tools_includes_browser_when_enabled() {
1546        let tmp = TempDir::new().unwrap();
1547        let security = Arc::new(SecurityPolicy::default());
1548        let mem_cfg = MemoryConfig {
1549            backend: "markdown".into(),
1550            ..MemoryConfig::default()
1551        };
1552        let mem: Arc<dyn Memory> =
1553            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
1554
1555        let browser = BrowserConfig {
1556            enabled: true,
1557            allowed_domains: vec!["example.com".into()],
1558            session_name: None,
1559            ..BrowserConfig::default()
1560        };
1561        let http = zeroclaw_config::schema::HttpRequestConfig::default();
1562        let cfg = test_config(&tmp);
1563
1564        let tools = all_tools(
1565            Arc::new(Config::default()),
1566            &security,
1567            &zeroclaw_config::schema::RiskProfileConfig::default(),
1568            "test-agent",
1569            mem,
1570            None,
1571            None,
1572            &browser,
1573            &http,
1574            &zeroclaw_config::schema::WebFetchConfig::default(),
1575            tmp.path(),
1576            &HashMap::new(),
1577            None,
1578            &cfg,
1579            None,
1580            false,
1581            None,
1582        )
1583        .tools;
1584        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
1585        assert!(names.contains(&"browser_open"));
1586        assert!(names.contains(&"content_search"));
1587        assert!(names.contains(&"model_routing_config"));
1588        assert!(names.contains(&"pushover"));
1589        assert!(names.contains(&"proxy_config"));
1590    }
1591
1592    #[test]
1593    fn default_tools_names() {
1594        let security = Arc::new(SecurityPolicy::default());
1595        let tools = default_tools(security);
1596        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
1597        assert!(names.contains(&"shell"));
1598        assert!(names.contains(&"file_read"));
1599        assert!(names.contains(&"file_write"));
1600        assert!(names.contains(&"file_edit"));
1601        assert!(names.contains(&"glob_search"));
1602        assert!(names.contains(&"content_search"));
1603    }
1604
1605    #[test]
1606    fn default_tools_all_have_descriptions() {
1607        let security = Arc::new(SecurityPolicy::default());
1608        let tools = default_tools(security);
1609        for tool in &tools {
1610            assert!(
1611                !tool.description().is_empty(),
1612                "Tool {} has empty description",
1613                tool.name()
1614            );
1615        }
1616    }
1617
1618    #[test]
1619    fn default_tools_all_have_schemas() {
1620        let security = Arc::new(SecurityPolicy::default());
1621        let tools = default_tools(security);
1622        for tool in &tools {
1623            let schema = tool.parameters_schema();
1624            assert!(
1625                schema.is_object(),
1626                "Tool {} schema is not an object",
1627                tool.name()
1628            );
1629            assert!(
1630                schema["properties"].is_object(),
1631                "Tool {} schema has no properties",
1632                tool.name()
1633            );
1634        }
1635    }
1636
1637    #[test]
1638    fn tool_spec_generation() {
1639        let security = Arc::new(SecurityPolicy::default());
1640        let tools = default_tools(security);
1641        for tool in &tools {
1642            let spec = tool.spec();
1643            assert_eq!(spec.name, tool.name());
1644            assert_eq!(spec.description, tool.description());
1645            assert!(spec.parameters.is_object());
1646        }
1647    }
1648
1649    #[test]
1650    fn tool_result_serde() {
1651        let result = ToolResult {
1652            success: true,
1653            output: "hello".into(),
1654            error: None,
1655        };
1656        let json = serde_json::to_string(&result).unwrap();
1657        let parsed: ToolResult = serde_json::from_str(&json).unwrap();
1658        assert!(parsed.success);
1659        assert_eq!(parsed.output, "hello");
1660        assert!(parsed.error.is_none());
1661    }
1662
1663    #[test]
1664    fn tool_result_with_error_serde() {
1665        let result = ToolResult {
1666            success: false,
1667            output: String::new(),
1668            error: Some("boom".into()),
1669        };
1670        let json = serde_json::to_string(&result).unwrap();
1671        let parsed: ToolResult = serde_json::from_str(&json).unwrap();
1672        assert!(!parsed.success);
1673        assert_eq!(parsed.error.as_deref(), Some("boom"));
1674    }
1675
1676    #[test]
1677    fn tool_spec_serde() {
1678        let spec = ToolSpec {
1679            name: "test".into(),
1680            description: "A test tool".into(),
1681            parameters: serde_json::json!({"type": "object"}),
1682        };
1683        let json = serde_json::to_string(&spec).unwrap();
1684        let parsed: ToolSpec = serde_json::from_str(&json).unwrap();
1685        assert_eq!(parsed.name, "test");
1686        assert_eq!(parsed.description, "A test tool");
1687    }
1688
1689    #[test]
1690    fn all_tools_includes_delegate_when_agents_configured() {
1691        let tmp = TempDir::new().unwrap();
1692        let security = Arc::new(SecurityPolicy::default());
1693        let mem_cfg = MemoryConfig {
1694            backend: "markdown".into(),
1695            ..MemoryConfig::default()
1696        };
1697        let mem: Arc<dyn Memory> =
1698            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
1699
1700        let browser = BrowserConfig::default();
1701        let http = zeroclaw_config::schema::HttpRequestConfig::default();
1702        let cfg = test_config(&tmp);
1703
1704        let mut agents = HashMap::new();
1705        agents.insert(
1706            "researcher".to_string(),
1707            AliasedAgentConfig {
1708                model_provider: "ollama.researcher".into(),
1709                ..Default::default()
1710            },
1711        );
1712
1713        let tools = all_tools(
1714            Arc::new(Config::default()),
1715            &security,
1716            &zeroclaw_config::schema::RiskProfileConfig::default(),
1717            "test-agent",
1718            mem,
1719            None,
1720            None,
1721            &browser,
1722            &http,
1723            &zeroclaw_config::schema::WebFetchConfig::default(),
1724            tmp.path(),
1725            &agents,
1726            Some("delegate-test-credential"),
1727            &cfg,
1728            None,
1729            false,
1730            None,
1731        )
1732        .tools;
1733        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
1734        assert!(names.contains(&"delegate"));
1735    }
1736
1737    #[test]
1738    fn all_tools_excludes_delegate_when_no_agents() {
1739        let tmp = TempDir::new().unwrap();
1740        let security = Arc::new(SecurityPolicy::default());
1741        let mem_cfg = MemoryConfig {
1742            backend: "markdown".into(),
1743            ..MemoryConfig::default()
1744        };
1745        let mem: Arc<dyn Memory> =
1746            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
1747
1748        let browser = BrowserConfig::default();
1749        let http = zeroclaw_config::schema::HttpRequestConfig::default();
1750        let cfg = test_config(&tmp);
1751
1752        let tools = all_tools(
1753            Arc::new(Config::default()),
1754            &security,
1755            &zeroclaw_config::schema::RiskProfileConfig::default(),
1756            "test-agent",
1757            mem,
1758            None,
1759            None,
1760            &browser,
1761            &http,
1762            &zeroclaw_config::schema::WebFetchConfig::default(),
1763            tmp.path(),
1764            &HashMap::new(),
1765            None,
1766            &cfg,
1767            None,
1768            false,
1769            None,
1770        )
1771        .tools;
1772        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
1773        assert!(!names.contains(&"delegate"));
1774    }
1775
1776    #[test]
1777    fn all_tools_includes_read_skill_in_compact_mode() {
1778        let tmp = TempDir::new().unwrap();
1779        let security = Arc::new(SecurityPolicy::default());
1780        let mem_cfg = MemoryConfig {
1781            backend: "markdown".into(),
1782            ..MemoryConfig::default()
1783        };
1784        let mem: Arc<dyn Memory> =
1785            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
1786
1787        let browser = BrowserConfig::default();
1788        let http = zeroclaw_config::schema::HttpRequestConfig::default();
1789        let mut cfg = test_config(&tmp);
1790        cfg.skills.prompt_injection_mode =
1791            zeroclaw_config::schema::SkillsPromptInjectionMode::Compact;
1792
1793        let tools = all_tools(
1794            Arc::new(cfg.clone()),
1795            &security,
1796            &zeroclaw_config::schema::RiskProfileConfig::default(),
1797            "test-agent",
1798            mem,
1799            None,
1800            None,
1801            &browser,
1802            &http,
1803            &zeroclaw_config::schema::WebFetchConfig::default(),
1804            tmp.path(),
1805            &HashMap::new(),
1806            None,
1807            &cfg,
1808            None,
1809            false,
1810            None,
1811        )
1812        .tools;
1813        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
1814        assert!(names.contains(&"read_skill"));
1815    }
1816
1817    #[test]
1818    fn all_tools_excludes_read_skill_in_full_mode() {
1819        let tmp = TempDir::new().unwrap();
1820        let security = Arc::new(SecurityPolicy::default());
1821        let mem_cfg = MemoryConfig {
1822            backend: "markdown".into(),
1823            ..MemoryConfig::default()
1824        };
1825        let mem: Arc<dyn Memory> =
1826            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
1827
1828        let browser = BrowserConfig::default();
1829        let http = zeroclaw_config::schema::HttpRequestConfig::default();
1830        let mut cfg = test_config(&tmp);
1831        cfg.skills.prompt_injection_mode = zeroclaw_config::schema::SkillsPromptInjectionMode::Full;
1832
1833        let tools = all_tools(
1834            Arc::new(cfg.clone()),
1835            &security,
1836            &zeroclaw_config::schema::RiskProfileConfig::default(),
1837            "test-agent",
1838            mem,
1839            None,
1840            None,
1841            &browser,
1842            &http,
1843            &zeroclaw_config::schema::WebFetchConfig::default(),
1844            tmp.path(),
1845            &HashMap::new(),
1846            None,
1847            &cfg,
1848            None,
1849            false,
1850            None,
1851        )
1852        .tools;
1853        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
1854        assert!(!names.contains(&"read_skill"));
1855    }
1856
1857    fn registry_names(tmp: &TempDir, is_subagent_caller: bool) -> Vec<String> {
1858        let security = Arc::new(SecurityPolicy::default());
1859        let mem_cfg = MemoryConfig {
1860            backend: "markdown".into(),
1861            ..MemoryConfig::default()
1862        };
1863        let mem: Arc<dyn Memory> =
1864            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
1865        let cfg = test_config(tmp);
1866
1867        all_tools(
1868            Arc::new(cfg.clone()),
1869            &security,
1870            &zeroclaw_config::schema::RiskProfileConfig::default(),
1871            "test-agent",
1872            mem,
1873            None,
1874            None,
1875            &BrowserConfig::default(),
1876            &zeroclaw_config::schema::HttpRequestConfig::default(),
1877            &zeroclaw_config::schema::WebFetchConfig::default(),
1878            tmp.path(),
1879            &HashMap::new(),
1880            None,
1881            &cfg,
1882            None,
1883            is_subagent_caller,
1884            None,
1885        )
1886        .tools
1887        .iter()
1888        .map(|t| t.name().to_string())
1889        .collect()
1890    }
1891
1892    #[test]
1893    fn model_switch_present_for_top_level_absent_for_subagent() {
1894        let tmp = TempDir::new().unwrap();
1895        let top = registry_names(&tmp, false);
1896        assert!(
1897            top.iter().any(|n| n == ModelSwitchTool::NAME),
1898            "top-level agent must keep model_switch"
1899        );
1900        let subagent = registry_names(&tmp, true);
1901        assert!(
1902            !subagent.iter().any(|n| n == ModelSwitchTool::NAME),
1903            "subagent must not be able to switch the inherited model"
1904        );
1905    }
1906}