Skip to main content

crabtalk_daemon/hook/
mod.rs

1//! Stateful Hook implementation for the daemon.
2//!
3//! [`DaemonHook`] composes skill, MCP, OS, and built-in memory sub-hooks.
4//! `on_build_agent` delegates to skills, memory; `on_register_tools` delegates
5//! to all sub-hooks in sequence. `dispatch_tool` routes every agent tool call
6//! by name — the single entry point from `event.rs`.
7
8use crate::{
9    daemon::event::DaemonEventSender,
10    hook::{mcp::McpHandler, skill::SkillHandler, system::memory::Memory},
11};
12use std::{
13    collections::{BTreeMap, HashMap},
14    path::PathBuf,
15    sync::Arc,
16};
17use tokio::sync::{Mutex, broadcast, oneshot};
18use wcore::{
19    AgentConfig, AgentEvent, Hook, ToolRegistry,
20    model::Message,
21    protocol::message::{AgentEventKind, AgentEventMsg},
22};
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    /// Working directory for agent commands (caller's cwd at daemon startup).
42    pub cwd: std::path::PathBuf,
43    /// Built-in memory.
44    pub memory: Option<Memory>,
45    /// Event channel for task delegation.
46    pub(crate) event_tx: DaemonEventSender,
47    /// Per-agent scope maps, populated during load_agents.
48    pub(crate) scopes: BTreeMap<String, AgentScope>,
49    /// Sub-agent descriptions for catalog injection into the crab agent.
50    pub(crate) agent_descriptions: BTreeMap<String, String>,
51    /// Broadcast channel for agent events (console subscription).
52    events_tx: broadcast::Sender<AgentEventMsg>,
53    /// Pending `ask_user` oneshots, keyed by session_id.
54    pub(crate) pending_asks: Arc<Mutex<HashMap<u64, oneshot::Sender<String>>>>,
55    /// Per-session working directory overrides. Populated by protocol handler,
56    /// used by `dispatch_bash` to resolve the caller's cwd.
57    pub(crate) session_cwds: Arc<Mutex<HashMap<u64, PathBuf>>>,
58}
59
60/// Base tools always included in every agent's whitelist.
61const BASE_TOOLS: &[&str] = &["bash", "ask_user"];
62
63/// Skill discovery/loading tools.
64const SKILL_TOOLS: &[&str] = &["skill"];
65
66/// MCP discovery/call tools.
67const MCP_TOOLS: &[&str] = &["mcp"];
68
69/// Memory tools.
70const MEMORY_TOOLS: &[&str] = &["recall", "remember", "memory", "forget"];
71
72/// Task delegation tools.
73const TASK_TOOLS: &[&str] = &["delegate"];
74
75impl Hook for DaemonHook {
76    fn on_build_agent(&self, mut config: AgentConfig) -> AgentConfig {
77        // Inject environment context (OS info). Working directory is
78        // injected per-session in on_before_run.
79        config.system_prompt.push_str(&os::environment_block());
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 the mcp tool to list or call tools.",
97                names.join(", ")
98            ));
99        }
100        if let Ok(reg) = self.skills.registry.try_lock() {
101            let all_skills = reg.skills();
102            // If the agent declares specific skills, show only those with
103            // descriptions. Otherwise show all skills.
104            let visible: Vec<_> = if config.skills.is_empty() {
105                all_skills.iter().collect()
106            } else {
107                all_skills
108                    .iter()
109                    .filter(|s| config.skills.iter().any(|n| n == &s.name))
110                    .collect()
111            };
112            if !visible.is_empty() {
113                let lines: Vec<String> = visible
114                    .iter()
115                    .map(|s| {
116                        if s.description.is_empty() {
117                            format!("- {}", s.name)
118                        } else {
119                            format!("- {}: {}", s.name, s.description)
120                        }
121                    })
122                    .collect();
123                hints.push(format!(
124                    "Skills:\n\
125                     When a <skill> tag appears in a message, it has been pre-loaded by the system. \
126                     Follow its instructions directly — do not announce or re-load it.\n\
127                     Use the skill tool to discover available skills or load one by name.\n{}",
128                    lines.join("\n")
129                ));
130            }
131        }
132        if !hints.is_empty() {
133            config.system_prompt.push_str(&format!(
134                "\n\n<resources>\n{}\n</resources>",
135                hints.join("\n")
136            ));
137        }
138
139        // Apply scoped tool whitelist + prompt for sub-agents.
140        self.apply_scope(&mut config);
141        config
142    }
143
144    fn preprocess(&self, agent: &str, content: &str) -> String {
145        self.resolve_slash_skill(agent, content)
146    }
147
148    fn on_before_run(
149        &self,
150        agent: &str,
151        session_id: u64,
152        history: &[wcore::model::Message],
153    ) -> Vec<wcore::model::Message> {
154        let mut messages = Vec::new();
155        // Any agent with members gets the sub-agent catalog.
156        let has_members = self
157            .scopes
158            .get(agent)
159            .is_some_and(|s| !s.members.is_empty());
160        if has_members && !self.agent_descriptions.is_empty() {
161            let mut block = String::from("<agents>\n");
162            for (name, desc) in &self.agent_descriptions {
163                block.push_str(&format!("- {name}: {desc}\n"));
164            }
165            block.push_str("</agents>");
166            let mut msg = Message::user(block);
167            msg.auto_injected = true;
168            messages.push(msg);
169        }
170        if let Some(ref mem) = self.memory {
171            messages.extend(mem.before_run(history));
172        }
173        // Inject per-session working directory.
174        let cwd = self
175            .session_cwds
176            .try_lock()
177            .ok()
178            .and_then(|m| m.get(&session_id).cloned())
179            .unwrap_or_else(|| self.cwd.clone());
180        let mut cwd_msg = Message::user(format!(
181            "<environment>\nworking_directory: {}\n</environment>",
182            cwd.display()
183        ));
184        cwd_msg.auto_injected = true;
185        messages.push(cwd_msg);
186        messages
187    }
188
189    async fn on_register_tools(&self, tools: &mut ToolRegistry) {
190        self.mcp.register_tools(tools);
191        tools.insert_all(os::tool::tools());
192        tools.insert_all(skill::tool::tools());
193        tools.insert_all(system::task::tool::tools());
194        tools.insert_all(system::ask_user::tools());
195        if self.memory.is_some() {
196            tools.insert_all(system::memory::tool::tools());
197        }
198    }
199
200    fn on_after_compact(&self, agent: &str, summary: &str) {
201        if let Some(ref mem) = self.memory {
202            mem.after_compact(agent, summary);
203        }
204    }
205
206    fn on_event(&self, agent: &str, session_id: u64, event: &AgentEvent) {
207        let (kind, content) = match event {
208            AgentEvent::TextDelta(text) => {
209                tracing::trace!(%agent, text_len = text.len(), "agent text delta");
210                (AgentEventKind::TextDelta, String::new())
211            }
212            AgentEvent::ThinkingDelta(text) => {
213                tracing::trace!(%agent, text_len = text.len(), "agent thinking delta");
214                (AgentEventKind::ThinkingDelta, String::new())
215            }
216            AgentEvent::ToolCallsStart(calls) => {
217                tracing::debug!(%agent, count = calls.len(), "agent tool calls started");
218                let names: Vec<&str> = calls.iter().map(|c| c.function.name.as_str()).collect();
219                (AgentEventKind::ToolStart, names.join(", "))
220            }
221            AgentEvent::ToolResult { call_id, .. } => {
222                tracing::debug!(%agent, %call_id, "agent tool result");
223                (AgentEventKind::ToolResult, call_id.clone())
224            }
225            AgentEvent::ToolCallsComplete => {
226                tracing::debug!(%agent, "agent tool calls complete");
227                (AgentEventKind::ToolsComplete, String::new())
228            }
229            AgentEvent::Compact { summary } => {
230                tracing::info!(%agent, summary_len = summary.len(), "context compacted");
231                self.on_after_compact(agent, summary);
232                return;
233            }
234            AgentEvent::Done(response) => {
235                tracing::info!(
236                    %agent,
237                    iterations = response.iterations,
238                    stop_reason = ?response.stop_reason,
239                    "agent run complete"
240                );
241                (AgentEventKind::Done, String::new())
242            }
243        };
244        let _ = self.events_tx.send(AgentEventMsg {
245            agent: agent.to_string(),
246            session: session_id,
247            kind: kind.into(),
248            content,
249        });
250    }
251}
252
253impl DaemonHook {
254    /// Create a new DaemonHook with the given backends.
255    pub fn new(
256        skills: SkillHandler,
257        mcp: McpHandler,
258        cwd: std::path::PathBuf,
259        memory: Option<Memory>,
260        event_tx: DaemonEventSender,
261    ) -> Self {
262        let (events_tx, _) = broadcast::channel(256);
263        Self {
264            skills,
265            mcp,
266            cwd,
267            memory,
268            event_tx,
269            scopes: BTreeMap::new(),
270            agent_descriptions: BTreeMap::new(),
271            events_tx,
272            pending_asks: Arc::new(Mutex::new(HashMap::new())),
273            session_cwds: Arc::new(Mutex::new(HashMap::new())),
274        }
275    }
276
277    /// Subscribe to agent events (for console event streaming).
278    pub fn subscribe_events(&self) -> broadcast::Receiver<AgentEventMsg> {
279        self.events_tx.subscribe()
280    }
281
282    /// Register an agent's scope for dispatch enforcement.
283    pub(crate) fn register_scope(&mut self, name: String, config: &AgentConfig) {
284        if name != wcore::paths::DEFAULT_AGENT && !config.description.is_empty() {
285            self.agent_descriptions
286                .insert(name.clone(), config.description.clone());
287        }
288        self.scopes.insert(
289            name,
290            AgentScope {
291                tools: config.tools.clone(),
292                members: config.members.clone(),
293                skills: config.skills.clone(),
294                mcps: config.mcps.clone(),
295            },
296        );
297    }
298
299    /// Apply scoped tool whitelist and scope prompt for sub-agents.
300    /// No-op for the crab agent (empty scoping = all tools).
301    fn apply_scope(&self, config: &mut AgentConfig) {
302        let has_scoping =
303            !config.skills.is_empty() || !config.mcps.is_empty() || !config.members.is_empty();
304        if !has_scoping {
305            return;
306        }
307
308        // Base tools + memory always included.
309        let mut whitelist: Vec<String> = BASE_TOOLS.iter().map(|&s| s.to_owned()).collect();
310        if self.memory.is_some() {
311            for &t in MEMORY_TOOLS {
312                whitelist.push(t.to_owned());
313            }
314        }
315        let mut scope_lines = Vec::new();
316
317        if !config.skills.is_empty() {
318            for &t in SKILL_TOOLS {
319                whitelist.push(t.to_owned());
320            }
321            scope_lines.push(format!("skills: {}", config.skills.join(", ")));
322        }
323
324        if !config.mcps.is_empty() {
325            for &t in MCP_TOOLS {
326                whitelist.push(t.to_owned());
327            }
328            let server_names: Vec<&str> = config.mcps.iter().map(|s| s.as_str()).collect();
329            scope_lines.push(format!("mcp servers: {}", server_names.join(", ")));
330        }
331
332        if !config.members.is_empty() {
333            for &t in TASK_TOOLS {
334                whitelist.push(t.to_owned());
335            }
336            scope_lines.push(format!("members: {}", config.members.join(", ")));
337        }
338
339        if !scope_lines.is_empty() {
340            let scope_block = format!("\n\n<scope>\n{}\n</scope>", scope_lines.join("\n"));
341            config.system_prompt.push_str(&scope_block);
342        }
343
344        config.tools = whitelist;
345    }
346
347    /// Resolve a leading `/skill-name` command at the start of the message.
348    /// Only the first token is checked — `/skill-name` must begin the message.
349    /// The slash token is stripped and the skill body is appended as a
350    /// `<skill>` tag. If no leading slash command is found, content is
351    /// returned unchanged.
352    fn resolve_slash_skill(&self, agent: &str, content: &str) -> String {
353        let trimmed = content.trim_start();
354        let Some(rest) = trimmed.strip_prefix('/') else {
355            return content.to_owned();
356        };
357
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        let remainder = &rest[end..];
364
365        if name.is_empty() || name.contains("..") {
366            return content.to_owned();
367        }
368
369        // Enforce skill scope.
370        if let Some(scope) = self.scopes.get(agent)
371            && !scope.skills.is_empty()
372            && !scope.skills.iter().any(|s| s == name)
373        {
374            return content.to_owned();
375        }
376
377        // Try to load the skill from disk.
378        for dir in &self.skills.skill_dirs {
379            let skill_file = dir.join(name).join("SKILL.md");
380            let Ok(file_content) = std::fs::read_to_string(&skill_file) else {
381                continue;
382            };
383            let Ok(skill) = skill::loader::parse_skill_md(&file_content) else {
384                continue;
385            };
386            // Strip the /skill-name token, keep the rest of the message.
387            let body = remainder.trim_start();
388            let block = format!("<skill name=\"{name}\">\n{}\n</skill>", skill.body);
389            return if body.is_empty() {
390                block
391            } else {
392                format!("{body}\n\n{block}")
393            };
394        }
395
396        content.to_owned()
397    }
398
399    /// Route a tool call by name to the appropriate handler.
400    ///
401    /// This is the single dispatch entry point — `event.rs` calls this
402    /// and never matches on tool names itself.
403    pub async fn dispatch_tool(
404        &self,
405        name: &str,
406        args: &str,
407        agent: &str,
408        _sender: &str,
409        session_id: Option<u64>,
410    ) -> String {
411        // Dispatch enforcement: reject tools not in the agent's whitelist.
412        if let Some(scope) = self.scopes.get(agent)
413            && !scope.tools.is_empty()
414            && !scope.tools.iter().any(|t| t.as_str() == name)
415        {
416            return format!("tool not available: {name}");
417        }
418        match name {
419            "mcp" => self.dispatch_mcp(args, agent).await,
420            "skill" => self.dispatch_skill(args, agent).await,
421            "bash" => self.dispatch_bash(args, session_id).await,
422            "delegate" => self.dispatch_delegate(args, agent).await,
423            "recall" => self.dispatch_recall(args).await,
424            "remember" => self.dispatch_remember(args).await,
425            "memory" => self.dispatch_memory(args).await,
426            "forget" => self.dispatch_forget(args).await,
427            "ask_user" => self.dispatch_ask_user(args, session_id).await,
428            name => format!("tool not available: {name}"),
429        }
430    }
431}