Skip to main content

walrus_daemon/hook/
mod.rs

1//! Stateful Hook implementation for the daemon.
2//!
3//! [`DaemonHook`] composes skill, MCP, OS, and built-in memory sub-hooks plus
4//! external extension services. `on_build_agent` delegates to skills, memory,
5//! and extension services; `on_register_tools` delegates to all sub-hooks in
6//! sequence. `dispatch_tool` routes every agent tool call by name — the single
7//! entry point from `event.rs`.
8
9use crate::{
10    daemon::event::DaemonEventSender,
11    ext::hub::DownloadRegistry,
12    hook::{
13        mcp::McpHandler,
14        os::PermissionConfig,
15        skill::SkillHandler,
16        system::{memory::Memory, task::TaskSet},
17    },
18    service::ServiceRegistry,
19};
20use std::{collections::BTreeMap, sync::Arc};
21use tokio::sync::Mutex;
22use wcore::{AgentConfig, AgentEvent, Hook, ToolRegistry, model::Message};
23
24pub mod mcp;
25pub mod os;
26pub mod skill;
27pub mod system;
28
29/// Per-agent scope for dispatch enforcement. Empty vecs = unrestricted.
30#[derive(Default)]
31pub(crate) struct AgentScope {
32    pub(crate) tools: Vec<String>,
33    pub(crate) members: Vec<String>,
34    pub(crate) skills: Vec<String>,
35    pub(crate) mcps: Vec<String>,
36}
37
38pub struct DaemonHook {
39    pub skills: SkillHandler,
40    pub mcp: McpHandler,
41    pub tasks: Arc<Mutex<TaskSet>>,
42    pub downloads: Arc<Mutex<DownloadRegistry>>,
43    pub permissions: PermissionConfig,
44    /// Whether the daemon is running as the `walrus` OS user (sandbox active).
45    pub sandboxed: bool,
46    /// Built-in memory.
47    pub memory: Option<Memory>,
48    /// Event channel for task dispatch.
49    pub(crate) event_tx: DaemonEventSender,
50    /// Per-agent scope maps, populated during load_agents.
51    pub(crate) scopes: BTreeMap<String, AgentScope>,
52    /// Sub-agent descriptions for catalog injection into the walrus agent.
53    pub(crate) agent_descriptions: BTreeMap<String, String>,
54    /// External extension service registry (tools + queries).
55    pub(crate) registry: Option<Arc<ServiceRegistry>>,
56}
57
58/// Base tools always included in every agent's whitelist.
59/// Also bypass permission check when running in sandbox mode.
60const BASE_TOOLS: &[&str] = &["bash"];
61
62/// Skill discovery/loading tools.
63const SKILL_TOOLS: &[&str] = &["search_skill", "load_skill", "save_skill"];
64
65/// MCP discovery/call tools.
66const MCP_TOOLS: &[&str] = &["search_mcp", "call_mcp_tool"];
67
68/// Memory tools.
69const MEMORY_TOOLS: &[&str] = &["recall", "remember", "memory", "forget", "soul"];
70
71/// Task delegation tools.
72const TASK_TOOLS: &[&str] = &["delegate", "collect", "check_tasks"];
73
74impl Hook for DaemonHook {
75    fn on_build_agent(&self, mut config: AgentConfig) -> AgentConfig {
76        // Inject environment context (OS, working directory, sandbox state).
77        config
78            .system_prompt
79            .push_str(&os::environment_block(self.sandboxed));
80
81        // Inject built-in memory prompt if active.
82        if let Some(ref mem) = self.memory {
83            let prompt = mem.build_prompt();
84            if !prompt.is_empty() {
85                config.system_prompt.push_str(&prompt);
86            }
87        }
88
89        // Inject discoverable resource hints so the agent knows what's
90        // available without resorting to bash exploration.
91        let mut hints = Vec::new();
92        let mcp_servers = self.mcp.cached_list();
93        if !mcp_servers.is_empty() {
94            let names: Vec<&str> = mcp_servers.iter().map(|(n, _)| n.as_str()).collect();
95            hints.push(format!(
96                "MCP servers: {}. Use search_mcp to list tools, call_mcp_tool to invoke them.",
97                names.join(", ")
98            ));
99        }
100        if let Ok(reg) = self.skills.registry.try_lock() {
101            let skills: Vec<&str> = reg.skills().iter().map(|s| s.name.as_str()).collect();
102            if !skills.is_empty() {
103                hints.push(format!(
104                    "Skills: {}. Use search_skill to find skills, load_skill to activate one.",
105                    skills.join(", ")
106                ));
107            }
108        }
109        if !hints.is_empty() {
110            config.system_prompt.push_str(&format!(
111                "\n\n<resources>\n{}\n</resources>",
112                hints.join("\n")
113            ));
114        }
115
116        // Apply scoped tool whitelist + prompt for sub-agents.
117        self.apply_scope(&mut config);
118        config
119    }
120
121    fn preprocess(&self, agent: &str, content: &str) -> String {
122        self.resolve_slash_skill(agent, content)
123    }
124
125    fn on_before_run(
126        &self,
127        agent: &str,
128        history: &[wcore::model::Message],
129    ) -> Vec<wcore::model::Message> {
130        let mut messages = Vec::new();
131        // Any agent with members gets the sub-agent catalog.
132        let has_members = self
133            .scopes
134            .get(agent)
135            .is_some_and(|s| !s.members.is_empty());
136        if has_members && !self.agent_descriptions.is_empty() {
137            let mut block = String::from("<agents>\n");
138            for (name, desc) in &self.agent_descriptions {
139                block.push_str(&format!("- {name}: {desc}\n"));
140            }
141            block.push_str("</agents>");
142            let mut msg = Message::user(block);
143            msg.auto_injected = true;
144            messages.push(msg);
145        }
146        if let Some(ref mem) = self.memory {
147            messages.extend(mem.before_run(history));
148        }
149        messages
150    }
151
152    async fn on_register_tools(&self, tools: &mut ToolRegistry) {
153        self.mcp.register_tools(tools);
154        tools.insert_all(os::tool::tools());
155        tools.insert_all(skill::tool::tools());
156        tools.insert_all(system::task::tool::tools());
157        if let Some(ref registry) = self.registry {
158            registry.register_tools(tools).await;
159        }
160        if self.memory.is_some() {
161            tools.insert_all(system::memory::tool::tools());
162        }
163    }
164
165    fn on_after_compact(&self, agent: &str, summary: &str) {
166        if let Some(ref mem) = self.memory {
167            mem.after_compact(agent, summary);
168        }
169    }
170
171    fn on_event(&self, agent: &str, event: &AgentEvent) {
172        match event {
173            AgentEvent::TextDelta(text) => {
174                tracing::trace!(%agent, text_len = text.len(), "agent text delta");
175            }
176            AgentEvent::ThinkingDelta(text) => {
177                tracing::trace!(%agent, text_len = text.len(), "agent thinking delta");
178            }
179            AgentEvent::ToolCallsStart(calls) => {
180                tracing::debug!(%agent, count = calls.len(), "agent tool calls started");
181            }
182            AgentEvent::ToolResult { call_id, .. } => {
183                tracing::debug!(%agent, %call_id, "agent tool result");
184            }
185            AgentEvent::ToolCallsComplete => {
186                tracing::debug!(%agent, "agent tool calls complete");
187            }
188            AgentEvent::Compact { summary } => {
189                tracing::info!(%agent, summary_len = summary.len(), "context compacted");
190                self.on_after_compact(agent, summary);
191            }
192            AgentEvent::Done(response) => {
193                tracing::info!(
194                    %agent,
195                    iterations = response.iterations,
196                    stop_reason = ?response.stop_reason,
197                    "agent run complete"
198                );
199            }
200        }
201    }
202}
203
204impl DaemonHook {
205    /// Create a new DaemonHook with the given backends.
206    #[allow(clippy::too_many_arguments)]
207    pub fn new(
208        skills: SkillHandler,
209        mcp: McpHandler,
210        tasks: Arc<Mutex<TaskSet>>,
211        downloads: Arc<Mutex<DownloadRegistry>>,
212        permissions: PermissionConfig,
213        sandboxed: bool,
214        memory: Option<Memory>,
215        registry: Option<Arc<ServiceRegistry>>,
216        event_tx: DaemonEventSender,
217    ) -> Self {
218        Self {
219            skills,
220            mcp,
221            tasks,
222            downloads,
223            permissions,
224            sandboxed,
225            memory,
226            event_tx,
227            scopes: BTreeMap::new(),
228            agent_descriptions: BTreeMap::new(),
229            registry,
230        }
231    }
232
233    /// Register an agent's scope for dispatch enforcement.
234    pub(crate) fn register_scope(&mut self, name: String, config: &AgentConfig) {
235        if name != wcore::paths::DEFAULT_AGENT && !config.description.is_empty() {
236            self.agent_descriptions
237                .insert(name.clone(), config.description.clone());
238        }
239        self.scopes.insert(
240            name,
241            AgentScope {
242                tools: config.tools.clone(),
243                members: config.members.clone(),
244                skills: config.skills.clone(),
245                mcps: config.mcps.clone(),
246            },
247        );
248    }
249
250    /// Apply scoped tool whitelist and scope prompt for sub-agents.
251    /// No-op for the walrus agent (empty scoping = all tools).
252    fn apply_scope(&self, config: &mut AgentConfig) {
253        let has_scoping =
254            !config.skills.is_empty() || !config.mcps.is_empty() || !config.members.is_empty();
255        if !has_scoping {
256            return;
257        }
258
259        // Base tools + memory + external service tools always included.
260        let mut whitelist: Vec<String> = BASE_TOOLS.iter().map(|&s| s.to_owned()).collect();
261        if self.memory.is_some() {
262            for &t in MEMORY_TOOLS {
263                whitelist.push(t.to_owned());
264            }
265        }
266        if let Some(ref registry) = self.registry {
267            for tool_name in registry.tools.keys() {
268                whitelist.push(tool_name.clone());
269            }
270        }
271        let mut scope_lines = Vec::new();
272
273        if !config.skills.is_empty() {
274            for &t in SKILL_TOOLS {
275                whitelist.push(t.to_owned());
276            }
277            scope_lines.push(format!("skills: {}", config.skills.join(", ")));
278        }
279
280        if !config.mcps.is_empty() {
281            for &t in MCP_TOOLS {
282                whitelist.push(t.to_owned());
283            }
284            let server_names: Vec<&str> = config.mcps.iter().map(|s| s.as_str()).collect();
285            scope_lines.push(format!(
286                "mcp servers: {}\nUse search_mcp to discover tools, call_mcp_tool to invoke them.",
287                server_names.join(", ")
288            ));
289        }
290
291        if !config.members.is_empty() {
292            for &t in TASK_TOOLS {
293                whitelist.push(t.to_owned());
294            }
295            scope_lines.push(format!("members: {}", config.members.join(", ")));
296        }
297
298        if !scope_lines.is_empty() {
299            let scope_block = format!("\n\n<scope>\n{}\n</scope>", scope_lines.join("\n"));
300            config.system_prompt.push_str(&scope_block);
301        }
302
303        config.tools = whitelist;
304    }
305
306    /// Check tool permission. Returns `Some(denied_message)` if denied,
307    /// `None` if allowed.
308    ///
309    /// `Ask` permission: allowed for interactive sessions (sender is empty
310    /// or "user"), denied for non-interactive (gateways, sub-agents).
311    fn check_perm(&self, name: &str, agent: &str, sender: &str) -> Option<String> {
312        // OS tools bypass permission when running in sandbox mode.
313        if self.sandboxed && BASE_TOOLS.contains(&name) {
314            return None;
315        }
316        use crate::hook::os::ToolPermission;
317        match self.permissions.resolve(agent, name) {
318            ToolPermission::Allow => None,
319            ToolPermission::Deny => Some(format!("permission denied: {name}")),
320            ToolPermission::Ask => {
321                let interactive = sender.is_empty() || sender == "user";
322                if interactive {
323                    None
324                } else {
325                    tracing::warn!(
326                        tool = name,
327                        agent = agent,
328                        sender = sender,
329                        "tool requires approval — denied for non-interactive session"
330                    );
331                    Some(format!(
332                        "permission denied: {name} (requires interactive approval)"
333                    ))
334                }
335            }
336        }
337    }
338
339    /// Dispatch to an external extension service if the tool is registered.
340    /// Returns `None` if the tool is not in the registry (fall through to in-process).
341    async fn dispatch_external(&self, name: &str, args: &str, agent: &str) -> Option<String> {
342        self.registry
343            .as_ref()?
344            .dispatch_tool(name, args, agent, None)
345            .await
346    }
347
348    /// Scan content for `/skill-name` tokens, load each skill found, and
349    /// append their bodies to the end of the message.
350    /// Tokens that don't match a skill are left as-is.
351    fn resolve_slash_skill(&self, agent: &str, content: &str) -> String {
352        let scope = self.scopes.get(agent);
353        let mut appended = Vec::new();
354        let mut rest = content;
355
356        while let Some(slash) = rest.find('/') {
357            rest = &rest[slash + 1..];
358            // Extract the skill name token: [a-z][a-z0-9-]*
359            let end = rest
360                .find(|c: char| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-')
361                .unwrap_or(rest.len());
362            let name = &rest[..end];
363            rest = &rest[end..];
364
365            if name.is_empty() || name.contains("..") {
366                continue;
367            }
368            // Enforce skill scope.
369            if let Some(scope) = scope
370                && !scope.skills.is_empty()
371                && !scope.skills.iter().any(|s| s == name)
372            {
373                continue;
374            }
375            let skill_file = self.skills.skills_dir.join(name).join("SKILL.md");
376            let Ok(file_content) = std::fs::read_to_string(&skill_file) else {
377                continue;
378            };
379            let Ok(skill) = skill::loader::parse_skill_md(&file_content) else {
380                continue;
381            };
382            appended.push(skill.body);
383        }
384
385        if appended.is_empty() {
386            return content.to_owned();
387        }
388        format!("{}\n\n{}", content, appended.join("\n\n"))
389    }
390
391    /// Route a tool call by name to the appropriate handler.
392    ///
393    /// This is the single dispatch entry point — `event.rs` calls this
394    /// and never matches on tool names itself. Unrecognised names are
395    /// forwarded to the MCP bridge after a warn-level log.
396    pub async fn dispatch_tool(&self, name: &str, args: &str, agent: &str, sender: &str) -> String {
397        if let Some(denied) = self.check_perm(name, agent, sender) {
398            return denied;
399        }
400        // Dispatch enforcement: reject tools not in the agent's whitelist.
401        if let Some(scope) = self.scopes.get(agent)
402            && !scope.tools.is_empty()
403            && !scope.tools.iter().any(|t| t.as_str() == name)
404        {
405            return format!("tool not available: {name}");
406        }
407        match name {
408            "search_mcp" => self.dispatch_search_mcp(args, agent).await,
409            "call_mcp_tool" => self.dispatch_call_mcp_tool(args, agent).await,
410            "search_skill" => self.dispatch_search_skill(args, agent).await,
411            "load_skill" => self.dispatch_load_skill(args, agent).await,
412            "save_skill" => self.dispatch_save_skill(args).await,
413            "bash" => self.dispatch_bash(args).await,
414            "delegate" => self.dispatch_delegate(args, agent).await,
415            "collect" => self.dispatch_collect(args).await,
416            "check_tasks" => self.dispatch_check_tasks(args).await,
417            "recall" => self.dispatch_recall(args).await,
418            "remember" => self.dispatch_remember(args).await,
419            "memory" => self.dispatch_memory(args).await,
420            "forget" => self.dispatch_forget(args).await,
421            "soul" => self.dispatch_soul(args).await,
422            // External extension services.
423            name => {
424                if let Some(result) = self.dispatch_external(name, args, agent).await {
425                    return result;
426                }
427                format!("tool not available: {name}")
428            }
429        }
430    }
431}