1use 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#[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
26const BASE_TOOLS: &[&str] = &["bash", "ask_user"];
28
29const SKILL_TOOLS: &[&str] = &["skill"];
31
32const MCP_TOOLS: &[&str] = &["mcp"];
34
35const MEMORY_TOOLS: &[&str] = &["recall", "remember", "memory", "forget"];
37
38const 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 pub bridge: B,
50}
51
52impl<B: RuntimeBridge> RuntimeHook<B> {
53 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 pub fn memory(&self) -> Option<&Memory> {
74 self.memory.as_ref()
75 }
76
77 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 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 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 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 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 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 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 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 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
360fn discover_instructions(cwd: &Path) -> Option<String> {
364 let config_dir = &*wcore::paths::CONFIG_DIR;
365 let mut layers = Vec::new();
366
367 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 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}