Skip to main content

crabtalk_runtime/
env.rs

1//! Env — the embeddable engine environment.
2//!
3//! [`Env`] 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//! [`Host`](crate::host::Host).
7
8use 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/// Per-agent scope for dispatch enforcement. Empty vecs = unrestricted.
16#[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
24/// Base tools always included in every agent's whitelist.
25const BASE_TOOLS: &[&str] = &["bash", "ask_user", "read", "edit"];
26
27/// Skill discovery/loading tools.
28const SKILL_TOOLS: &[&str] = &["skill"];
29
30/// MCP discovery/call tools.
31const MCP_TOOLS: &[&str] = &["mcp"];
32
33/// Memory tools.
34const MEMORY_TOOLS: &[&str] = &["recall", "remember", "memory", "forget"];
35
36/// Task delegation tools.
37const 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    /// Host providing server-specific functionality.
47    pub host: H,
48}
49
50impl<H: Host> Env<H> {
51    /// Create a new Env with the given backends.
52    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    /// Access memory.
71    pub fn memory(&self) -> Option<&Memory> {
72        self.memory.as_ref()
73    }
74
75    /// List connected MCP servers with their tool names.
76    pub fn mcp_servers(&self) -> Vec<(String, Vec<String>)> {
77        self.mcp.cached_list()
78    }
79
80    /// Register an agent's scope for dispatch enforcement.
81    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    /// Apply scoped tool whitelist and scope prompt for sub-agents.
98    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    /// Resolve a leading `/skill-name` command at the start of the message.
144    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        // Enforce skill scope.
161        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        // Try to load the skill from disk.
169        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    /// Validate member scope and delegate to the bridge.
190    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        // Enforce members scope for all target agents.
197        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    /// Route a tool call by name to the appropriate handler.
213    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        // Dispatch enforcement: reject tools not in the agent's whitelist.
222        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 guest agents have spoken in this conversation, inject framing
356        // so the primary agent doesn't drift toward the guests' personality.
357        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
388/// Collect layered `Crab.md` instructions: global (`~/.crabtalk/Crab.md`)
389/// first, then any `Crab.md` files found walking up from `cwd` (root-first,
390/// project-last so project instructions take precedence).
391fn discover_instructions(cwd: &Path) -> Option<String> {
392    let config_dir = &*wcore::paths::CONFIG_DIR;
393    let mut layers = Vec::new();
394
395    // Global instructions from config dir.
396    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    // Walk up from CWD collecting project Crab.md files.
402    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}