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. Built-in memory is active by default; when
5//! the walrus-memory extension registers `recall`, built-in tools are skipped.
6//! `on_build_agent` delegates to skills, memory, and extension services;
7//! `on_register_tools` delegates to all sub-hooks in sequence.
8//! `dispatch_tool` routes every agent tool call by name — the single
9//! entry point from `event.rs`.
10
11use crate::{
12    daemon::event::DaemonEventSender,
13    ext::hub::DownloadRegistry,
14    hook::{
15        mcp::McpHandler,
16        os::PermissionConfig,
17        skill::SkillHandler,
18        system::{memory::BuiltinMemory, task::TaskRegistry},
19    },
20    service::ServiceRegistry,
21};
22use compact_str::CompactString;
23use std::{collections::BTreeMap, sync::Arc, time::Duration};
24use tokio::sync::Mutex;
25use wcore::{AgentConfig, AgentEvent, Hook, ToolRegistry, model::Message};
26
27pub mod mcp;
28pub mod os;
29pub mod skill;
30pub mod system;
31
32/// Per-agent scope for dispatch enforcement. Empty vecs = unrestricted.
33#[derive(Default)]
34pub(crate) struct AgentScope {
35    pub(crate) tools: Vec<CompactString>,
36    pub(crate) members: Vec<String>,
37    pub(crate) skills: Vec<String>,
38    pub(crate) mcps: Vec<String>,
39}
40
41pub struct DaemonHook {
42    pub skills: SkillHandler,
43    pub mcp: McpHandler,
44    pub tasks: Arc<Mutex<TaskRegistry>>,
45    pub downloads: Arc<Mutex<DownloadRegistry>>,
46    pub permissions: PermissionConfig,
47    /// Whether the daemon is running as the `walrus` OS user (sandbox active).
48    pub sandboxed: bool,
49    /// Built-in memory (None if disabled or overridden by walrus-memory extension).
50    pub memory: Option<BuiltinMemory>,
51    /// Event channel for task dispatch.
52    pub(crate) event_tx: DaemonEventSender,
53    /// Per-task execution timeout.
54    pub(crate) task_timeout: Duration,
55    /// Per-agent scope maps, populated during load_agents.
56    pub(crate) scopes: BTreeMap<CompactString, AgentScope>,
57    /// Sub-agent descriptions for catalog injection into the walrus agent.
58    pub(crate) agent_descriptions: BTreeMap<CompactString, CompactString>,
59    /// External extension service registry (tools + queries).
60    pub(crate) registry: Option<Arc<ServiceRegistry>>,
61}
62
63/// Base tools always included in every agent's whitelist.
64/// Also bypass permission check when running in sandbox mode.
65const BASE_TOOLS: &[&str] = &["read", "write", "edit", "bash"];
66
67/// Skill discovery/loading tools.
68const SKILL_TOOLS: &[&str] = &["search_skill", "load_skill", "save_skill"];
69
70/// MCP discovery/call tools.
71const MCP_TOOLS: &[&str] = &["search_mcp", "call_mcp_tool"];
72
73/// Memory tools.
74const MEMORY_TOOLS: &[&str] = &["recall", "memory", "user_memory"];
75
76/// Task delegation tools.
77const TASK_TOOLS: &[&str] = &["spawn_task", "check_tasks", "ask_user", "await_tasks"];
78
79impl Hook for DaemonHook {
80    fn on_build_agent(&self, config: AgentConfig) -> AgentConfig {
81        // Delegate to extension services first (prompt enrichment).
82        let mut config = match self.registry {
83            Some(ref registry) => registry.on_build_agent(config),
84            None => config,
85        };
86
87        // Inject environment context (OS, working directory, sandbox state).
88        config
89            .system_prompt
90            .push_str(&os::environment_block(self.sandboxed));
91
92        // Inject built-in memory prompt if active.
93        if let Some(ref mem) = self.memory {
94            let prompt = mem.build_prompt();
95            if !prompt.is_empty() {
96                config.system_prompt.push_str(&prompt);
97            }
98        }
99
100        // Apply scoped tool whitelist + prompt for sub-agents.
101        self.apply_scope(&mut config);
102        config
103    }
104
105    fn on_compact(&self, agent: &str, prompt: &mut String) {
106        if let Some(ref registry) = self.registry {
107            registry.on_compact(agent, prompt);
108        }
109    }
110
111    fn on_before_run(
112        &self,
113        agent: &str,
114        history: &[wcore::model::Message],
115    ) -> Vec<wcore::model::Message> {
116        let mut messages = match self.registry {
117            Some(ref registry) => registry.on_before_run(agent, history),
118            None => Vec::new(),
119        };
120        if agent == wcore::paths::DEFAULT_AGENT && !self.agent_descriptions.is_empty() {
121            let mut block = String::from("<agents>\n");
122            for (name, desc) in &self.agent_descriptions {
123                block.push_str(&format!("- {name}: {desc}\n"));
124            }
125            block.push_str("</agents>");
126            messages.push(Message::user(block));
127        }
128        if let Some(ref mem) = self.memory {
129            messages.extend(mem.before_run(history));
130        }
131        messages
132    }
133
134    async fn on_register_tools(&self, tools: &mut ToolRegistry) {
135        self.mcp.on_register_tools(tools).await;
136        tools.insert_all(os::tool::tools());
137        tools.insert_all(skill::tool::tools());
138        tools.insert_all(system::task::tool::tools());
139        if let Some(ref registry) = self.registry {
140            registry.on_register_tools(tools).await;
141        }
142        // Register built-in memory tools only if no extension provides "recall".
143        if self.memory.is_some() && !tools.contains("recall") {
144            tools.insert_all(system::memory::tool::tools());
145        }
146    }
147
148    fn on_after_run(&self, agent: &str, history: &[Message], system_prompt: &str) {
149        if let Some(ref registry) = self.registry {
150            registry.on_after_run(agent, history, system_prompt);
151        }
152    }
153
154    fn on_after_compact(&self, agent: &str, summary: &str) {
155        if let Some(ref registry) = self.registry {
156            registry.on_after_compact(agent, summary);
157        }
158        if let Some(ref mem) = self.memory {
159            mem.after_compact(agent, summary);
160        }
161    }
162
163    fn on_event(&self, agent: &str, event: &AgentEvent) {
164        match event {
165            AgentEvent::TextDelta(text) => {
166                tracing::trace!(%agent, text_len = text.len(), "agent text delta");
167            }
168            AgentEvent::ThinkingDelta(text) => {
169                tracing::trace!(%agent, text_len = text.len(), "agent thinking delta");
170            }
171            AgentEvent::ToolCallsStart(calls) => {
172                tracing::debug!(%agent, count = calls.len(), "agent tool calls started");
173            }
174            AgentEvent::ToolResult { call_id, .. } => {
175                tracing::debug!(%agent, %call_id, "agent tool result");
176            }
177            AgentEvent::ToolCallsComplete => {
178                tracing::debug!(%agent, "agent tool calls complete");
179            }
180            AgentEvent::Compact { summary } => {
181                tracing::info!(%agent, summary_len = summary.len(), "context compacted");
182                self.on_after_compact(agent, summary);
183            }
184            AgentEvent::Done(response) => {
185                tracing::info!(
186                    %agent,
187                    iterations = response.iterations,
188                    stop_reason = ?response.stop_reason,
189                    "agent run complete"
190                );
191                // Track token usage on the active task for this agent.
192                let (prompt, completion) = response.steps.iter().fold((0u64, 0u64), |(p, c), s| {
193                    (
194                        p + u64::from(s.response.usage.prompt_tokens),
195                        c + u64::from(s.response.usage.completion_tokens),
196                    )
197                });
198                if (prompt > 0 || completion > 0)
199                    && let Ok(mut registry) = self.tasks.try_lock()
200                {
201                    let tid = registry
202                        .list(
203                            Some(agent),
204                            Some(system::task::TaskStatus::InProgress),
205                            None,
206                        )
207                        .first()
208                        .map(|t| t.id);
209                    if let Some(tid) = tid {
210                        registry.add_tokens(tid, prompt, completion);
211                    }
212                }
213            }
214        }
215    }
216}
217
218impl DaemonHook {
219    /// Create a new DaemonHook with the given backends.
220    #[allow(clippy::too_many_arguments)]
221    pub fn new(
222        skills: SkillHandler,
223        mcp: McpHandler,
224        tasks: Arc<Mutex<TaskRegistry>>,
225        downloads: Arc<Mutex<DownloadRegistry>>,
226        permissions: PermissionConfig,
227        sandboxed: bool,
228        memory: Option<BuiltinMemory>,
229        registry: Option<Arc<ServiceRegistry>>,
230        event_tx: DaemonEventSender,
231        task_timeout: Duration,
232    ) -> Self {
233        Self {
234            skills,
235            mcp,
236            tasks,
237            downloads,
238            permissions,
239            sandboxed,
240            memory,
241            event_tx,
242            task_timeout,
243            scopes: BTreeMap::new(),
244            agent_descriptions: BTreeMap::new(),
245            registry,
246        }
247    }
248
249    /// Register an agent's scope for dispatch enforcement.
250    pub(crate) fn register_scope(&mut self, name: CompactString, config: &AgentConfig) {
251        if name != wcore::paths::DEFAULT_AGENT && !config.description.is_empty() {
252            self.agent_descriptions
253                .insert(name.clone(), config.description.clone());
254        }
255        self.scopes.insert(
256            name,
257            AgentScope {
258                tools: config.tools.clone(),
259                members: config.members.clone(),
260                skills: config.skills.clone(),
261                mcps: config.mcps.clone(),
262            },
263        );
264    }
265
266    /// Apply scoped tool whitelist and scope prompt for sub-agents.
267    /// No-op for the walrus agent (empty scoping = all tools).
268    fn apply_scope(&self, config: &mut AgentConfig) {
269        let has_scoping =
270            !config.skills.is_empty() || !config.mcps.is_empty() || !config.members.is_empty();
271        if !has_scoping {
272            return;
273        }
274
275        // Base tools + memory + external service tools always included.
276        let mut whitelist: Vec<CompactString> =
277            BASE_TOOLS.iter().map(|&s| CompactString::from(s)).collect();
278        if self.memory.is_some() {
279            for &t in MEMORY_TOOLS {
280                whitelist.push(CompactString::from(t));
281            }
282        }
283        if let Some(ref registry) = self.registry {
284            for tool_name in registry.tools.keys() {
285                whitelist.push(CompactString::from(tool_name.as_str()));
286            }
287        }
288        let mut scope_lines = Vec::new();
289
290        if !config.skills.is_empty() {
291            for &t in SKILL_TOOLS {
292                whitelist.push(CompactString::from(t));
293            }
294            scope_lines.push(format!("skills: {}", config.skills.join(", ")));
295        }
296
297        if !config.mcps.is_empty() {
298            for &t in MCP_TOOLS {
299                whitelist.push(CompactString::from(t));
300            }
301            let mcp_servers = tokio::task::block_in_place(|| {
302                tokio::runtime::Handle::current().block_on(self.mcp.list())
303            });
304            let mut mcp_info = Vec::new();
305            for (server_name, tool_names) in &mcp_servers {
306                if config.mcps.iter().any(|m| m == server_name.as_str()) {
307                    for tn in tool_names {
308                        whitelist.push(tn.clone());
309                    }
310                    mcp_info.push(format!(
311                        "  - {}: {}",
312                        server_name,
313                        tool_names
314                            .iter()
315                            .map(|t| t.as_str())
316                            .collect::<Vec<_>>()
317                            .join(", ")
318                    ));
319                }
320            }
321            if !mcp_info.is_empty() {
322                scope_lines.push(format!("mcp servers:\n{}", mcp_info.join("\n")));
323            }
324        }
325
326        if !config.members.is_empty() {
327            for &t in TASK_TOOLS {
328                whitelist.push(CompactString::from(t));
329            }
330            scope_lines.push(format!("members: {}", config.members.join(", ")));
331        }
332
333        if !scope_lines.is_empty() {
334            let scope_block = format!("\n\n<scope>\n{}\n</scope>", scope_lines.join("\n"));
335            config.system_prompt.push_str(&scope_block);
336        }
337
338        config.tools = whitelist;
339    }
340
341    /// Check tool permission. Returns `Some(denied_message)` if denied,
342    /// `None` if allowed.
343    async fn check_perm(
344        &self,
345        name: &str,
346        args: &str,
347        agent: &str,
348        task_id: Option<u64>,
349    ) -> Option<String> {
350        // OS tools bypass permission when running in sandbox mode.
351        if self.sandboxed && BASE_TOOLS.contains(&name) {
352            return None;
353        }
354        use crate::hook::os::ToolPermission;
355        match self.permissions.resolve(agent, name) {
356            ToolPermission::Deny => Some(format!("permission denied: {name}")),
357            ToolPermission::Ask => {
358                if let Some(tid) = task_id {
359                    let summary = if args.len() > 200 {
360                        format!("{}…", &args[..200])
361                    } else {
362                        args.to_string()
363                    };
364                    let question = format!("{name}: {summary}");
365                    let rx = self.tasks.lock().await.block(tid, question);
366                    if let Some(rx) = rx {
367                        match rx.await {
368                            Ok(resp) if resp == "denied" => {
369                                return Some(format!("permission denied: {name}"));
370                            }
371                            Err(_) => {
372                                return Some(format!("permission denied: {name} (inbox dropped)"));
373                            }
374                            _ => {} // approved → proceed
375                        }
376                    }
377                }
378                // No task_id → can't block, treat as Allow.
379                None
380            }
381            ToolPermission::Allow => None,
382        }
383    }
384
385    /// Dispatch to an external extension service if the tool is registered.
386    /// Returns `None` if the tool is not in the registry (fall through to in-process).
387    async fn dispatch_external(
388        &self,
389        name: &str,
390        args: &str,
391        agent: &str,
392        task_id: Option<u64>,
393    ) -> Option<String> {
394        self.registry
395            .as_ref()?
396            .dispatch_tool(name, args, agent, task_id)
397            .await
398    }
399
400    /// Route a tool call by name to the appropriate handler.
401    ///
402    /// This is the single dispatch entry point — `event.rs` calls this
403    /// and never matches on tool names itself. Unrecognised names are
404    /// forwarded to the MCP bridge after a warn-level log.
405    pub async fn dispatch_tool(
406        &self,
407        name: &str,
408        args: &str,
409        agent: &str,
410        task_id: Option<u64>,
411    ) -> String {
412        if let Some(denied) = self.check_perm(name, args, agent, task_id).await {
413            return denied;
414        }
415        // Dispatch enforcement: reject tools not in the agent's whitelist.
416        if let Some(scope) = self.scopes.get(agent)
417            && !scope.tools.is_empty()
418            && !scope.tools.iter().any(|t| t.as_str() == name)
419        {
420            return format!("tool not available: {name}");
421        }
422        match name {
423            "search_mcp" => self.dispatch_search_mcp(args, agent).await,
424            "call_mcp_tool" => self.dispatch_call_mcp_tool(args, agent).await,
425            "search_skill" => self.dispatch_search_skill(args, agent).await,
426            "load_skill" => self.dispatch_load_skill(args, agent).await,
427            "save_skill" => self.dispatch_save_skill(args).await,
428            "read" => self.dispatch_read(args).await,
429            "write" => self.dispatch_write(args).await,
430            "edit" => self.dispatch_edit(args).await,
431            "bash" => self.dispatch_bash(args).await,
432            "spawn_task" => self.dispatch_spawn_task(args, agent, task_id).await,
433            "check_tasks" => self.dispatch_check_tasks(args).await,
434            "ask_user" => self.dispatch_ask_user(args, task_id).await,
435            "await_tasks" => self.dispatch_await_tasks(args, task_id).await,
436            "recall" => self.dispatch_recall(args).await,
437            "memory" => self.dispatch_memory(args).await,
438            "user_memory" => self.dispatch_user_memory(args).await,
439            // External extension services, then MCP bridge as final fallback.
440            name => {
441                if let Some(result) = self.dispatch_external(name, args, agent, task_id).await {
442                    return result;
443                }
444                tracing::debug!(tool = name, "forwarding tool to MCP bridge");
445                let bridge = self.mcp.bridge().await;
446                bridge.call(name, args).await
447            }
448        }
449    }
450}