Skip to main content

walrus_daemon/hook/
mod.rs

1//! Stateful Hook implementation for the daemon.
2//!
3//! [`DaemonHook`] composes memory, skill, MCP, and OS sub-hooks.
4//! `on_build_agent` delegates to skills and memory; `on_register_tools`
5//! delegates to all sub-hooks in sequence. `dispatch_tool` routes every
6//! agent tool call by name — the single entry point from `event.rs`.
7
8use crate::{
9    config::{GLOBAL_CONFIG_DIR, WORK_DIR},
10    hook::{
11        mcp::{CallMcpToolInput, McpHandler, SearchMcpInput},
12        os::OsHook,
13        skill::{LoadSkillInput, SearchSkillInput, SkillHandler, loader},
14    },
15};
16use memory::InMemory;
17use wcore::{
18    AgentConfig, AgentEvent, Hook, Memory, RecallInput, RecallOptions, RememberInput, ToolRegistry,
19    model::Tool,
20};
21
22pub mod mcp;
23pub mod os;
24pub mod skill;
25
26/// Stateful Hook implementation for the daemon.
27///
28/// Composes memory, skill, MCP, and OS sub-hooks. Each sub-hook
29/// self-registers its tools via `on_register_tools`. All tool dispatch
30/// is routed through `dispatch_tool`.
31pub struct DaemonHook {
32    pub memory: InMemory,
33    pub skills: SkillHandler,
34    pub mcp: McpHandler,
35    pub os: OsHook,
36}
37
38impl DaemonHook {
39    /// Create a new DaemonHook with the given backends.
40    pub fn new(memory: InMemory, skills: SkillHandler, mcp: McpHandler) -> Self {
41        Self {
42            memory,
43            skills,
44            mcp,
45            os: OsHook::new(GLOBAL_CONFIG_DIR.join(WORK_DIR)),
46        }
47    }
48
49    /// Route a tool call by name to the appropriate handler.
50    ///
51    /// This is the single dispatch entry point — `event.rs` calls this
52    /// and never matches on tool names itself. Unrecognised names are
53    /// forwarded to the MCP bridge after a warn-level log.
54    pub async fn dispatch_tool(&self, name: &str, args: &str) -> String {
55        match name {
56            "remember" => self.dispatch_remember(args).await,
57            "recall" => self.dispatch_recall(args).await,
58            "search_mcp" => self.dispatch_search_mcp(args).await,
59            "call_mcp_tool" => self.dispatch_call_mcp_tool(args).await,
60            "search_skill" => self.dispatch_search_skill(args).await,
61            "load_skill" => self.dispatch_load_skill(args).await,
62            "read" => self.os.dispatch_read(args).await,
63            "write" => self.os.dispatch_write(args).await,
64            "bash" => self.os.dispatch_bash(args).await,
65            name => {
66                tracing::debug!(tool = name, "forwarding tool to MCP bridge");
67                let bridge = self.mcp.bridge().await;
68                bridge.call(name, args).await
69            }
70        }
71    }
72
73    // ── Memory tools ─────────────────────────────────────────────────
74
75    async fn dispatch_remember(&self, args: &str) -> String {
76        let input: RememberInput = match serde_json::from_str(args) {
77            Ok(v) => v,
78            Err(e) => return format!("invalid arguments: {e}"),
79        };
80        if input.key.is_empty() {
81            return "missing required field: key".to_owned();
82        }
83        let key = input.key.clone();
84        match self.memory.store(input.key, input.value).await {
85            Ok(()) => format!("remembered: {key}"),
86            Err(e) => format!("failed to store: {e}"),
87        }
88    }
89
90    async fn dispatch_recall(&self, args: &str) -> String {
91        let input: RecallInput = match serde_json::from_str(args) {
92            Ok(v) => v,
93            Err(e) => return format!("invalid arguments: {e}"),
94        };
95        let limit = input.limit.unwrap_or(10) as usize;
96        let options = RecallOptions {
97            limit,
98            ..Default::default()
99        };
100        match self.memory.recall(&input.query, options).await {
101            Ok(entries) if entries.is_empty() => "no memories found".to_owned(),
102            Ok(entries) => entries
103                .iter()
104                .map(|e| format!("{}: {}", e.key, e.value))
105                .collect::<Vec<_>>()
106                .join("\n"),
107            Err(e) => format!("recall failed: {e}"),
108        }
109    }
110
111    // ── MCP tools ────────────────────────────────────────────────────
112
113    async fn dispatch_search_mcp(&self, args: &str) -> String {
114        let input: SearchMcpInput = match serde_json::from_str(args) {
115            Ok(v) => v,
116            Err(e) => return format!("invalid arguments: {e}"),
117        };
118        let query = input.query.to_lowercase();
119        let bridge = self.mcp.bridge().await;
120        let tools = bridge.tools().await;
121        let matches: Vec<String> = tools
122            .iter()
123            .filter(|t| {
124                t.name.to_lowercase().contains(&query)
125                    || t.description.to_lowercase().contains(&query)
126            })
127            .map(|t| format!("{}: {}", t.name, t.description))
128            .collect();
129        if matches.is_empty() {
130            "no tools found".to_owned()
131        } else {
132            matches.join("\n")
133        }
134    }
135
136    async fn dispatch_call_mcp_tool(&self, args: &str) -> String {
137        let input: CallMcpToolInput = match serde_json::from_str(args) {
138            Ok(v) => v,
139            Err(e) => return format!("invalid arguments: {e}"),
140        };
141        let tool_args = input.args.unwrap_or_default();
142        let bridge = self.mcp.bridge().await;
143        bridge.call(&input.name, &tool_args).await
144    }
145
146    // ── Skill tools ──────────────────────────────────────────────────
147
148    async fn dispatch_search_skill(&self, args: &str) -> String {
149        let input: SearchSkillInput = match serde_json::from_str(args) {
150            Ok(v) => v,
151            Err(e) => return format!("invalid arguments: {e}"),
152        };
153        let query = input.query.to_lowercase();
154        let registry = self.skills.registry.lock().await;
155        let matches: Vec<String> = registry
156            .skills()
157            .into_iter()
158            .filter(|s| {
159                s.name.to_lowercase().contains(&query)
160                    || s.description.to_lowercase().contains(&query)
161            })
162            .map(|s| format!("{}: {}", s.name, s.description))
163            .collect();
164        if matches.is_empty() {
165            "no skills found".to_owned()
166        } else {
167            matches.join("\n")
168        }
169    }
170
171    async fn dispatch_load_skill(&self, args: &str) -> String {
172        let input: LoadSkillInput = match serde_json::from_str(args) {
173            Ok(v) => v,
174            Err(e) => return format!("invalid arguments: {e}"),
175        };
176        let name = &input.name;
177        // Guard against path traversal in the skill name.
178        if name.contains("..") || name.contains('/') || name.contains('\\') {
179            return format!("invalid skill name: {name}");
180        }
181        let skill_dir = self.skills.skills_dir.join(name);
182        let skill_file = skill_dir.join("SKILL.md");
183        let content = match tokio::fs::read_to_string(&skill_file).await {
184            Ok(c) => c,
185            Err(_) => return format!("skill not found: {name}"),
186        };
187        let skill = match loader::parse_skill_md(&content) {
188            Ok(s) => s,
189            Err(e) => return format!("failed to parse skill: {e}"),
190        };
191        let body = skill.body.clone();
192        self.skills.registry.lock().await.add(skill);
193        let dir_path = skill_dir.display();
194        format!("{body}\n\nSkill directory: {dir_path}")
195    }
196}
197
198impl Hook for DaemonHook {
199    fn on_build_agent(&self, config: AgentConfig) -> AgentConfig {
200        self.memory.on_build_agent(config)
201    }
202
203    async fn on_register_tools(&self, tools: &mut ToolRegistry) {
204        self.memory.on_register_tools(tools).await;
205        self.mcp.on_register_tools(tools).await;
206        self.os.on_register_tools(tools).await;
207        self.register_system_tools(tools);
208    }
209
210    fn on_event(&self, agent: &str, event: &AgentEvent) {
211        match event {
212            AgentEvent::TextDelta(text) => {
213                tracing::trace!(%agent, text_len = text.len(), "agent text delta");
214            }
215            AgentEvent::ToolCallsStart(calls) => {
216                tracing::debug!(%agent, count = calls.len(), "agent tool calls started");
217            }
218            AgentEvent::ToolResult { call_id, .. } => {
219                tracing::debug!(%agent, %call_id, "agent tool result");
220            }
221            AgentEvent::ToolCallsComplete => {
222                tracing::debug!(%agent, "agent tool calls complete");
223            }
224            AgentEvent::Done(response) => {
225                tracing::info!(
226                    %agent,
227                    iterations = response.iterations,
228                    stop_reason = ?response.stop_reason,
229                    "agent run complete"
230                );
231            }
232        }
233    }
234}
235
236impl DaemonHook {
237    /// Register MCP and skill discovery tool schemas.
238    fn register_system_tools(&self, tools: &mut ToolRegistry) {
239        tools.insert(Tool {
240            name: "search_mcp".into(),
241            description: "Search available MCP tools by keyword.".into(),
242            parameters: schemars::schema_for!(SearchMcpInput),
243            strict: false,
244        });
245        tools.insert(Tool {
246            name: "call_mcp_tool".into(),
247            description: "Call an MCP tool by name with JSON-encoded arguments.".into(),
248            parameters: schemars::schema_for!(CallMcpToolInput),
249            strict: false,
250        });
251        tools.insert(Tool {
252            name: "search_skill".into(),
253            description: "Search available skills by keyword. Returns name and description only."
254                .into(),
255            parameters: schemars::schema_for!(SearchSkillInput),
256            strict: false,
257        });
258        tools.insert(Tool {
259            name: "load_skill".into(),
260            description: "Load a skill by name. Returns its instructions and the skill directory path for resolving relative file references.".into(),
261            parameters: schemars::schema_for!(LoadSkillInput),
262            strict: false,
263        });
264    }
265}