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::HistoryEntry};
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", "read", "edit"];
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 mcp_servers(&self) -> Vec<(String, Vec<String>)> {
77 self.mcp.cached_list()
78 }
79
80 pub fn register_scope(&mut self, name: String, config: &AgentConfig) {
82 if name != wcore::paths::DEFAULT_AGENT && !config.description.is_empty() {
83 self.agent_descriptions
84 .insert(name.clone(), config.description.clone());
85 }
86 self.scopes.insert(
87 name,
88 AgentScope {
89 tools: config.tools.clone(),
90 members: config.members.clone(),
91 skills: config.skills.clone(),
92 mcps: config.mcps.clone(),
93 },
94 );
95 }
96
97 fn apply_scope(&self, config: &mut AgentConfig) {
99 let has_scoping =
100 !config.skills.is_empty() || !config.mcps.is_empty() || !config.members.is_empty();
101 if !has_scoping {
102 return;
103 }
104
105 let mut whitelist: Vec<String> = BASE_TOOLS.iter().map(|&s| s.to_owned()).collect();
106 if self.memory.is_some() {
107 for &t in MEMORY_TOOLS {
108 whitelist.push(t.to_owned());
109 }
110 }
111 let mut scope_lines = Vec::new();
112
113 if !config.skills.is_empty() {
114 for &t in SKILL_TOOLS {
115 whitelist.push(t.to_owned());
116 }
117 scope_lines.push(format!("skills: {}", config.skills.join(", ")));
118 }
119
120 if !config.mcps.is_empty() {
121 for &t in MCP_TOOLS {
122 whitelist.push(t.to_owned());
123 }
124 let server_names: Vec<&str> = config.mcps.iter().map(|s| s.as_str()).collect();
125 scope_lines.push(format!("mcp servers: {}", server_names.join(", ")));
126 }
127
128 if !config.members.is_empty() {
129 for &t in TASK_TOOLS {
130 whitelist.push(t.to_owned());
131 }
132 scope_lines.push(format!("members: {}", config.members.join(", ")));
133 }
134
135 if !scope_lines.is_empty() {
136 let scope_block = format!("\n\n<scope>\n{}\n</scope>", scope_lines.join("\n"));
137 config.system_prompt.push_str(&scope_block);
138 }
139
140 config.tools = whitelist;
141 }
142
143 fn resolve_slash_skill(&self, agent: &str, content: &str) -> String {
145 let trimmed = content.trim_start();
146 let Some(rest) = trimmed.strip_prefix('/') else {
147 return content.to_owned();
148 };
149
150 let end = rest
151 .find(|c: char| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-')
152 .unwrap_or(rest.len());
153 let name = &rest[..end];
154 let remainder = &rest[end..];
155
156 if name.is_empty() || name.contains("..") {
157 return content.to_owned();
158 }
159
160 if let Some(scope) = self.scopes.get(agent)
162 && !scope.skills.is_empty()
163 && !scope.skills.iter().any(|s| s == name)
164 {
165 return content.to_owned();
166 }
167
168 for dir in &self.skills.skill_dirs {
170 let skill_file = dir.join(name).join("SKILL.md");
171 let Ok(file_content) = std::fs::read_to_string(&skill_file) else {
172 continue;
173 };
174 let Ok(skill) = skill::loader::parse_skill_md(&file_content) else {
175 continue;
176 };
177 let body = remainder.trim_start();
178 let block = format!("<skill name=\"{name}\">\n{}\n</skill>", skill.body);
179 return if body.is_empty() {
180 block
181 } else {
182 format!("{body}\n\n{block}")
183 };
184 }
185
186 content.to_owned()
187 }
188
189 async fn dispatch_delegate(&self, args: &str, agent: &str) -> Result<String, String> {
191 let input: crate::task::Delegate =
192 serde_json::from_str(args).map_err(|e| format!("invalid arguments: {e}"))?;
193 if input.tasks.is_empty() {
194 return Err("no tasks provided".to_owned());
195 }
196 if let Some(scope) = self.scopes.get(agent)
198 && !scope.members.is_empty()
199 {
200 for task in &input.tasks {
201 if !scope.members.iter().any(|m| m == &task.agent) {
202 return Err(format!(
203 "agent '{}' is not in your members list",
204 task.agent
205 ));
206 }
207 }
208 }
209 self.host.dispatch_delegate(args, agent).await
210 }
211
212 pub async fn dispatch_tool(
214 &self,
215 name: &str,
216 args: &str,
217 agent: &str,
218 sender: &str,
219 conversation_id: Option<u64>,
220 ) -> Result<String, String> {
221 if let Some(scope) = self.scopes.get(agent)
223 && !scope.tools.is_empty()
224 && !scope.tools.iter().any(|t| t.as_str() == name)
225 {
226 return Err(format!("tool not available: {name}"));
227 }
228 match name {
229 "mcp" => self.dispatch_mcp(args, agent).await,
230 "skill" => self.dispatch_skill(args, agent).await,
231 "bash" if sender.contains(':') => {
232 Err("bash is only available in the command line interface".to_owned())
233 }
234 "bash" => self.dispatch_bash(args, conversation_id).await,
235 "read" => self.dispatch_read(args, conversation_id).await,
236 "edit" => self.dispatch_edit(args, conversation_id).await,
237 "recall" => self.dispatch_recall(args).await,
238 "remember" => self.dispatch_remember(args).await,
239 "memory" => self.dispatch_memory(args).await,
240 "forget" => self.dispatch_forget(args).await,
241 "delegate" => self.dispatch_delegate(args, agent).await,
242 "ask_user" => self.host.dispatch_ask_user(args, conversation_id).await,
243 name => {
244 self.host
245 .dispatch_custom_tool(name, args, agent, conversation_id)
246 .await
247 }
248 }
249 }
250}
251
252impl<H: Host + 'static> Hook for Env<H> {
253 fn on_build_agent(&self, mut config: AgentConfig) -> AgentConfig {
254 config.system_prompt.push_str(&os::environment_block());
255
256 if let Some(ref mem) = self.memory {
257 let prompt = mem.build_prompt();
258 if !prompt.is_empty() {
259 config.system_prompt.push_str(&prompt);
260 }
261 }
262
263 let mut hints = Vec::new();
264 let mcp_servers = self.mcp.cached_list();
265 if !mcp_servers.is_empty() {
266 let names: Vec<&str> = mcp_servers.iter().map(|(n, _)| n.as_str()).collect();
267 hints.push(format!(
268 "MCP servers: {}. Use the mcp tool to list or call tools.",
269 names.join(", ")
270 ));
271 }
272 if let Ok(reg) = self.skills.registry.try_lock() {
273 let visible: Vec<_> = if config.skills.is_empty() {
274 reg.skills.iter().collect()
275 } else {
276 reg.skills
277 .iter()
278 .filter(|s| config.skills.iter().any(|n| n == &s.name))
279 .collect()
280 };
281 if !visible.is_empty() {
282 let lines: Vec<String> = visible
283 .iter()
284 .map(|s| {
285 if s.description.is_empty() {
286 format!("- {}", s.name)
287 } else {
288 format!("- {}: {}", s.name, s.description)
289 }
290 })
291 .collect();
292 hints.push(format!(
293 "Skills:\n\
294 When a <skill> tag appears in a message, it has been pre-loaded by the system. \
295 Follow its instructions directly — do not announce or re-load it.\n\
296 Use the skill tool to discover available skills or load one by name.\n{}",
297 lines.join("\n")
298 ));
299 }
300 }
301 if !hints.is_empty() {
302 config.system_prompt.push_str(&format!(
303 "\n\n<resources>\n{}\n</resources>",
304 hints.join("\n")
305 ));
306 }
307
308 self.apply_scope(&mut config);
309 config
310 }
311
312 fn preprocess(&self, agent: &str, content: &str) -> String {
313 self.resolve_slash_skill(agent, content)
314 }
315
316 fn on_before_run(
317 &self,
318 agent: &str,
319 conversation_id: u64,
320 history: &[HistoryEntry],
321 ) -> Vec<HistoryEntry> {
322 let mut entries = Vec::new();
323 let has_members = self
324 .scopes
325 .get(agent)
326 .is_some_and(|s| !s.members.is_empty());
327 if has_members && !self.agent_descriptions.is_empty() {
328 let mut block = String::from("<agents>\n");
329 for (name, desc) in &self.agent_descriptions {
330 block.push_str(&format!("- {name}: {desc}\n"));
331 }
332 block.push_str("</agents>");
333 entries.push(HistoryEntry::user(block).auto_injected());
334 }
335 if let Some(ref mem) = self.memory {
336 entries.extend(mem.before_run(history));
337 }
338 let cwd = self
339 .host
340 .conversation_cwd(conversation_id)
341 .unwrap_or_else(|| self.cwd.clone());
342 entries.push(
343 HistoryEntry::user(format!(
344 "<environment>\nworking_directory: {}\n</environment>",
345 cwd.display()
346 ))
347 .auto_injected(),
348 );
349 if let Some(instructions) = discover_instructions(&cwd) {
350 entries.push(
351 HistoryEntry::user(format!("<instructions>\n{instructions}\n</instructions>"))
352 .auto_injected(),
353 );
354 }
355 if history.iter().any(|e| !e.agent.is_empty()) {
358 entries.push(
359 HistoryEntry::user(
360 "Messages wrapped in <from agent=\"...\"> tags are from guest agents \
361 who were consulted in this conversation. Continue responding as yourself."
362 .to_string(),
363 )
364 .auto_injected(),
365 );
366 }
367 entries
368 }
369
370 async fn on_register_tools(&self, tools: &mut ToolRegistry) {
371 self.mcp.register_tools(tools);
372 tools.insert_all(os::tool::tools());
373 tools.insert_all(os::read::tools());
374 tools.insert_all(os::edit::tools());
375 tools.insert_all(skill::tool::tools());
376 tools.insert_all(crate::task::tools());
377 tools.insert_all(crate::ask_user::tools());
378 if self.memory.is_some() {
379 tools.insert_all(crate::memory::tool::tools());
380 }
381 }
382
383 fn on_event(&self, agent: &str, conversation_id: u64, event: &AgentEvent) {
384 self.host.on_agent_event(agent, conversation_id, event);
385 }
386}
387
388fn discover_instructions(cwd: &Path) -> Option<String> {
392 let config_dir = &*wcore::paths::CONFIG_DIR;
393 let mut layers = Vec::new();
394
395 let global = config_dir.join("Crab.md");
397 if let Ok(content) = std::fs::read_to_string(&global) {
398 layers.push(content);
399 }
400
401 let mut found = Vec::new();
403 let mut dir = cwd;
404 loop {
405 let candidate = dir.join("Crab.md");
406 if candidate.is_file()
407 && !candidate.starts_with(config_dir)
408 && let Ok(content) = std::fs::read_to_string(&candidate)
409 {
410 found.push(content);
411 }
412 match dir.parent() {
413 Some(p) => dir = p,
414 None => break,
415 }
416 }
417 found.reverse();
418 layers.extend(found);
419
420 if layers.is_empty() {
421 return None;
422 }
423 Some(layers.join("\n\n"))
424}