1use crate::error::{Error, Result};
51use serde::Deserialize;
52use std::collections::HashMap;
53use std::path::Path;
54
55#[derive(Debug, Clone, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct AgentDefinition {
63 pub name: String,
65
66 pub system_prompt: String,
68
69 #[serde(default)]
71 pub allowed_tools: Vec<String>,
72}
73
74#[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#[derive(Debug, Clone, Default)]
114pub struct AgentDefinitions {
115 definitions: HashMap<String, AgentDefinition>,
117}
118
119impl AgentDefinitions {
120 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 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 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 tracing::warn!("Skipping agent file {}: {e}", path.display());
160 }
161 }
162 }
163
164 Ok(Self { definitions })
165 }
166
167 pub fn get(&self, name: &str) -> Option<&AgentDefinition> {
169 self.definitions.get(name)
170 }
171
172 pub fn len(&self) -> usize {
174 self.definitions.len()
175 }
176
177 pub fn is_empty(&self) -> bool {
179 self.definitions.is_empty()
180 }
181
182 pub fn iter(&self) -> impl Iterator<Item = (&String, &AgentDefinition)> {
184 self.definitions.iter()
185 }
186
187 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 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 let after_open = &trimmed[3..];
224 let close_pos = after_open.find("\n---").or_else(|| {
226 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#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[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 #[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 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 #[test]
381 fn load_empty_when_no_agents_dir() {
382 let tmp = tempfile::tempdir().unwrap();
383 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 std::fs::write(agents_dir.join("notes.txt"), "junk").unwrap();
396
397 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 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 assert_eq!(defs.len(), 1);
486 }
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 std::fs::write(agents_dir.join("bad.md"), "just text\n").unwrap();
497
498 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}