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