Skip to main content

pawan/agent/
definitions.rs

1//! Agent definition loading and parsing (YAML frontmatter + Markdown body).
2//!
3//! File format:
4//! - Optional frontmatter delimited by `---` lines at the start of the file
5//! - Markdown body after the closing delimiter becomes the agent system prompt
6//!
7//! We intentionally support a small, JSON-compatible subset of YAML in frontmatter
8//! to avoid adding a hard dependency on `serde_yaml`.
9
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use tracing::warn;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ThinkingLevel {
18    Minimal,
19    Low,
20    Medium,
21    High,
22}
23
24impl ThinkingLevel {
25    fn parse(s: &str) -> Option<Self> {
26        match s.trim().to_ascii_lowercase().as_str() {
27            "minimal" => Some(Self::Minimal),
28            "low" => Some(Self::Low),
29            "medium" => Some(Self::Medium),
30            "high" => Some(Self::High),
31            _ => None,
32        }
33    }
34}
35
36impl Default for ThinkingLevel {
37    fn default() -> Self {
38        Self::Minimal
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct AgentDefinition {
44    pub name: String,                  // "explore", "plan", etc.
45    pub description: String,           // what this agent does
46    pub tools: Vec<String>,            // allowed tool names (or ["*"] for all)
47    pub model_pattern: String,         // model filter ("*" = any, "claude*" = claude only)
48    pub thinking_level: ThinkingLevel, // minimal/low/medium/high
49    pub blocking: bool,                // wait for result before continuing
50    pub spawns: Vec<String>,           // agent types this can spawn (["*"] or ["explore", "task"])
51    pub system_prompt: String,         // the markdown body
52    pub max_turns: u32,                // max tool-calling turns
53}
54
55impl AgentDefinition {
56    pub fn defaults_with_name(name: impl Into<String>, system_prompt: impl Into<String>) -> Self {
57        Self {
58            name: name.into(),
59            description: String::new(),
60            tools: vec!["*".to_string()],
61            model_pattern: "*".to_string(),
62            thinking_level: ThinkingLevel::default(),
63            blocking: true,
64            spawns: Vec::new(),
65            system_prompt: system_prompt.into(),
66            max_turns: 15,
67        }
68    }
69}
70
71pub struct AgentRegistry {
72    definitions: Vec<AgentDefinition>,
73}
74
75impl AgentRegistry {
76    /// Load bundled + user + project agents.
77    ///
78    /// Duplicate resolution: project > user > bundled.
79    pub fn load() -> Self {
80        let mut map: HashMap<String, AgentDefinition> = HashMap::new();
81
82        for def in bundled_agent_definitions() {
83            map.insert(def.name.clone(), def);
84        }
85
86        for def in load_dir_agents(user_agents_dir()) {
87            map.insert(def.name.clone(), def);
88        }
89
90        for def in load_dir_agents(project_agents_dir()) {
91            map.insert(def.name.clone(), def);
92        }
93
94        let mut definitions: Vec<AgentDefinition> = map.into_values().collect();
95        definitions.sort_by(|a, b| a.name.cmp(&b.name));
96        Self { definitions }
97    }
98
99    pub fn find(&self, name: &str) -> Option<&AgentDefinition> {
100        self.definitions.iter().find(|d| d.name == name)
101    }
102
103    #[cfg(test)]
104    fn names(&self) -> Vec<String> {
105        self.definitions.iter().map(|d| d.name.clone()).collect()
106    }
107}
108
109fn bundled_agent_definitions() -> Vec<AgentDefinition> {
110    // Keep this tiny; it only exists so the registry always has something.
111    const EXPLORE_MD: &str = r#"---
112name: explore
113description: Fast read-only codebase scout
114tools: ["read", "grep", "find"]
115model: "*"
116thinking: minimal
117blocking: true
118spawns: []
119max_turns: 15
120---
121
122You are a code exploration agent. Focus on quickly mapping a codebase and returning
123compact, high-signal context for another agent to act on.
124"#;
125
126    match parse_agent_markdown("explore.md", EXPLORE_MD) {
127        Ok(def) => vec![def],
128        Err(e) => {
129            warn!("Bundled agent explore failed to parse: {e}");
130            vec![]
131        }
132    }
133}
134
135fn user_agents_dir() -> Option<PathBuf> {
136    dirs::home_dir().map(|h| h.join(".pawan").join("agents"))
137}
138
139fn project_agents_dir() -> Option<PathBuf> {
140    std::env::current_dir()
141        .ok()
142        .map(|d| d.join(".pawan").join("agents"))
143}
144
145fn load_dir_agents(dir: Option<PathBuf>) -> Vec<AgentDefinition> {
146    let Some(dir) = dir else { return vec![] };
147    let Ok(read_dir) = fs::read_dir(&dir) else {
148        return vec![];
149    };
150
151    let mut out = Vec::new();
152    for entry in read_dir.flatten() {
153        let path = entry.path();
154        if path.extension().and_then(|e| e.to_str()) != Some("md") {
155            continue;
156        }
157        match fs::read_to_string(&path) {
158            Ok(content) => match parse_agent_markdown(
159                path.file_name()
160                    .and_then(|s| s.to_str())
161                    .unwrap_or("agent.md"),
162                &content,
163            ) {
164                Ok(def) => out.push(def),
165                Err(e) => warn!("Skipping agent file {}: {e}", path.display()),
166            },
167            Err(e) => warn!("Skipping unreadable agent file {}: {e}", path.display()),
168        }
169    }
170    out
171}
172
173#[derive(Default, Debug, Clone)]
174struct Frontmatter {
175    name: Option<String>,
176    description: Option<String>,
177    tools: Option<Vec<String>>,
178    model: Option<String>,
179    thinking: Option<ThinkingLevel>,
180    blocking: Option<bool>,
181    spawns: Option<Vec<String>>,
182    max_turns: Option<u32>,
183}
184
185pub fn parse_agent_markdown(file_name: &str, content: &str) -> Result<AgentDefinition, String> {
186    let (frontmatter, body) = split_frontmatter(content);
187
188    let stem_name = Path::new(file_name)
189        .file_stem()
190        .and_then(|s| s.to_str())
191        .unwrap_or("unknown")
192        .to_string();
193
194    let Some(front) = frontmatter else {
195        return Ok(AgentDefinition::defaults_with_name(stem_name, body));
196    };
197
198    let fm = parse_frontmatter_kv(&front).map_err(|e| format!("invalid frontmatter: {e}"))?;
199
200    let name = fm.name.unwrap_or(stem_name);
201    let mut def = AgentDefinition::defaults_with_name(name, body);
202
203    if let Some(d) = fm.description {
204        def.description = d;
205    }
206    if let Some(t) = fm.tools {
207        def.tools = t;
208    }
209    if let Some(m) = fm.model {
210        def.model_pattern = m;
211    }
212    if let Some(th) = fm.thinking {
213        def.thinking_level = th;
214    }
215    if let Some(b) = fm.blocking {
216        def.blocking = b;
217    }
218    if let Some(s) = fm.spawns {
219        def.spawns = s;
220    }
221    if let Some(mt) = fm.max_turns {
222        def.max_turns = mt;
223    }
224
225    Ok(def)
226}
227
228fn split_frontmatter(content: &str) -> (Option<String>, String) {
229    let mut lines = content.lines();
230    let Some(first) = lines.next() else {
231        return (None, String::new());
232    };
233    if first.trim() != "---" {
234        return (None, content.to_string());
235    }
236
237    let mut front = Vec::new();
238    let mut found_end = false;
239    for line in lines.by_ref() {
240        if line.trim() == "---" {
241            found_end = true;
242            break;
243        }
244        front.push(line);
245    }
246
247    if !found_end {
248        // Malformed frontmatter; treat as missing.
249        return (None, content.to_string());
250    }
251
252    let body = lines.collect::<Vec<_>>().join("\n");
253    (
254        Some(front.join("\n")),
255        body.trim_start_matches('\n').to_string(),
256    )
257}
258
259fn parse_frontmatter_kv(front: &str) -> Result<Frontmatter, String> {
260    let mut fm = Frontmatter::default();
261
262    for (idx, raw) in front.lines().enumerate() {
263        let line = raw.trim();
264        if line.is_empty() || line.starts_with('#') {
265            continue;
266        }
267        let (k, v) = line
268            .split_once(':')
269            .ok_or_else(|| format!("line {} missing ':'", idx + 1))?;
270        let key = k.trim().to_ascii_lowercase();
271        let val = v.trim();
272
273        match key.as_str() {
274            "name" => fm.name = Some(parse_string(val)?),
275            "description" => fm.description = Some(parse_string(val)?),
276            "tools" => fm.tools = Some(parse_string_list(val)?),
277            "model" => fm.model = Some(parse_string(val)?),
278            "thinking" => {
279                let s = parse_string(val)?;
280                fm.thinking = ThinkingLevel::parse(&s).or_else(|| ThinkingLevel::parse(val));
281                if fm.thinking.is_none() {
282                    return Err(format!("invalid thinking level: {val}"));
283                }
284            }
285            "blocking" => fm.blocking = Some(parse_bool(val)?),
286            "spawns" => fm.spawns = Some(parse_string_list(val)?),
287            "max_turns" => fm.max_turns = Some(parse_u32(val)?),
288            _ => {
289                // Ignore unknown keys for forward compatibility.
290            }
291        }
292    }
293
294    Ok(fm)
295}
296
297fn parse_string(s: &str) -> Result<String, String> {
298    let t = s.trim();
299    if t.starts_with('"') && t.ends_with('"') && t.len() >= 2 {
300        return Ok(t[1..t.len() - 1].to_string());
301    }
302    if t.starts_with('\'') && t.ends_with('\'') && t.len() >= 2 {
303        return Ok(t[1..t.len() - 1].to_string());
304    }
305    Ok(t.to_string())
306}
307
308fn parse_bool(s: &str) -> Result<bool, String> {
309    match s.trim().to_ascii_lowercase().as_str() {
310        "true" => Ok(true),
311        "false" => Ok(false),
312        _ => Err(format!("invalid bool: {s}")),
313    }
314}
315
316fn parse_u32(s: &str) -> Result<u32, String> {
317    s.trim()
318        .parse::<u32>()
319        .map_err(|e| format!("invalid u32: {e}"))
320}
321
322fn parse_string_list(s: &str) -> Result<Vec<String>, String> {
323    // We support JSON-style arrays since that's also YAML-valid: ["a", "b"] or [].
324    let t = s.trim();
325    if !t.starts_with('[') {
326        return Err("expected JSON-style array (e.g. [\"read\", \"grep\"])".to_string());
327    }
328
329    let v: serde_json::Value =
330        serde_json::from_str(t).map_err(|e| format!("invalid array syntax: {e}"))?;
331    let arr = v.as_array().ok_or_else(|| "expected array".to_string())?;
332
333    let mut out = Vec::with_capacity(arr.len());
334    for el in arr {
335        let s = el
336            .as_str()
337            .ok_or_else(|| "array elements must be strings".to_string())?;
338        out.push(s.to_string());
339    }
340
341    Ok(out)
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn parse_valid_agent_definition() {
350        let md = r#"---
351name: explore
352description: Fast read-only codebase scout
353tools: ["read", "grep", "find"]
354model: "*"
355thinking: minimal
356blocking: true
357spawns: []
358max_turns: 15
359---
360
361You are a code exploration agent...
362"#;
363
364        let def = parse_agent_markdown("explore.md", md).expect("parse");
365        assert_eq!(def.name, "explore");
366        assert_eq!(def.description, "Fast read-only codebase scout");
367        assert_eq!(def.tools, vec!["read", "grep", "find"]);
368        assert_eq!(def.model_pattern, "*");
369        assert_eq!(def.thinking_level, ThinkingLevel::Minimal);
370        assert!(def.blocking);
371        assert_eq!(def.spawns, Vec::<String>::new());
372        assert_eq!(def.max_turns, 15);
373        assert!(def
374            .system_prompt
375            .contains("You are a code exploration agent"));
376    }
377
378    #[test]
379    fn parse_missing_frontmatter_uses_defaults() {
380        let md = "System prompt only.\nSecond line.\n";
381        let def = parse_agent_markdown("custom.md", md).expect("parse");
382        assert_eq!(def.name, "custom");
383        assert_eq!(def.tools, vec!["*"]);
384        assert_eq!(def.model_pattern, "*");
385        assert_eq!(def.thinking_level, ThinkingLevel::Minimal);
386        assert!(def.blocking);
387        assert_eq!(def.spawns, Vec::<String>::new());
388        assert_eq!(def.max_turns, 15);
389        assert!(def.system_prompt.contains("System prompt only."));
390    }
391
392    #[test]
393    fn registry_loads_bundled_agents() {
394        let reg = AgentRegistry::load();
395        assert!(reg.find("explore").is_some());
396        assert!(reg.names().contains(&"explore".to_string()));
397    }
398}