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