Skip to main content

sparrow/agent/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use crate::engine::Identity;
6use crate::memory::Memory;
7
8// ─── SOUL: persistent agent definition ──────────────────────────────────────────
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Soul {
12    pub name: String,
13    #[serde(default)]
14    pub description: String,
15    pub role: String,
16    pub personality: String,
17    #[serde(default)]
18    pub prompt: String,
19    #[serde(default)]
20    pub rules: Vec<String>,
21    #[serde(default)]
22    pub tools: Vec<String>,
23    #[serde(default)]
24    pub disallowed_tools: Vec<String>,
25    #[serde(default)]
26    pub default_model: Option<String>,
27    #[serde(default)]
28    pub default_autonomy: Option<String>,
29    #[serde(default)]
30    pub permission_mode: Option<String>,
31    #[serde(default)]
32    pub mcp_servers: Vec<String>,
33    #[serde(default)]
34    pub max_turns: Option<u32>,
35    #[serde(default)]
36    pub memory: Option<bool>,
37    #[serde(default)]
38    pub background: bool,
39    #[serde(default)]
40    pub isolation: Option<String>,
41    #[serde(default)]
42    pub color: Option<String>,
43}
44
45impl Soul {
46    pub fn to_identity(&self) -> Identity {
47        Identity {
48            name: self.name.clone(),
49            role: self.role.clone(),
50            personality: if self.prompt.trim().is_empty() {
51                self.personality.clone()
52            } else {
53                format!("{}\n\n{}", self.personality, self.prompt)
54            },
55        }
56    }
57
58    pub fn to_toml(&self) -> anyhow::Result<String> {
59        Ok(toml::to_string_pretty(self)?)
60    }
61
62    pub fn from_toml(content: &str) -> anyhow::Result<Self> {
63        Ok(toml::from_str(content)?)
64    }
65
66    pub fn from_markdown_frontmatter(content: &str) -> anyhow::Result<Self> {
67        let Some(rest) = content.strip_prefix("---") else {
68            anyhow::bail!("agent markdown is missing frontmatter");
69        };
70        let Some((frontmatter, body)) = rest.split_once("---") else {
71            anyhow::bail!("agent markdown frontmatter is not closed");
72        };
73        let mut soul = Soul::default();
74        for line in frontmatter.lines() {
75            let line = line.trim();
76            if line.is_empty() || line.starts_with('#') {
77                continue;
78            }
79            let Some((key, value)) = line.split_once(':') else {
80                continue;
81            };
82            let key = key.trim();
83            let value = value.trim().trim_matches('"').trim_matches('\'');
84            match key {
85                "name" => soul.name = value.to_string(),
86                "description" => soul.description = value.to_string(),
87                "role" => soul.role = value.to_string(),
88                "personality" => soul.personality = value.to_string(),
89                "prompt" => soul.prompt = value.to_string(),
90                "tools" => soul.tools = parse_list(value),
91                "disallowed_tools" => soul.disallowed_tools = parse_list(value),
92                "model" | "default_model" => soul.default_model = nonempty(value),
93                "default_autonomy" => soul.default_autonomy = nonempty(value),
94                "permission_mode" => soul.permission_mode = nonempty(value),
95                "mcp_servers" => soul.mcp_servers = parse_list(value),
96                "max_turns" => soul.max_turns = value.parse::<u32>().ok(),
97                "memory" => soul.memory = parse_bool(value),
98                "background" => soul.background = parse_bool(value).unwrap_or(false),
99                "isolation" => soul.isolation = nonempty(value),
100                "color" => soul.color = nonempty(value),
101                _ => {}
102            }
103        }
104        let body = body.trim();
105        if !body.is_empty() {
106            soul.prompt = if soul.prompt.trim().is_empty() {
107                body.to_string()
108            } else {
109                format!("{}\n\n{}", soul.prompt, body)
110            };
111        }
112        Ok(soul)
113    }
114}
115
116impl Default for Soul {
117    fn default() -> Self {
118        Self {
119            name: "sparrow".into(),
120            description: String::new(),
121            role: "senior software engineer".into(),
122            personality: "concise, competent, direct. Prefers working code over explanation."
123                .into(),
124            prompt: String::new(),
125            rules: vec![],
126            tools: vec![],
127            disallowed_tools: vec![],
128            default_model: None,
129            default_autonomy: Some("supervised".into()),
130            permission_mode: None,
131            mcp_servers: vec![],
132            max_turns: None,
133            memory: None,
134            background: false,
135            isolation: None,
136            color: None,
137        }
138    }
139}
140
141fn nonempty(value: &str) -> Option<String> {
142    let value = value.trim();
143    if value.is_empty() {
144        None
145    } else {
146        Some(value.to_string())
147    }
148}
149
150fn parse_bool(value: &str) -> Option<bool> {
151    match value.trim().to_lowercase().as_str() {
152        "true" | "yes" | "1" => Some(true),
153        "false" | "no" | "0" => Some(false),
154        _ => None,
155    }
156}
157
158fn parse_list(value: &str) -> Vec<String> {
159    let value = value.trim();
160    let value = value
161        .strip_prefix('[')
162        .and_then(|v| v.strip_suffix(']'))
163        .unwrap_or(value);
164    value
165        .split(',')
166        .map(|item| item.trim().trim_matches('"').trim_matches('\''))
167        .filter(|item| !item.is_empty())
168        .map(str::to_string)
169        .collect()
170}
171
172// ─── Agent store trait ──────────────────────────────────────────────────────────
173
174pub trait AgentStore: Send + Sync {
175    fn create(&self, soul: &Soul) -> anyhow::Result<()>;
176    fn get(&self, name: &str) -> Option<Soul>;
177    fn list(&self) -> Vec<Soul>;
178    fn update(&self, name: &str, soul: &Soul) -> anyhow::Result<()>;
179    fn remove(&self, name: &str) -> anyhow::Result<()>;
180}
181
182// ─── Filesystem-backed agent store (SOUL files as TOML) ─────────────────────────
183
184pub struct FsAgentStore {
185    agents_dir: PathBuf,
186    memory: Option<Arc<dyn Memory>>,
187}
188
189impl FsAgentStore {
190    pub fn new(agents_dir: PathBuf) -> Self {
191        Self {
192            agents_dir,
193            memory: None,
194        }
195    }
196
197    pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
198        self.memory = Some(memory);
199        self
200    }
201
202    fn soul_path(&self, name: &str) -> PathBuf {
203        self.agents_dir.join(format!(
204            "{}.soul.toml",
205            safe_agent_name(name).unwrap_or("invalid")
206        ))
207    }
208
209    fn find_agent_path(&self, name: &str) -> Option<PathBuf> {
210        let name = safe_agent_name(name).ok()?;
211        let toml_path = self.soul_path(name);
212        if toml_path.exists() {
213            return Some(toml_path);
214        }
215        let md_path = self.agents_dir.join(format!("{}.agent.md", name));
216        if md_path.exists() {
217            return Some(md_path);
218        }
219        None
220    }
221}
222
223impl AgentStore for FsAgentStore {
224    fn create(&self, soul: &Soul) -> anyhow::Result<()> {
225        std::fs::create_dir_all(&self.agents_dir)?;
226        safe_agent_name(&soul.name)?;
227        let path = self.soul_path(&soul.name);
228        if path.exists() {
229            anyhow::bail!(
230                "Agent '{}' already exists. Use 'edit' to modify.",
231                soul.name
232            );
233        }
234        let content = soul.to_toml()?;
235        std::fs::write(&path, content)?;
236
237        // Persist to memory if available
238        if let Some(mem) = &self.memory {
239            mem.save_identity(&soul.name, &soul.to_identity())?;
240        }
241        Ok(())
242    }
243
244    fn get(&self, name: &str) -> Option<Soul> {
245        let path = self.find_agent_path(name)?;
246        read_soul_file(&path)
247    }
248
249    fn list(&self) -> Vec<Soul> {
250        let mut souls = Vec::new();
251        if let Ok(entries) = std::fs::read_dir(&self.agents_dir) {
252            for entry in entries.flatten() {
253                let path = entry.path();
254                if let Some(soul) = read_soul_file(&path) {
255                    souls.push(soul);
256                }
257            }
258        }
259        souls.sort_by(|a, b| a.name.cmp(&b.name));
260        souls
261    }
262
263    fn update(&self, name: &str, soul: &Soul) -> anyhow::Result<()> {
264        safe_agent_name(name)?;
265        safe_agent_name(&soul.name)?;
266        let path = self
267            .find_agent_path(name)
268            .ok_or_else(|| anyhow::anyhow!("Agent '{}' not found.", name))?;
269        let content = soul.to_toml()?;
270        std::fs::write(&path, content)?;
271
272        if let Some(mem) = &self.memory {
273            mem.save_identity(&soul.name, &soul.to_identity())?;
274        }
275        Ok(())
276    }
277
278    fn remove(&self, name: &str) -> anyhow::Result<()> {
279        if let Some(path) = self.find_agent_path(name) {
280            std::fs::remove_file(&path)?;
281        }
282        Ok(())
283    }
284}
285
286fn read_soul_file(path: &std::path::Path) -> Option<Soul> {
287    let content = std::fs::read_to_string(path).ok()?;
288    match path.extension().and_then(|e| e.to_str()) {
289        Some("toml") => Soul::from_toml(&content).ok(),
290        Some("md") => Soul::from_markdown_frontmatter(&content).ok(),
291        _ => None,
292    }
293}
294
295fn safe_agent_name(name: &str) -> anyhow::Result<&str> {
296    let trimmed = name.trim();
297    if trimmed.is_empty()
298        || trimmed.contains("..")
299        || trimmed.contains('/')
300        || trimmed.contains('\\')
301        || trimmed.contains(':')
302    {
303        anyhow::bail!("invalid agent name '{}'", name);
304    }
305    Ok(trimmed)
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn soul_toml_roundtrip_keeps_declarative_fields() {
314        let soul = Soul {
315            name: "reviewer".into(),
316            description: "Adversarial reviewer".into(),
317            role: "verifier".into(),
318            personality: "strict".into(),
319            tools: vec!["fs_read".into()],
320            disallowed_tools: vec!["fs_write".into()],
321            default_model: Some("nvidia:test".into()),
322            permission_mode: Some("read-only".into()),
323            max_turns: Some(8),
324            background: true,
325            color: Some("gold".into()),
326            ..Soul::default()
327        };
328        let encoded = soul.to_toml().unwrap();
329        let decoded = Soul::from_toml(&encoded).unwrap();
330        assert_eq!(decoded.name, "reviewer");
331        assert_eq!(decoded.disallowed_tools, vec!["fs_write"]);
332        assert_eq!(decoded.permission_mode.as_deref(), Some("read-only"));
333        assert_eq!(decoded.max_turns, Some(8));
334    }
335
336    #[test]
337    fn markdown_frontmatter_agent_is_parsed() {
338        let content = r#"---
339name: verifier
340description: Checks code
341role: verifier
342personality: adversarial
343tools: [fs_read, search]
344disallowed_tools: [fs_write, exec]
345model: nvidia/test
346permission_mode: read-only
347max_turns: 5
348background: true
349color: gold
350---
351Review every claim against evidence.
352"#;
353        let soul = Soul::from_markdown_frontmatter(content).unwrap();
354        assert_eq!(soul.name, "verifier");
355        assert_eq!(soul.tools, vec!["fs_read", "search"]);
356        assert_eq!(soul.disallowed_tools, vec!["fs_write", "exec"]);
357        assert_eq!(soul.default_model.as_deref(), Some("nvidia/test"));
358        assert_eq!(soul.permission_mode.as_deref(), Some("read-only"));
359        assert_eq!(soul.max_turns, Some(5));
360        assert!(soul.background);
361        assert!(soul.prompt.contains("Review every claim"));
362    }
363
364    #[test]
365    fn fs_agent_store_get_finds_agent_md() {
366        let dir = std::env::temp_dir().join(format!(
367            "sparrow-agent-md-{}",
368            std::time::SystemTime::now()
369                .duration_since(std::time::UNIX_EPOCH)
370                .unwrap()
371                .as_nanos()
372        ));
373        std::fs::create_dir_all(&dir).unwrap();
374
375        let md_content = "---\nname: scout\ndescription: Finds issues\nrole: verifier\npersonality: sharp\ntools: [fs_read]\n---\nBe thorough.";
376        std::fs::write(dir.join("scout.agent.md"), md_content).unwrap();
377
378        let store = FsAgentStore::new(dir.clone());
379
380        let found = store.get("scout");
381        assert!(found.is_some(), "get() should find .agent.md files");
382        let soul = found.unwrap();
383        assert_eq!(soul.name, "scout");
384        assert_eq!(soul.tools, vec!["fs_read"]);
385
386        let listed = store.list();
387        assert!(listed.iter().any(|s| s.name == "scout"));
388
389        store.remove("scout").unwrap();
390        assert!(store.get("scout").is_none());
391
392        let _ = std::fs::remove_dir_all(dir);
393    }
394
395    #[test]
396    fn fs_agent_store_rejects_path_traversal_names() {
397        let dir = std::env::temp_dir().join(format!(
398            "sparrow-agent-escape-{}",
399            std::time::SystemTime::now()
400                .duration_since(std::time::UNIX_EPOCH)
401                .unwrap()
402                .as_nanos()
403        ));
404        let store = FsAgentStore::new(dir.clone());
405        let soul = Soul {
406            name: "../outside".into(),
407            ..Soul::default()
408        };
409
410        assert!(store.create(&soul).is_err());
411        assert!(store.get("../outside").is_none());
412
413        let _ = std::fs::remove_dir_all(dir);
414    }
415}