Skip to main content

nika_engine/ast/
loader.rs

1//! Multi-format loader for agents and skills
2//!
3//! Supports loading agent and skill definitions from multiple formats:
4//! - `.agent.yaml` / `.agent.yml` - YAML format
5//! - `.skill.yaml` / `.skill.yml` - YAML format
6//! - `.md` files with YAML frontmatter (Claude Code style)
7//! - Folders containing the above
8//!
9//! # Examples
10//!
11//! ```yaml
12//! # In workflow
13//! agents:
14//!   researcher:
15//!     from: ./agents/researcher          # Folder or file (auto-detect)
16//!   helper:
17//!     from: ./agents/helper.agent.yaml   # Explicit YAML
18//!   reviewer:
19//!     from: ./agents/reviewer.md         # Markdown with frontmatter
20//! ```
21
22use std::path::{Path, PathBuf};
23
24use serde::Deserialize;
25
26use crate::error::NikaError;
27use crate::serde_yaml;
28
29/// Parsed agent/skill definition from any format
30#[derive(Debug, Clone)]
31pub struct LoadedDefinition {
32    /// Name of the agent/skill (from filename or frontmatter)
33    pub name: String,
34
35    /// Description (from frontmatter or first paragraph)
36    pub description: Option<String>,
37
38    /// System prompt / instructions (body content)
39    pub system: String,
40
41    /// Provider (claude, openai, etc.)
42    pub provider: Option<String>,
43
44    /// Model override
45    pub model: Option<String>,
46
47    /// Maximum turns for agent loops
48    pub max_turns: Option<u32>,
49
50    /// Temperature for generation
51    pub temperature: Option<f32>,
52
53    /// Source path (for debugging)
54    pub source_path: PathBuf,
55}
56
57/// YAML frontmatter structure (Claude Code style)
58#[derive(Debug, Deserialize)]
59struct Frontmatter {
60    name: Option<String>,
61    description: Option<String>,
62    provider: Option<String>,
63    model: Option<String>,
64    max_turns: Option<u32>,
65    temperature: Option<f32>,
66}
67
68/// YAML-only definition format
69#[derive(Debug, Deserialize)]
70struct YamlDefinition {
71    name: Option<String>,
72    description: Option<String>,
73    system: String,
74    provider: Option<String>,
75    model: Option<String>,
76    max_turns: Option<u32>,
77    temperature: Option<f32>,
78}
79
80/// Definition kind for detection
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum DefinitionKind {
83    Agent,
84    Skill,
85}
86
87impl DefinitionKind {
88    /// Get file extensions for this kind
89    pub fn extensions(&self) -> &[&str] {
90        match self {
91            DefinitionKind::Agent => &[".agent.yaml", ".agent.yml", ".md"],
92            DefinitionKind::Skill => &[".skill.yaml", ".skill.yml", ".md"],
93        }
94    }
95
96    /// Get standard filename for this kind
97    pub fn standard_filename(&self) -> &str {
98        match self {
99            DefinitionKind::Agent => "AGENT.md",
100            DefinitionKind::Skill => "SKILL.md",
101        }
102    }
103}
104
105/// Load a definition from a path (file or folder)
106///
107/// Auto-detects format based on extension or folder contents.
108pub fn load_definition(path: &Path, kind: DefinitionKind) -> Result<LoadedDefinition, NikaError> {
109    if path.is_dir() {
110        load_from_folder(path, kind)
111    } else if path.is_file() {
112        load_from_file(path, kind)
113    } else {
114        // Try adding extensions
115        try_load_with_extensions(path, kind)
116    }
117}
118
119/// Try loading with various extensions
120fn try_load_with_extensions(
121    path: &Path,
122    kind: DefinitionKind,
123) -> Result<LoadedDefinition, NikaError> {
124    let base = path.to_string_lossy();
125
126    for ext in kind.extensions() {
127        let with_ext = PathBuf::from(format!("{}{}", base, ext));
128        if with_ext.is_file() {
129            return load_from_file(&with_ext, kind);
130        }
131    }
132
133    // Try as folder
134    if path.is_dir() {
135        return load_from_folder(path, kind);
136    }
137
138    // Try without extension (might be folder)
139    let as_folder = path.to_path_buf();
140    if as_folder.is_dir() {
141        return load_from_folder(&as_folder, kind);
142    }
143
144    Err(NikaError::WorkflowNotFound {
145        path: path.to_string_lossy().to_string(),
146    })
147}
148
149/// Load definition from a folder
150fn load_from_folder(path: &Path, kind: DefinitionKind) -> Result<LoadedDefinition, NikaError> {
151    // Look for standard file (AGENT.md or SKILL.md)
152    let standard = path.join(kind.standard_filename());
153    if standard.is_file() {
154        return load_from_file(&standard, kind);
155    }
156
157    // Look for any matching file in folder
158    if let Ok(entries) = std::fs::read_dir(path) {
159        for entry in entries.flatten() {
160            let entry_path = entry.path();
161            if entry_path.is_file() {
162                let filename = entry_path
163                    .file_name()
164                    .and_then(|s| s.to_str())
165                    .unwrap_or("");
166                for ext in kind.extensions() {
167                    if filename.ends_with(ext) {
168                        return load_from_file(&entry_path, kind);
169                    }
170                }
171            }
172        }
173    }
174
175    // Fallback: look for index files
176    for name in &["index.md", "README.md", "main.md"] {
177        let index = path.join(name);
178        if index.is_file() {
179            return load_from_file(&index, kind);
180        }
181    }
182
183    Err(NikaError::WorkflowNotFound {
184        path: path.to_string_lossy().to_string(),
185    })
186}
187
188/// Load definition from a file
189fn load_from_file(path: &Path, kind: DefinitionKind) -> Result<LoadedDefinition, NikaError> {
190    let content = std::fs::read_to_string(path).map_err(|_| NikaError::WorkflowNotFound {
191        path: path.to_string_lossy().to_string(),
192    })?;
193
194    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
195
196    match ext {
197        "yaml" | "yml" => parse_yaml(&content, path, kind),
198        "md" => parse_markdown(&content, path, kind),
199        _ => {
200            // Try markdown first (more common), then YAML
201            parse_markdown(&content, path, kind).or_else(|_| parse_yaml(&content, path, kind))
202        }
203    }
204}
205
206/// Parse YAML format definition
207fn parse_yaml(
208    content: &str,
209    path: &Path,
210    _kind: DefinitionKind,
211) -> Result<LoadedDefinition, NikaError> {
212    let def: YamlDefinition = serde_yaml::from_str(content).map_err(|e| NikaError::ParseError {
213        details: format!("{}: {}", path.display(), e),
214    })?;
215
216    let name = def.name.unwrap_or_else(|| extract_name_from_path(path));
217
218    Ok(LoadedDefinition {
219        name,
220        description: def.description,
221        system: def.system,
222        provider: def.provider,
223        model: def.model,
224        max_turns: def.max_turns,
225        temperature: def.temperature,
226        source_path: path.to_path_buf(),
227    })
228}
229
230/// Parse Markdown with YAML frontmatter (Claude Code style)
231fn parse_markdown(
232    content: &str,
233    path: &Path,
234    _kind: DefinitionKind,
235) -> Result<LoadedDefinition, NikaError> {
236    let (frontmatter, body) = extract_frontmatter(content)?;
237
238    let fm: Frontmatter = if let Some(fm_str) = frontmatter {
239        serde_yaml::from_str(&fm_str).map_err(|e| NikaError::ParseError {
240            details: format!("{}: Invalid frontmatter: {}", path.display(), e),
241        })?
242    } else {
243        Frontmatter {
244            name: None,
245            description: None,
246            provider: None,
247            model: None,
248            max_turns: None,
249            temperature: None,
250        }
251    };
252
253    let name = fm.name.unwrap_or_else(|| extract_name_from_path(path));
254
255    let description = fm.description.or_else(|| extract_first_paragraph(&body));
256
257    Ok(LoadedDefinition {
258        name,
259        description,
260        system: body.trim().to_string(),
261        provider: fm.provider,
262        model: fm.model,
263        max_turns: fm.max_turns,
264        temperature: fm.temperature,
265        source_path: path.to_path_buf(),
266    })
267}
268
269/// Extract YAML frontmatter from markdown content
270fn extract_frontmatter(content: &str) -> Result<(Option<String>, String), NikaError> {
271    let content = content.trim_start();
272
273    if !content.starts_with("---") {
274        return Ok((None, content.to_string()));
275    }
276
277    // Find end of frontmatter
278    let after_start = &content[3..];
279    if let Some(end_pos) = after_start.find("\n---") {
280        let frontmatter = after_start[..end_pos].trim().to_string();
281        let body = after_start[end_pos + 4..].trim().to_string();
282        Ok((Some(frontmatter), body))
283    } else {
284        Err(NikaError::ParseError {
285            details: "Unterminated YAML frontmatter (missing closing ---)".to_string(),
286        })
287    }
288}
289
290/// Extract name from file path
291fn extract_name_from_path(path: &Path) -> String {
292    let stem = path
293        .file_stem()
294        .and_then(|s| s.to_str())
295        .unwrap_or("unknown");
296
297    // Remove common suffixes
298    let name = stem
299        .strip_suffix(".agent")
300        .or_else(|| stem.strip_suffix(".skill"))
301        .unwrap_or(stem);
302
303    // Handle standard names
304    if name.eq_ignore_ascii_case("agent") || name.eq_ignore_ascii_case("skill") {
305        // Use parent folder name
306        path.parent()
307            .and_then(|p| p.file_name())
308            .and_then(|s| s.to_str())
309            .unwrap_or(name)
310            .to_string()
311    } else {
312        name.to_string()
313    }
314}
315
316/// Extract first paragraph as description
317fn extract_first_paragraph(content: &str) -> Option<String> {
318    let content = content.trim();
319    if content.is_empty() {
320        return None;
321    }
322
323    // Skip any headers at the start
324    let content = content
325        .lines()
326        .skip_while(|l| l.starts_with('#') || l.is_empty())
327        .collect::<Vec<_>>()
328        .join("\n");
329
330    // Take until blank line
331    let paragraph: String = content
332        .lines()
333        .take_while(|l| !l.is_empty())
334        .collect::<Vec<_>>()
335        .join(" ");
336
337    if paragraph.is_empty() {
338        None
339    } else {
340        Some(paragraph)
341    }
342}
343
344/// Discover all agents/skills in a directory
345pub fn discover_definitions(
346    dir: &Path,
347    kind: DefinitionKind,
348) -> Result<Vec<LoadedDefinition>, NikaError> {
349    if !dir.is_dir() {
350        return Ok(vec![]);
351    }
352
353    let mut definitions = Vec::new();
354
355    let entries = std::fs::read_dir(dir).map_err(|_| NikaError::WorkflowNotFound {
356        path: dir.to_string_lossy().to_string(),
357    })?;
358
359    for entry in entries.flatten() {
360        let path = entry.path();
361
362        // Try to load as definition — warn on parse failures so users know
363        match load_definition(&path, kind) {
364            Ok(def) => definitions.push(def),
365            Err(e) => {
366                tracing::warn!(
367                    path = %path.display(),
368                    error = %e,
369                    "Skipping unparseable definition file"
370                );
371            }
372        }
373    }
374
375    Ok(definitions)
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn test_extract_frontmatter_with_frontmatter() {
384        let content = r#"---
385name: test-agent
386description: A test agent
387model: claude
388---
389
390This is the body content.
391"#;
392        let (fm, body) = extract_frontmatter(content).unwrap();
393        assert!(fm.is_some());
394        let fm = fm.unwrap();
395        assert!(fm.contains("name: test-agent"));
396        assert!(body.contains("This is the body content"));
397    }
398
399    #[test]
400    fn test_extract_frontmatter_without_frontmatter() {
401        let content = "Just plain markdown content.";
402        let (fm, body) = extract_frontmatter(content).unwrap();
403        assert!(fm.is_none());
404        assert_eq!(body, content);
405    }
406
407    #[test]
408    fn test_extract_frontmatter_unterminated() {
409        let content = "---\nname: test\nNo closing delimiter";
410        let result = extract_frontmatter(content);
411        assert!(result.is_err());
412    }
413
414    #[test]
415    fn test_extract_name_from_path_agent_yaml() {
416        let path = Path::new("/foo/bar/researcher.agent.yaml");
417        assert_eq!(extract_name_from_path(path), "researcher");
418    }
419
420    #[test]
421    fn test_extract_name_from_path_skill_yaml() {
422        let path = Path::new("/foo/bar/brainstorm.skill.yaml");
423        assert_eq!(extract_name_from_path(path), "brainstorm");
424    }
425
426    #[test]
427    fn test_extract_name_from_path_md() {
428        let path = Path::new("/foo/bar/reviewer.md");
429        assert_eq!(extract_name_from_path(path), "reviewer");
430    }
431
432    #[test]
433    fn test_extract_name_from_path_standard_file() {
434        let path = Path::new("/foo/my-agent/AGENT.md");
435        assert_eq!(extract_name_from_path(path), "my-agent");
436    }
437
438    #[test]
439    fn test_extract_first_paragraph() {
440        let content = r#"# Header
441
442First paragraph content here.
443More of the same paragraph.
444
445Second paragraph.
446"#;
447        let result = extract_first_paragraph(content);
448        assert_eq!(
449            result,
450            Some("First paragraph content here. More of the same paragraph.".to_string())
451        );
452    }
453
454    #[test]
455    fn test_extract_first_paragraph_empty() {
456        assert_eq!(extract_first_paragraph(""), None);
457        assert_eq!(extract_first_paragraph("# Just a header"), None);
458    }
459
460    #[test]
461    fn test_definition_kind_extensions() {
462        assert!(DefinitionKind::Agent.extensions().contains(&".agent.yaml"));
463        assert!(DefinitionKind::Agent.extensions().contains(&".md"));
464        assert!(DefinitionKind::Skill.extensions().contains(&".skill.yaml"));
465    }
466
467    #[test]
468    fn test_definition_kind_standard_filename() {
469        assert_eq!(DefinitionKind::Agent.standard_filename(), "AGENT.md");
470        assert_eq!(DefinitionKind::Skill.standard_filename(), "SKILL.md");
471    }
472
473    #[test]
474    fn test_parse_yaml_definition() {
475        let yaml = r#"
476name: test-agent
477description: A test agent
478system: You are a helpful assistant.
479provider: claude
480model: claude-sonnet-4-6
481max_turns: 5
482"#;
483        let path = Path::new("test.agent.yaml");
484        let def = parse_yaml(yaml, path, DefinitionKind::Agent).unwrap();
485
486        assert_eq!(def.name, "test-agent");
487        assert_eq!(def.description, Some("A test agent".to_string()));
488        assert_eq!(def.system, "You are a helpful assistant.");
489        assert_eq!(def.provider, Some("claude".to_string()));
490        assert_eq!(def.model, Some("claude-sonnet-4-6".to_string()));
491        assert_eq!(def.max_turns, Some(5));
492    }
493
494    #[test]
495    fn test_parse_markdown_with_frontmatter() {
496        let md = r#"---
497name: code-reviewer
498description: Reviews code quality
499model: sonnet
500---
501
502You are a Senior Code Reviewer with expertise in software architecture.
503
504Your role is to review completed project steps.
505"#;
506        let path = Path::new("code-reviewer.md");
507        let def = parse_markdown(md, path, DefinitionKind::Agent).unwrap();
508
509        assert_eq!(def.name, "code-reviewer");
510        assert_eq!(def.description, Some("Reviews code quality".to_string()));
511        assert!(def.system.contains("Senior Code Reviewer"));
512        assert_eq!(def.model, Some("sonnet".to_string()));
513    }
514
515    #[test]
516    fn test_parse_markdown_without_frontmatter() {
517        let md = "You are a simple assistant.\n\nHelp the user.";
518        let path = Path::new("simple-agent.md");
519        let def = parse_markdown(md, path, DefinitionKind::Agent).unwrap();
520
521        assert_eq!(def.name, "simple-agent");
522        assert!(def.system.contains("simple assistant"));
523    }
524
525    #[test]
526    fn test_loaded_definition_has_source_path() {
527        let md = "---\nname: test\n---\nBody";
528        let path = Path::new("/foo/bar/test.md");
529        let def = parse_markdown(md, path, DefinitionKind::Agent).unwrap();
530
531        assert_eq!(def.source_path, path);
532    }
533}