1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use crate::engine::Identity;
6use crate::memory::Memory;
7
8#[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
172pub 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
182pub 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 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}