Skip to main content

crabtalk_runtime/
hook.rs

1//! RuntimeHook — the embeddable engine hook.
2//!
3//! [`RuntimeHook`] composes skill, MCP, OS, and memory sub-hooks. It implements
4//! `wcore::Hook` and provides the central `dispatch_tool` entry point. Server-
5//! specific tools (`ask_user`, `delegate`) are routed through the
6//! [`RuntimeBridge`](crate::bridge::RuntimeBridge).
7
8use crate::{
9    bridge::RuntimeBridge, mcp::McpHandler, memory::Memory, os, skill, skill::SkillHandler,
10};
11use std::{
12    collections::BTreeMap,
13    path::{Path, PathBuf},
14};
15use wcore::{AgentConfig, AgentEvent, Hook, ToolRegistry, model::Message};
16
17/// Per-agent scope for dispatch enforcement. Empty vecs = unrestricted.
18#[derive(Default)]
19pub struct AgentScope {
20    pub(crate) tools: Vec<String>,
21    pub(crate) members: Vec<String>,
22    pub(crate) skills: Vec<String>,
23    pub(crate) mcps: Vec<String>,
24}
25
26/// Base tools always included in every agent's whitelist.
27const BASE_TOOLS: &[&str] = &["bash", "ask_user"];
28
29/// Skill discovery/loading tools.
30const SKILL_TOOLS: &[&str] = &["skill"];
31
32/// MCP discovery/call tools.
33const MCP_TOOLS: &[&str] = &["mcp"];
34
35/// Memory tools.
36const MEMORY_TOOLS: &[&str] = &["recall", "remember", "memory", "forget"];
37
38/// Task delegation tools.
39const TASK_TOOLS: &[&str] = &["delegate"];
40
41pub struct RuntimeHook<B: RuntimeBridge = crate::NoBridge> {
42    pub(crate) skills: SkillHandler,
43    pub(crate) mcp: McpHandler,
44    pub(crate) cwd: PathBuf,
45    pub(crate) memory: Option<Memory>,
46    pub(crate) scopes: BTreeMap<String, AgentScope>,
47    pub(crate) agent_descriptions: BTreeMap<String, String>,
48    /// Bridge to server-specific functionality.
49    pub bridge: B,
50}
51
52impl<B: RuntimeBridge> RuntimeHook<B> {
53    /// Create a new RuntimeHook with the given backends.
54    pub fn new(
55        skills: SkillHandler,
56        mcp: McpHandler,
57        cwd: PathBuf,
58        memory: Option<Memory>,
59        bridge: B,
60    ) -> Self {
61        Self {
62            skills,
63            mcp,
64            cwd,
65            memory,
66            scopes: BTreeMap::new(),
67            agent_descriptions: BTreeMap::new(),
68            bridge,
69        }
70    }
71
72    /// Access memory.
73    pub fn memory(&self) -> Option<&Memory> {
74        self.memory.as_ref()
75    }
76
77    /// Register an agent's scope for dispatch enforcement.
78    pub fn register_scope(&mut self, name: String, config: &AgentConfig) {
79        if name != wcore::paths::DEFAULT_AGENT && !config.description.is_empty() {
80            self.agent_descriptions
81                .insert(name.clone(), config.description.clone());
82        }
83        self.scopes.insert(
84            name,
85            AgentScope {
86                tools: config.tools.clone(),
87                members: config.members.clone(),
88                skills: config.skills.clone(),
89                mcps: config.mcps.clone(),
90            },
91        );
92    }
93
94    /// Apply scoped tool whitelist and scope prompt for sub-agents.
95    fn apply_scope(&self, config: &mut AgentConfig) {
96        let has_scoping =
97            !config.skills.is_empty() || !config.mcps.is_empty() || !config.members.is_empty();
98        if !has_scoping {
99            return;
100        }
101
102        let mut whitelist: Vec<String> = BASE_TOOLS.iter().map(|&s| s.to_owned()).collect();
103        if self.memory.is_some() {
104            for &t in MEMORY_TOOLS {
105                whitelist.push(t.to_owned());
106            }
107        }
108        let mut scope_lines = Vec::new();
109
110        if !config.skills.is_empty() {
111            for &t in SKILL_TOOLS {
112                whitelist.push(t.to_owned());
113            }
114            scope_lines.push(format!("skills: {}", config.skills.join(", ")));
115        }
116
117        if !config.mcps.is_empty() {
118            for &t in MCP_TOOLS {
119                whitelist.push(t.to_owned());
120            }
121            let server_names: Vec<&str> = config.mcps.iter().map(|s| s.as_str()).collect();
122            scope_lines.push(format!("mcp servers: {}", server_names.join(", ")));
123        }
124
125        if !config.members.is_empty() {
126            for &t in TASK_TOOLS {
127                whitelist.push(t.to_owned());
128            }
129            scope_lines.push(format!("members: {}", config.members.join(", ")));
130        }
131
132        if !scope_lines.is_empty() {
133            let scope_block = format!("\n\n<scope>\n{}\n</scope>", scope_lines.join("\n"));
134            config.system_prompt.push_str(&scope_block);
135        }
136
137        config.tools = whitelist;
138    }
139
140    /// Resolve a leading `/skill-name` command at the start of the message.
141    fn resolve_slash_skill(&self, agent: &str, content: &str) -> String {
142        let trimmed = content.trim_start();
143        let Some(rest) = trimmed.strip_prefix('/') else {
144            return content.to_owned();
145        };
146
147        let end = rest
148            .find(|c: char| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-')
149            .unwrap_or(rest.len());
150        let name = &rest[..end];
151        let remainder = &rest[end..];
152
153        if name.is_empty() || name.contains("..") {
154            return content.to_owned();
155        }
156
157        // Enforce skill scope.
158        if let Some(scope) = self.scopes.get(agent)
159            && !scope.skills.is_empty()
160            && !scope.skills.iter().any(|s| s == name)
161        {
162            return content.to_owned();
163        }
164
165        // Try to load the skill from disk.
166        for dir in &self.skills.skill_dirs {
167            let skill_file = dir.join(name).join("SKILL.md");
168            let Ok(file_content) = std::fs::read_to_string(&skill_file) else {
169                continue;
170            };
171            let Ok(skill) = skill::loader::parse_skill_md(&file_content) else {
172                continue;
173            };
174            let body = remainder.trim_start();
175            let block = format!("<skill name=\"{name}\">\n{}\n</skill>", skill.body);
176            return if body.is_empty() {
177                block
178            } else {
179                format!("{body}\n\n{block}")
180            };
181        }
182
183        content.to_owned()
184    }
185
186    /// Validate member scope and delegate to the bridge.
187    async fn dispatch_delegate(&self, args: &str, agent: &str) -> String {
188        let input: crate::task::Delegate = match serde_json::from_str(args) {
189            Ok(v) => v,
190            Err(e) => return format!("invalid arguments: {e}"),
191        };
192        if input.tasks.is_empty() {
193            return "no tasks provided".to_owned();
194        }
195        // Enforce members scope for all target agents.
196        if let Some(scope) = self.scopes.get(agent)
197            && !scope.members.is_empty()
198        {
199            for task in &input.tasks {
200                if !scope.members.iter().any(|m| m == &task.agent) {
201                    return format!("agent '{}' is not in your members list", task.agent);
202                }
203            }
204        }
205        self.bridge.dispatch_delegate(args, agent).await
206    }
207
208    /// Route a tool call by name to the appropriate handler.
209    pub async fn dispatch_tool(
210        &self,
211        name: &str,
212        args: &str,
213        agent: &str,
214        sender: &str,
215        session_id: Option<u64>,
216    ) -> String {
217        // Dispatch enforcement: reject tools not in the agent's whitelist.
218        if let Some(scope) = self.scopes.get(agent)
219            && !scope.tools.is_empty()
220            && !scope.tools.iter().any(|t| t.as_str() == name)
221        {
222            return format!("tool not available: {name}");
223        }
224        match name {
225            "mcp" => self.dispatch_mcp(args, agent).await,
226            "skill" => self.dispatch_skill(args, agent).await,
227            "bash" if sender.contains(':') => {
228                "bash is only available in the command line interface".to_owned()
229            }
230            "bash" => self.dispatch_bash(args, session_id).await,
231            "recall" => self.dispatch_recall(args).await,
232            "remember" => self.dispatch_remember(args).await,
233            "memory" => self.dispatch_memory(args).await,
234            "forget" => self.dispatch_forget(args).await,
235            "delegate" => self.dispatch_delegate(args, agent).await,
236            "ask_user" => self.bridge.dispatch_ask_user(args, session_id).await,
237            name => format!("tool not available: {name}"),
238        }
239    }
240}
241
242impl<B: RuntimeBridge + 'static> Hook for RuntimeHook<B> {
243    fn on_build_agent(&self, mut config: AgentConfig) -> AgentConfig {
244        config.system_prompt.push_str(&os::environment_block());
245
246        if let Some(ref mem) = self.memory {
247            let prompt = mem.build_prompt();
248            if !prompt.is_empty() {
249                config.system_prompt.push_str(&prompt);
250            }
251        }
252
253        let mut hints = Vec::new();
254        let mcp_servers = self.mcp.cached_list();
255        if !mcp_servers.is_empty() {
256            let names: Vec<&str> = mcp_servers.iter().map(|(n, _)| n.as_str()).collect();
257            hints.push(format!(
258                "MCP servers: {}. Use the mcp tool to list or call tools.",
259                names.join(", ")
260            ));
261        }
262        if let Ok(reg) = self.skills.registry.try_lock() {
263            let all_skills = reg.skills();
264            let visible: Vec<_> = if config.skills.is_empty() {
265                all_skills.iter().collect()
266            } else {
267                all_skills
268                    .iter()
269                    .filter(|s| config.skills.iter().any(|n| n == &s.name))
270                    .collect()
271            };
272            if !visible.is_empty() {
273                let lines: Vec<String> = visible
274                    .iter()
275                    .map(|s| {
276                        if s.description.is_empty() {
277                            format!("- {}", s.name)
278                        } else {
279                            format!("- {}: {}", s.name, s.description)
280                        }
281                    })
282                    .collect();
283                hints.push(format!(
284                    "Skills:\n\
285                     When a <skill> tag appears in a message, it has been pre-loaded by the system. \
286                     Follow its instructions directly — do not announce or re-load it.\n\
287                     Use the skill tool to discover available skills or load one by name.\n{}",
288                    lines.join("\n")
289                ));
290            }
291        }
292        if !hints.is_empty() {
293            config.system_prompt.push_str(&format!(
294                "\n\n<resources>\n{}\n</resources>",
295                hints.join("\n")
296            ));
297        }
298
299        self.apply_scope(&mut config);
300        config
301    }
302
303    fn preprocess(&self, agent: &str, content: &str) -> String {
304        self.resolve_slash_skill(agent, content)
305    }
306
307    fn on_before_run(&self, agent: &str, session_id: u64, history: &[Message]) -> Vec<Message> {
308        let mut messages = Vec::new();
309        let has_members = self
310            .scopes
311            .get(agent)
312            .is_some_and(|s| !s.members.is_empty());
313        if has_members && !self.agent_descriptions.is_empty() {
314            let mut block = String::from("<agents>\n");
315            for (name, desc) in &self.agent_descriptions {
316                block.push_str(&format!("- {name}: {desc}\n"));
317            }
318            block.push_str("</agents>");
319            let mut msg = Message::user(block);
320            msg.auto_injected = true;
321            messages.push(msg);
322        }
323        if let Some(ref mem) = self.memory {
324            messages.extend(mem.before_run(history));
325        }
326        let cwd = self
327            .bridge
328            .session_cwd(session_id)
329            .unwrap_or_else(|| self.cwd.clone());
330        let mut cwd_msg = Message::user(format!(
331            "<environment>\nworking_directory: {}\n</environment>",
332            cwd.display()
333        ));
334        cwd_msg.auto_injected = true;
335        messages.push(cwd_msg);
336        if let Some(instructions) = discover_instructions(&cwd) {
337            let mut msg = Message::user(format!("<instructions>\n{instructions}\n</instructions>"));
338            msg.auto_injected = true;
339            messages.push(msg);
340        }
341        messages
342    }
343
344    async fn on_register_tools(&self, tools: &mut ToolRegistry) {
345        self.mcp.register_tools(tools);
346        tools.insert_all(os::tool::tools());
347        tools.insert_all(skill::tool::tools());
348        tools.insert_all(crate::task::tools());
349        tools.insert_all(crate::ask_user::tools());
350        if self.memory.is_some() {
351            tools.insert_all(crate::memory::tool::tools());
352        }
353    }
354
355    fn on_event(&self, agent: &str, session_id: u64, event: &AgentEvent) {
356        self.bridge.on_agent_event(agent, session_id, event);
357    }
358}
359
360/// Collect layered `Crab.md` instructions: global (`~/.crabtalk/Crab.md`)
361/// first, then any `Crab.md` files found walking up from `cwd` (root-first,
362/// project-last so project instructions take precedence).
363fn discover_instructions(cwd: &Path) -> Option<String> {
364    let config_dir = &*wcore::paths::CONFIG_DIR;
365    let mut layers = Vec::new();
366
367    // Global instructions from config dir.
368    let global = config_dir.join("Crab.md");
369    if let Ok(content) = std::fs::read_to_string(&global) {
370        layers.push(content);
371    }
372
373    // Walk up from CWD collecting project Crab.md files.
374    let mut found = Vec::new();
375    let mut dir = cwd;
376    loop {
377        let candidate = dir.join("Crab.md");
378        if candidate.is_file()
379            && !candidate.starts_with(config_dir)
380            && let Ok(content) = std::fs::read_to_string(&candidate)
381        {
382            found.push(content);
383        }
384        match dir.parent() {
385            Some(p) => dir = p,
386            None => break,
387        }
388    }
389    found.reverse();
390    layers.extend(found);
391
392    if layers.is_empty() {
393        return None;
394    }
395    Some(layers.join("\n\n"))
396}