Skip to main content

nika_core/ast/
agent_def.rs

1//! Agent definition types for workflow
2//!
3//! The `agents:` block in a workflow allows defining reusable agent configurations.
4//! Agents can be defined inline or loaded from external files.
5//!
6//! # Multi-format Support
7//!
8//! Agents and skills can be loaded from multiple formats:
9//! - `.agent.yaml` / `.agent.yml` - YAML format
10//! - `.skill.yaml` / `.skill.yml` - YAML format
11//! - `.md` files with YAML frontmatter (Claude Code style)
12//! - Folders containing the above
13//!
14//! # Example
15//!
16//! ```yaml
17//! agents:
18//!   researcher:
19//!     from: ./agents/researcher           # Auto-detect format (folder or file)
20//!   helper:
21//!     file: ./agents/helper.agent.yaml    # Explicit YAML
22//!   reviewer:
23//!     from: ./agents/reviewer.md          # Markdown with frontmatter
24//!   translator:
25//!     system: "You are a translator..."   # Inline definition
26//!     provider: claude
27//!     model: claude-sonnet-4-6
28//!     max_turns: 3
29//! ```
30
31use serde::Deserialize;
32
33/// Agent definition
34///
35/// Can be either an external file/folder reference or an inline definition.
36/// Adds support for `from:` which auto-detects format.
37#[derive(Debug, Clone, Deserialize)]
38#[serde(untagged)]
39pub enum AgentDef {
40    /// External reference using `from:`
41    From {
42        /// Path to agent definition (file or folder, any supported format)
43        from: String,
44    },
45
46    /// External file reference using `file:`
47    External {
48        /// Path to the agent definition file (.agent.yaml)
49        file: String,
50    },
51
52    /// Inline definition
53    Inline {
54        /// System prompt for the agent
55        system: String,
56
57        /// Provider to use (claude, openai, etc.)
58        #[serde(default = "default_provider")]
59        provider: String,
60
61        /// Model to use (optional, uses provider default if not specified)
62        model: Option<String>,
63
64        /// Maximum turns for the agent (optional)
65        max_turns: Option<u32>,
66
67        /// Temperature for generation (optional)
68        temperature: Option<f32>,
69
70        /// Skills to load for this agent
71        ///
72        /// Skills can be:
73        /// - Relative paths: `./skills/my-skill.md`
74        /// - pkg: URIs: `pkg:@scope/name@version/path`
75        ///
76        /// Agent-level skills override workflow-level skills when both define
77        /// the same skill path.
78        #[serde(default)]
79        skills: Option<Vec<String>>,
80    },
81}
82
83fn default_provider() -> String {
84    "claude".to_string()
85}
86
87impl AgentDef {
88    /// Check if this is an external reference (file or from)
89    pub fn is_external(&self) -> bool {
90        matches!(self, AgentDef::External { .. } | AgentDef::From { .. })
91    }
92
93    /// Check if this is an inline definition
94    pub fn is_inline(&self) -> bool {
95        matches!(self, AgentDef::Inline { .. })
96    }
97
98    /// Check if this uses the new `from:` syntax
99    pub fn is_from(&self) -> bool {
100        matches!(self, AgentDef::From { .. })
101    }
102
103    /// Get the file/folder path if this is an external reference
104    pub fn file_path(&self) -> Option<&str> {
105        match self {
106            AgentDef::External { file } => Some(file),
107            AgentDef::From { from } => Some(from),
108            AgentDef::Inline { .. } => None,
109        }
110    }
111
112    /// Get the source path (alias for file_path)
113    pub fn source_path(&self) -> Option<&str> {
114        self.file_path()
115    }
116
117    /// Get the skills list if this is an inline definition
118    ///
119    /// Agent-level skills override workflow-level skills when both define
120    /// the same skill path.
121    pub fn skills(&self) -> Option<&Vec<String>> {
122        match self {
123            AgentDef::Inline { skills, .. } => skills.as_ref(),
124            _ => None,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::serde_yaml;
133
134    #[test]
135    fn test_agent_def_from() {
136        let yaml = r#"
137from: ./agents/researcher
138"#;
139        let def: AgentDef = serde_yaml::from_str(yaml).unwrap();
140        assert!(def.is_external());
141        assert!(def.is_from());
142        assert_eq!(def.file_path(), Some("./agents/researcher"));
143    }
144
145    #[test]
146    fn test_agent_def_from_md() {
147        let yaml = r#"
148from: ./agents/reviewer.md
149"#;
150        let def: AgentDef = serde_yaml::from_str(yaml).unwrap();
151        assert!(def.is_from());
152        assert_eq!(def.source_path(), Some("./agents/reviewer.md"));
153    }
154
155    #[test]
156    fn test_agent_def_external() {
157        let yaml = r#"
158file: ./agents/researcher.agent.yaml
159"#;
160        let def: AgentDef = serde_yaml::from_str(yaml).unwrap();
161        assert!(def.is_external());
162        assert!(!def.is_from());
163        assert_eq!(def.file_path(), Some("./agents/researcher.agent.yaml"));
164    }
165
166    #[test]
167    fn test_agent_def_inline_minimal() {
168        let yaml = r#"
169system: "You are a helpful assistant."
170"#;
171        let def: AgentDef = serde_yaml::from_str(yaml).unwrap();
172        assert!(def.is_inline());
173        if let AgentDef::Inline {
174            system,
175            provider,
176            model,
177            max_turns,
178            temperature,
179            skills,
180        } = def
181        {
182            assert_eq!(system, "You are a helpful assistant.");
183            assert_eq!(provider, "claude"); // default
184            assert!(model.is_none());
185            assert!(max_turns.is_none());
186            assert!(temperature.is_none());
187            assert!(skills.is_none()); // no skills in minimal
188        }
189    }
190
191    #[test]
192    fn test_agent_def_inline_full() {
193        let yaml = r#"
194system: "You are a translator."
195provider: openai
196model: gpt-4o
197max_turns: 5
198temperature: 0.7
199"#;
200        let def: AgentDef = serde_yaml::from_str(yaml).unwrap();
201        assert!(def.is_inline());
202        if let AgentDef::Inline {
203            system,
204            provider,
205            model,
206            max_turns,
207            temperature,
208            skills,
209        } = def
210        {
211            assert_eq!(system, "You are a translator.");
212            assert_eq!(provider, "openai");
213            assert_eq!(model, Some("gpt-4o".to_string()));
214            assert_eq!(max_turns, Some(5));
215            assert_eq!(temperature, Some(0.7));
216            assert!(skills.is_none()); // no skills in this test
217        }
218    }
219
220    #[test]
221    fn test_agent_def_is_external() {
222        let external = AgentDef::External {
223            file: "test.yaml".to_string(),
224        };
225        let from = AgentDef::From {
226            from: "./agents/test".to_string(),
227        };
228        let inline = AgentDef::Inline {
229            system: "test".to_string(),
230            provider: "claude".to_string(),
231            model: None,
232            max_turns: None,
233            temperature: None,
234            skills: None,
235        };
236
237        assert!(external.is_external());
238        assert!(!external.is_inline());
239        assert!(!external.is_from());
240
241        assert!(from.is_external());
242        assert!(from.is_from());
243        assert!(!from.is_inline());
244
245        assert!(!inline.is_external());
246        assert!(inline.is_inline());
247        assert!(!inline.is_from());
248    }
249
250    #[test]
251    fn test_agent_def_file_path() {
252        let external = AgentDef::External {
253            file: "path/to/agent.yaml".to_string(),
254        };
255        let from = AgentDef::From {
256            from: "./agents/researcher".to_string(),
257        };
258        let inline = AgentDef::Inline {
259            system: "test".to_string(),
260            provider: "claude".to_string(),
261            model: None,
262            max_turns: None,
263            temperature: None,
264            skills: None,
265        };
266
267        assert_eq!(external.file_path(), Some("path/to/agent.yaml"));
268        assert_eq!(from.file_path(), Some("./agents/researcher"));
269        assert_eq!(from.source_path(), Some("./agents/researcher"));
270        assert_eq!(inline.file_path(), None);
271    }
272
273    // =========================================================================
274    // Skills Tests
275    // =========================================================================
276
277    #[test]
278    fn test_agent_def_inline_with_skills_array() {
279        let yaml = r#"
280            system: "You are a helpful assistant"
281            provider: claude
282            skills:
283              - ./skills/research.md
284              - ./skills/writing.md
285        "#;
286        let agent: AgentDef = serde_yaml::from_str(yaml).unwrap();
287
288        assert!(agent.is_inline());
289        let skills = agent.skills().expect("skills should be Some");
290        assert_eq!(skills.len(), 2);
291        assert_eq!(skills[0], "./skills/research.md");
292        assert_eq!(skills[1], "./skills/writing.md");
293    }
294
295    #[test]
296    fn test_agent_def_skills_helper_returns_some() {
297        let inline = AgentDef::Inline {
298            system: "test".to_string(),
299            provider: "claude".to_string(),
300            model: None,
301            max_turns: None,
302            temperature: None,
303            skills: Some(vec!["./skill.md".to_string()]),
304        };
305
306        assert!(inline.skills().is_some());
307        assert_eq!(inline.skills().unwrap().len(), 1);
308    }
309
310    #[test]
311    fn test_agent_def_skills_helper_returns_none_for_no_skills() {
312        let inline = AgentDef::Inline {
313            system: "test".to_string(),
314            provider: "claude".to_string(),
315            model: None,
316            max_turns: None,
317            temperature: None,
318            skills: None,
319        };
320
321        assert!(inline.skills().is_none());
322    }
323
324    #[test]
325    fn test_agent_def_skills_helper_returns_none_for_external() {
326        let external = AgentDef::External {
327            file: "agent.yaml".to_string(),
328        };
329
330        // External agents don't have inline skills
331        assert!(external.skills().is_none());
332    }
333
334    #[test]
335    fn test_agent_def_empty_skills_array() {
336        let yaml = r#"
337            system: "You are a helpful assistant"
338            provider: claude
339            skills: []
340        "#;
341        let agent: AgentDef = serde_yaml::from_str(yaml).unwrap();
342
343        let skills = agent.skills().expect("empty array should still be Some");
344        assert!(skills.is_empty());
345    }
346
347    #[test]
348    fn test_agent_def_skills_with_pkg_uri() {
349        let yaml = r#"
350            system: "You are a helpful assistant"
351            provider: claude
352            skills:
353              - pkg:@supernovae/research@1.0.0/skills/deep-research.md
354              - pkg:@supernovae/writing@2.0.0/skills/technical-writing.md
355        "#;
356        let agent: AgentDef = serde_yaml::from_str(yaml).unwrap();
357
358        let skills = agent.skills().expect("skills should be Some");
359        assert_eq!(skills.len(), 2);
360        assert!(skills[0].starts_with("pkg:@"));
361        assert!(skills[1].starts_with("pkg:@"));
362        assert!(skills[0].contains("@1.0.0"));
363        assert!(skills[1].contains("@2.0.0"));
364    }
365
366    #[test]
367    fn test_agent_def_skills_with_relative_paths() {
368        let yaml = r#"
369            system: "You are a helpful assistant"
370            provider: claude
371            skills:
372              - ./skills/local-skill.md
373              - ../shared/common-skill.md
374              - skills/nested/deep-skill.md
375        "#;
376        let agent: AgentDef = serde_yaml::from_str(yaml).unwrap();
377
378        let skills = agent.skills().expect("skills should be Some");
379        assert_eq!(skills.len(), 3);
380        assert_eq!(skills[0], "./skills/local-skill.md");
381        assert_eq!(skills[1], "../shared/common-skill.md");
382        assert_eq!(skills[2], "skills/nested/deep-skill.md");
383    }
384
385    #[test]
386    fn test_agent_def_skills_mixed_pkg_and_relative() {
387        // Agent-level skills can mix pkg: URIs and relative paths
388        // This tests the intended behavior documented in ADR:
389        // - Agent-level skills override workflow-level skills
390        // - Skills are loaded and injected into the agent's system prompt
391        let yaml = r#"
392            system: "You are a helpful assistant"
393            provider: claude
394            skills:
395              - ./skills/project-specific.md
396              - pkg:@supernovae/research@1.0.0/skills/research.md
397        "#;
398        let agent: AgentDef = serde_yaml::from_str(yaml).unwrap();
399
400        let skills = agent.skills().expect("skills should be Some");
401        assert_eq!(skills.len(), 2);
402
403        // First is relative path
404        assert!(skills[0].starts_with("./"));
405        // Second is pkg: URI
406        assert!(skills[1].starts_with("pkg:"));
407    }
408}