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