Skip to main content

recursive/tools/
agent_defs.rs

1//! Custom agent definitions loaded from `.recursive/agents/**/*.md` files.
2//!
3//! Each markdown file has optional YAML frontmatter delimited by `---` at the
4//! start of the file.  The frontmatter declares:
5//!
6//! | Field            | Required | Description                              |
7//! |------------------|----------|------------------------------------------|
8//! | `name`           | yes      | Unique agent name (must match directory) |
9//! | `system_prompt`  | yes      | System prompt for the agent              |
10//! | `allowed_tools`  | no       | Array of tool names; empty = read-only   |
11//!
12//! After the closing `---`, the remaining markdown body is treated as
13//! human-readable documentation and is ignored by the runtime.
14//!
15//! Example (`code-reviewer.md`):
16//!
17//! ```markdown
18//! ---
19//! name: code-reviewer
20//! system_prompt: |
21//!   You are an expert code reviewer.  Examine diffs and flag:
22//!   - correctness issues
23//!   - performance concerns
24//!   - security vulnerabilities
25//! allowed_tools:
26//!   - Read
27//!   - Glob
28//!   - Grep
29//! ---
30//!
31//! # Code Reviewer
32//! Reviews pull requests for quality and security.
33//! ```
34//!
35//! # Resolution in `AgentTool`
36//!
37//! When a `manifest` entry includes a `definition` field (instead of — or
38//! in addition to — `system_prompt`), the definition is resolved from the
39//! loaded registry and merged with any inline overrides.  This lets
40//! callers write:
41//!
42//! ```json
43//! {
44//!   "manifest": {
45//!     "reviewer": { "definition": "code-reviewer" }
46//!   }
47//! }
48//! ```
49
50use crate::error::{Error, Result};
51use serde::Deserialize;
52use std::collections::HashMap;
53use std::path::Path;
54
55// ---------------------------------------------------------------------------
56// AgentDefinition
57// ---------------------------------------------------------------------------
58
59/// A single agent definition parsed from a `.recursive/agents/*.md` file.
60#[derive(Debug, Clone, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct AgentDefinition {
63    /// Unique name for this agent (must match the sub-directory name).
64    pub name: String,
65
66    /// The system prompt that defines the agent's role and behaviour.
67    pub system_prompt: String,
68
69    /// Optional tool allowlist.  Empty = default read-only set.
70    #[serde(default)]
71    pub allowed_tools: Vec<String>,
72}
73
74/// Used internally for the top-level YAML parsing so `serde_yaml_ng` can
75/// give us a flat `AgentDefinition` without requiring a nested key.
76#[derive(Debug, Clone, Deserialize)]
77struct AgentDefinitionRaw {
78    name: String,
79    system_prompt: String,
80    #[serde(default)]
81    allowed_tools: Vec<String>,
82}
83
84impl From<AgentDefinitionRaw> for AgentDefinition {
85    fn from(raw: AgentDefinitionRaw) -> Self {
86        Self {
87            name: raw.name,
88            system_prompt: raw.system_prompt,
89            allowed_tools: raw.allowed_tools,
90        }
91    }
92}
93
94// ---------------------------------------------------------------------------
95// AgentDefinitions — the registry
96// ---------------------------------------------------------------------------
97
98/// A read-only registry of named agent definitions loaded from disk.
99///
100/// Usage:
101///
102/// ```no_run
103/// # use std::path::Path;
104/// # use recursive::tools::agent_defs::AgentDefinitions;
105/// # fn foo() -> Result<(), Box<dyn std::error::Error>> {
106/// let defs = AgentDefinitions::load(Path::new("/workspace"))?;
107/// if let Some(d) = defs.get("code-reviewer") {
108///     println!("system_prompt = {}", d.system_prompt);
109/// }
110/// # Ok(())
111/// # }
112/// ```
113#[derive(Debug, Clone, Default)]
114pub struct AgentDefinitions {
115    /// Name → definition mapping.
116    definitions: HashMap<String, AgentDefinition>,
117}
118
119impl AgentDefinitions {
120    /// Walk `.recursive/agents/**/*.md`, parse each file, and return a
121    /// populated registry.  Returns an empty registry (not an error) when
122    /// the directory does not exist — missing dir is a silent no-op.
123    pub fn load(workspace_root: &Path) -> Result<Self> {
124        let agents_dir = workspace_root.join(".recursive").join("agents");
125        if !agents_dir.is_dir() {
126            return Ok(Self::default());
127        }
128
129        let mut definitions = HashMap::new();
130
131        for entry in walkdir::WalkDir::new(&agents_dir)
132            .follow_links(false)
133            .into_iter()
134            .filter_map(|e| e.ok())
135            .filter(|e| e.file_type().is_file())
136        {
137            let path = entry.path();
138            // Only process .md files
139            if path.extension().and_then(|s| s.to_str()) != Some("md") {
140                continue;
141            }
142
143            match Self::parse_file(path) {
144                Ok(def) => {
145                    if let Some(existing) = definitions.insert(def.name.clone(), def.clone()) {
146                        // Duplicate name — keep whichever file sorts first
147                        // (walkdir is deterministic), but warn.
148                        tracing::warn!(
149                            "Duplicate agent name '{}' — using {} (ignoring {})",
150                            def.name,
151                            path.display(),
152                            format!("{}/{}", agents_dir.display(), existing.name)
153                        );
154                    }
155                }
156                Err(e) => {
157                    // Skip files that fail to parse — this is permissive
158                    // by design so one bad file doesn't break everything.
159                    tracing::warn!("Skipping agent file {}: {e}", path.display());
160                }
161            }
162        }
163
164        Ok(Self { definitions })
165    }
166
167    /// Resolve a named definition.
168    pub fn get(&self, name: &str) -> Option<&AgentDefinition> {
169        self.definitions.get(name)
170    }
171
172    /// Number of loaded definitions.
173    pub fn len(&self) -> usize {
174        self.definitions.len()
175    }
176
177    /// Whether the registry is empty.
178    pub fn is_empty(&self) -> bool {
179        self.definitions.is_empty()
180    }
181
182    /// Iterate over all definitions.
183    pub fn iter(&self) -> impl Iterator<Item = (&String, &AgentDefinition)> {
184        self.definitions.iter()
185    }
186
187    // ------------------------------------------------------------------
188    // Internal helpers
189    // ------------------------------------------------------------------
190
191    /// Parse a single `.md` file, extracting YAML frontmatter.
192    fn parse_file(path: &Path) -> Result<AgentDefinition> {
193        let raw = std::fs::read_to_string(path).map_err(|e| Error::Config {
194            message: format!("Cannot read agent file {}: {e}", path.display()),
195        })?;
196
197        let frontmatter = Self::extract_frontmatter(&raw, path)?;
198        let def: AgentDefinitionRaw =
199            serde_yaml_ng::from_str(&frontmatter).map_err(|e| Error::Config {
200                message: format!("Invalid YAML frontmatter in {}: {e}", path.display()),
201            })?;
202
203        Ok(def.into())
204    }
205
206    /// Extract YAML frontmatter from a markdown string.
207    ///
208    /// Frontmatter must be delimited by `---` on its own line at the
209    /// very start of the file, with a closing `---` on its own line.
210    /// Returns the content between the delimiters.
211    fn extract_frontmatter(raw: &str, path: &Path) -> Result<String> {
212        let trimmed = raw.trim_start();
213        if !trimmed.starts_with("---") {
214            return Err(Error::Config {
215                message: format!(
216                    "Agent file {} has no YAML frontmatter (must start with ---)",
217                    path.display()
218                ),
219            });
220        }
221
222        // Skip the opening `---`
223        let after_open = &trimmed[3..];
224        // Find the closing `---` — it must be on a line by itself
225        let close_pos = after_open.find("\n---").or_else(|| {
226            // Also handle the case where `---` is followed by EOF
227            if after_open.starts_with("---") {
228                Some(0)
229            } else {
230                None
231            }
232        });
233
234        match close_pos {
235            Some(pos) => {
236                let content = &after_open[..pos].trim_end();
237                Ok(content.to_string())
238            }
239            None => Err(Error::Config {
240                message: format!(
241                    "Agent file {} has unclosed YAML frontmatter (missing closing ---)",
242                    path.display()
243                ),
244            }),
245        }
246    }
247}
248
249// ---------------------------------------------------------------------------
250// Tests
251// ---------------------------------------------------------------------------
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    // ------------------------------------------------------------------
258    // extract_frontmatter
259    // ------------------------------------------------------------------
260
261    #[test]
262    fn frontmatter_basic() {
263        let raw = "---\nname: test\nsystem_prompt: 'hello'\n---\n\n# Doc\n";
264        let front = AgentDefinitions::extract_frontmatter(raw, Path::new("test.md")).unwrap();
265        assert_eq!(front.trim(), "name: test\nsystem_prompt: 'hello'");
266    }
267
268    #[test]
269    fn frontmatter_multiline_prompt() {
270        let raw = concat!(
271            "---\n",
272            "name: reviewer\n",
273            "system_prompt: |\n",
274            "  Line one.\n",
275            "  Line two.\n",
276            "allowed_tools:\n",
277            "  - Read\n",
278            "  - Grep\n",
279            "---\n",
280            "\n",
281            "# Doc\n",
282        );
283        let front = AgentDefinitions::extract_frontmatter(raw, Path::new("test.md")).unwrap();
284        let def: AgentDefinitionRaw = serde_yaml_ng::from_str(&front).unwrap();
285        assert_eq!(def.name, "reviewer");
286        assert_eq!(def.system_prompt, "Line one.\nLine two.\n");
287        assert_eq!(def.allowed_tools, vec!["Read", "Grep"]);
288    }
289
290    #[test]
291    fn frontmatter_no_closing() {
292        let raw = "---\nname: test\nsystem_prompt: 'hi'\n";
293        let err = AgentDefinitions::extract_frontmatter(raw, Path::new("test.md")).unwrap_err();
294        let msg = format!("{err}");
295        assert!(msg.contains("unclosed"), "got: {msg}");
296    }
297
298    #[test]
299    fn frontmatter_missing_opening() {
300        let raw = "name: test\nsystem_prompt: 'hi'\n---\n";
301        let err = AgentDefinitions::extract_frontmatter(raw, Path::new("test.md")).unwrap_err();
302        let msg = format!("{err}");
303        assert!(msg.contains("no YAML frontmatter"), "got: {msg}");
304    }
305
306    // ------------------------------------------------------------------
307    // parse_file
308    // ------------------------------------------------------------------
309
310    #[test]
311    fn parse_full_file() {
312        let tmp = tempfile::tempdir().unwrap();
313        let md = tmp.path().join("test.md");
314        std::fs::write(
315            &md,
316            concat!(
317                "---\n",
318                "name: helper\n",
319                "system_prompt: 'You are helpful.'\n",
320                "allowed_tools:\n",
321                "  - Read\n",
322                "---\n",
323                "\n",
324                "# My Agent\n",
325                "Extra docs go here.\n",
326            ),
327        )
328        .unwrap();
329
330        let def = AgentDefinitions::parse_file(&md).unwrap();
331        assert_eq!(def.name, "helper");
332        assert_eq!(def.system_prompt, "You are helpful.");
333        assert_eq!(def.allowed_tools, vec!["Read"]);
334    }
335
336    #[test]
337    fn parse_file_no_frontmatter_is_error() {
338        let tmp = tempfile::tempdir().unwrap();
339        let md = tmp.path().join("test.md");
340        std::fs::write(&md, "# Just docs\nNo frontmatter.\n").unwrap();
341
342        let err = AgentDefinitions::parse_file(&md).unwrap_err();
343        let msg = format!("{err}");
344        assert!(msg.contains("no YAML frontmatter"), "got: {msg}");
345    }
346
347    #[test]
348    fn parse_file_invalid_yaml_is_config_error() {
349        // Delimiters are well-formed, but the YAML body itself is invalid.
350        // This exercises the changed `serde_yaml_ng::from_str` error path in
351        // `parse_file` (the `map_err` wrapping into `Error::Config`).
352        let tmp = tempfile::tempdir().unwrap();
353        let md = tmp.path().join("bad.yaml.md");
354        std::fs::write(
355            &md,
356            "---\nname: helper\nsystem_prompt: [unclosed flow\n---\n",
357        )
358        .unwrap();
359
360        let err = AgentDefinitions::parse_file(&md).unwrap_err();
361        let msg = format!("{err}");
362        assert!(
363            msg.contains("Invalid YAML frontmatter"),
364            "expected wrapped config error, got: {msg}"
365        );
366        assert!(
367            msg.contains("bad.yaml.md"),
368            "error should name the offending file, got: {msg}"
369        );
370        match err {
371            Error::Config { .. } => {}
372            other => panic!("expected Error::Config, got: {other:?}"),
373        }
374    }
375
376    // ------------------------------------------------------------------
377    // load (directory walking)
378    // ------------------------------------------------------------------
379
380    #[test]
381    fn load_empty_when_no_agents_dir() {
382        let tmp = tempfile::tempdir().unwrap();
383        // No .recursive/agents dir at all
384        let defs = AgentDefinitions::load(tmp.path()).unwrap();
385        assert!(defs.is_empty());
386    }
387
388    #[test]
389    fn load_skips_non_md_files() {
390        let tmp = tempfile::tempdir().unwrap();
391        let agents_dir = tmp.path().join(".recursive").join("agents");
392        std::fs::create_dir_all(&agents_dir).unwrap();
393
394        // Write a .txt file (should be skipped)
395        std::fs::write(agents_dir.join("notes.txt"), "junk").unwrap();
396
397        // Write a valid .md file
398        std::fs::write(
399            agents_dir.join("helper.md"),
400            "---\nname: helper\nsystem_prompt: 'You help.'\n---\n",
401        )
402        .unwrap();
403
404        let defs = AgentDefinitions::load(tmp.path()).unwrap();
405        assert_eq!(defs.len(), 1);
406        assert!(defs.get("helper").is_some());
407    }
408
409    #[test]
410    fn load_registry_reports_non_empty_and_iter_yields_definitions() {
411        let tmp = tempfile::tempdir().unwrap();
412        let agents_dir = tmp.path().join(".recursive").join("agents");
413        std::fs::create_dir_all(&agents_dir).unwrap();
414
415        std::fs::write(
416            agents_dir.join("helper.md"),
417            "---\nname: helper\nsystem_prompt: 'You help.'\n---\n",
418        )
419        .unwrap();
420
421        let defs = AgentDefinitions::load(tmp.path()).unwrap();
422        // Pins `is_empty()` returning false for a non-empty registry
423        // and `iter()` actually yielding the loaded definitions.
424        assert!(
425            !defs.is_empty(),
426            "registry with one agent must not be empty"
427        );
428        assert_eq!(defs.len(), 1);
429        let names: Vec<&String> = defs.iter().map(|(name, _)| name).collect();
430        assert_eq!(names, vec!["helper"]);
431        let prompts: Vec<&String> = defs.iter().map(|(_, def)| &def.system_prompt).collect();
432        assert_eq!(prompts, vec!["You help."]);
433    }
434
435    #[test]
436    fn load_multiple_agents_with_subdirs() {
437        let tmp = tempfile::tempdir().unwrap();
438        let agents_dir = tmp.path().join(".recursive").join("agents");
439        std::fs::create_dir_all(agents_dir.join("sub")).unwrap();
440
441        std::fs::write(
442            agents_dir.join("reviewer.md"),
443            "---\nname: reviewer\nsystem_prompt: 'Review code.'\n---\n",
444        )
445        .unwrap();
446
447        std::fs::write(
448            agents_dir.join("sub").join("tester.md"),
449            "---\nname: tester\nsystem_prompt: 'Run tests.'\nallowed_tools:\n  - Read\n---\n",
450        )
451        .unwrap();
452
453        let defs = AgentDefinitions::load(tmp.path()).unwrap();
454        assert_eq!(defs.len(), 2);
455
456        let reviewer = defs.get("reviewer").unwrap();
457        assert_eq!(reviewer.system_prompt, "Review code.");
458        assert!(reviewer.allowed_tools.is_empty());
459
460        let tester = defs.get("tester").unwrap();
461        assert_eq!(tester.system_prompt, "Run tests.");
462        assert_eq!(tester.allowed_tools, vec!["Read"]);
463    }
464
465    #[test]
466    fn load_handles_duplicate_names() {
467        let tmp = tempfile::tempdir().unwrap();
468        let agents_dir = tmp.path().join(".recursive").join("agents");
469        std::fs::create_dir_all(&agents_dir).unwrap();
470
471        std::fs::write(
472            agents_dir.join("a.md"),
473            "---\nname: dup\nsystem_prompt: 'First.'\n---\n",
474        )
475        .unwrap();
476        std::fs::write(
477            agents_dir.join("b.md"),
478            "---\nname: dup\nsystem_prompt: 'Second.'\n---\n",
479        )
480        .unwrap();
481
482        let defs = AgentDefinitions::load(tmp.path()).unwrap();
483        // Should still have 1 entry (first wins)
484        // Actually due to WalkDir ordering, a.md or b.md could be first.
485        assert_eq!(defs.len(), 1);
486        // We don't assert which one wins since WalkDir order is platform-dependent
487    }
488
489    #[test]
490    fn load_skips_bad_files_without_crashing() {
491        let tmp = tempfile::tempdir().unwrap();
492        let agents_dir = tmp.path().join(".recursive").join("agents");
493        std::fs::create_dir_all(&agents_dir).unwrap();
494
495        // Bad file (no frontmatter)
496        std::fs::write(agents_dir.join("bad.md"), "just text\n").unwrap();
497
498        // Good file
499        std::fs::write(
500            agents_dir.join("good.md"),
501            "---\nname: good\nsystem_prompt: 'works'\n---\n",
502        )
503        .unwrap();
504
505        let defs = AgentDefinitions::load(tmp.path()).unwrap();
506        assert_eq!(defs.len(), 1);
507        assert!(defs.get("good").is_some());
508    }
509}