Skip to main content

nika_engine/runtime/
resolver.rs

1//! Agent and Skill Resolver
2//!
3//! Resolves external agent definitions and loads skill files at workflow start.
4//! This module handles the loading and resolution of:
5//! - External agent definition files (.agent.yaml)
6//! - Package agent references (@agents/name)
7//! - Package prompt references (@prompts/name)
8//! - Package skill references (@skills/name)
9//! - Skill files (.skill.md) for prompt augmentation
10//!
11//! # Example
12//!
13//! ```yaml
14//! agents:
15//!   researcher:
16//!     from: "@agents/researcher"              # From package
17//!   local:
18//!     file: ./agents/local.agent.yaml        # Local file
19//!   translator:
20//!     system: "You are a translator..."      # Inline definition
21//!
22//! skills:
23//!   seo: "@prompts/seo-meta"                  # From package
24//!   local: ./skills/seo-writer.skill.md      # Local file
25//! ```
26
27use crate::ast::analyzed::AnalyzedWorkflow;
28use crate::ast::{AgentDef, SkillDef, Workflow};
29use crate::error::NikaError;
30use crate::registry::resolver; // Package resolution
31use crate::serde_yaml;
32use rustc_hash::FxHashMap;
33use std::path::{Path, PathBuf};
34use tokio::fs;
35use tracing::debug;
36
37/// Resolved agents (all expanded to inline definitions)
38pub type ResolvedAgents = FxHashMap<String, ResolvedAgent>;
39
40/// Resolved skills (loaded file contents)
41pub type ResolvedSkills = FxHashMap<String, String>;
42
43/// Resolved agent definition (always inline after resolution)
44#[derive(Debug, Clone)]
45pub struct ResolvedAgent {
46    /// System prompt for the agent
47    pub system: String,
48    /// Provider to use (claude, openai, etc.)
49    pub provider: String,
50    /// Model to use (optional)
51    pub model: Option<String>,
52    /// Maximum turns for the agent (optional)
53    pub max_turns: Option<u32>,
54    /// Temperature for generation (optional)
55    pub temperature: Option<f32>,
56    /// Source of the definition (for debugging)
57    pub source: AgentSource,
58}
59
60/// Source of agent definition
61#[derive(Debug, Clone, PartialEq)]
62pub enum AgentSource {
63    /// Defined inline in workflow
64    Inline,
65    /// Loaded from external file
66    External(String),
67}
68
69/// Resolved assets container
70#[derive(Debug, Default)]
71pub struct ResolvedAssets {
72    /// Resolved agent definitions (all inline)
73    pub agents: ResolvedAgents,
74    /// Loaded skill contents
75    pub skills: ResolvedSkills,
76}
77
78impl ResolvedAssets {
79    /// Create empty resolved assets
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Get a resolved agent by name
85    pub fn get_agent(&self, name: &str) -> Option<&ResolvedAgent> {
86        self.agents.get(name)
87    }
88
89    /// Get a loaded skill content by name
90    pub fn get_skill(&self, name: &str) -> Option<&str> {
91        self.skills.get(name).map(String::as_str)
92    }
93
94    /// Check if assets are empty
95    pub fn is_empty(&self) -> bool {
96        self.agents.is_empty() && self.skills.is_empty()
97    }
98}
99
100/// Resolve all agents and skills in a workflow.
101///
102/// This loads external agent files and skill contents, making them available
103/// for task execution.
104///
105/// # Arguments
106/// * `workflow` - The workflow to resolve assets for
107/// * `base_path` - Base directory for resolving relative paths
108///
109/// # Errors
110/// Returns `NikaError` if any file cannot be loaded or parsed.
111pub async fn resolve_assets(
112    workflow: &Workflow,
113    base_path: &Path,
114) -> Result<ResolvedAssets, NikaError> {
115    let mut assets = ResolvedAssets::new();
116
117    // Resolve agents
118    if let Some(agents) = &workflow.agents {
119        for (name, def) in agents {
120            let resolved = resolve_agent(name, def, base_path).await?;
121            assets.agents.insert(name.clone(), resolved);
122        }
123    }
124
125    // Load skills
126    if let Some(skills) = &workflow.skills {
127        for (name, path) in skills {
128            let content = load_skill(name, path, base_path).await?;
129            assets.skills.insert(name.clone(), content);
130        }
131    }
132
133    debug!(
134        agents = assets.agents.len(),
135        skills = assets.skills.len(),
136        "Resolved workflow assets"
137    );
138
139    Ok(assets)
140}
141
142/// Resolve agents from an AnalyzedWorkflow.
143///
144/// AnalyzedWorkflow has `agents` but no `skills` (skills are resolved during
145/// analysis and merged via include_loader). This function only resolves agents.
146pub async fn resolve_assets_analyzed(
147    workflow: &AnalyzedWorkflow,
148    base_path: &Path,
149) -> Result<ResolvedAssets, NikaError> {
150    let mut assets = ResolvedAssets::new();
151
152    // Resolve agents
153    if let Some(agents) = &workflow.agents {
154        for (name, def) in agents {
155            let resolved = resolve_agent(name, def, base_path).await?;
156            assets.agents.insert(name.clone(), resolved);
157        }
158    }
159
160    // No skills on AnalyzedWorkflow — resolved during analysis
161
162    debug!(
163        agents = assets.agents.len(),
164        "Resolved workflow assets (analyzed)"
165    );
166
167    Ok(assets)
168}
169
170/// Resolve a single agent definition.
171///
172/// For external definitions, loads and parses the file.
173/// For inline definitions, converts directly.
174async fn resolve_agent(
175    name: &str,
176    def: &AgentDef,
177    base_path: &Path,
178) -> Result<ResolvedAgent, NikaError> {
179    match def {
180        AgentDef::From { from } => {
181            // Support package references (@agents/name)
182            use crate::ast::loader::{load_definition, DefinitionKind};
183
184            let source_path: PathBuf = if from.starts_with('@') {
185                // Package reference - resolve via registry
186                debug!(agent = name, package = from, "Resolving agent from package");
187
188                let resolved = resolver::resolve_package_path(from).map_err(|e| {
189                    NikaError::ContextLoadError {
190                        alias: name.to_string(),
191                        path: from.clone(),
192                        reason: format!("Package not found: {}. Try: nika add {}", e, from),
193                    }
194                })?;
195
196                // Agent packages should contain agent.md or agent.yaml
197                let agent_md = resolved.path.join("agent.md");
198                let agent_yaml = resolved.path.join("agent.yaml");
199
200                if agent_md.exists() {
201                    agent_md
202                } else if agent_yaml.exists() {
203                    agent_yaml
204                } else {
205                    return Err(NikaError::ContextLoadError {
206                        alias: name.to_string(),
207                        path: from.clone(),
208                        reason: format!(
209                            "Package {} exists but missing agent.md or agent.yaml at {}",
210                            from,
211                            resolved.path.display()
212                        ),
213                    });
214                }
215            } else {
216                // Regular filesystem path
217                base_path.join(from)
218            };
219
220            debug!(agent = name, path = ?source_path, "Loading agent via multi-format loader");
221
222            let loaded = load_definition(&source_path, DefinitionKind::Agent)?;
223
224            Ok(ResolvedAgent {
225                system: loaded.system,
226                provider: loaded.provider.unwrap_or_else(|| "claude".to_string()),
227                model: loaded.model,
228                max_turns: loaded.max_turns,
229                temperature: loaded.temperature,
230                source: AgentSource::External(from.clone()),
231            })
232        }
233        AgentDef::External { file } => {
234            let file_path = base_path.join(file);
235            debug!(agent = name, path = ?file_path, "Loading external agent definition");
236
237            let content =
238                fs::read_to_string(&file_path)
239                    .await
240                    .map_err(|e| NikaError::ContextLoadError {
241                        alias: name.to_string(),
242                        path: file_path.display().to_string(),
243                        reason: e.to_string(),
244                    })?;
245
246            // Parse the external file as an inline agent definition
247            let parsed: ExternalAgentFile =
248                serde_yaml::from_str(&content).map_err(|e| NikaError::ParseError {
249                    details: format!("Failed to parse agent file '{}': {}", file, e),
250                })?;
251
252            Ok(ResolvedAgent {
253                system: parsed.system,
254                provider: parsed.provider,
255                model: parsed.model,
256                max_turns: parsed.max_turns,
257                temperature: parsed.temperature,
258                source: AgentSource::External(file.clone()),
259            })
260        }
261        AgentDef::Inline {
262            system,
263            provider,
264            model,
265            max_turns,
266            temperature,
267            skills: _, // agent-level skills handled by skill injector
268        } => Ok(ResolvedAgent {
269            system: system.clone(),
270            provider: provider.clone(),
271            model: model.clone(),
272            max_turns: *max_turns,
273            temperature: *temperature,
274            source: AgentSource::Inline,
275        }),
276    }
277}
278
279/// External agent file structure
280#[derive(Debug, serde::Deserialize)]
281struct ExternalAgentFile {
282    /// System prompt for the agent
283    system: String,
284    /// Provider to use (claude, openai, etc.)
285    #[serde(default = "default_provider")]
286    provider: String,
287    /// Model to use (optional)
288    model: Option<String>,
289    /// Maximum turns for the agent (optional)
290    max_turns: Option<u32>,
291    /// Temperature for generation (optional)
292    temperature: Option<f32>,
293}
294
295fn default_provider() -> String {
296    "claude".to_string()
297}
298
299/// Load a skill file content.
300async fn load_skill(name: &str, path: &SkillDef, base_path: &Path) -> Result<String, NikaError> {
301    // Support package references (@prompts/name, @skills/name)
302    let file_path: PathBuf = if path.starts_with('@') {
303        // Package reference - resolve via registry
304        debug!(
305            skill = name,
306            package = path,
307            "Resolving skill/prompt from package"
308        );
309
310        let resolved =
311            resolver::resolve_package_path(path).map_err(|e| NikaError::ContextLoadError {
312                alias: name.to_string(),
313                path: path.to_string(),
314                reason: format!("Package not found: {}. Try: nika add {}", e, path),
315            })?;
316
317        // Skill/Prompt packages should contain skill.md or prompt.md
318        let skill_md = resolved.path.join("skill.md");
319        let prompt_md = resolved.path.join("prompt.md");
320
321        if skill_md.exists() {
322            skill_md
323        } else if prompt_md.exists() {
324            prompt_md
325        } else {
326            return Err(NikaError::ContextLoadError {
327                alias: name.to_string(),
328                path: path.to_string(),
329                reason: format!(
330                    "Package {} exists but missing skill.md or prompt.md at {}",
331                    path,
332                    resolved.path.display()
333                ),
334            });
335        }
336    } else {
337        // Regular filesystem path
338        base_path.join(path)
339    };
340
341    debug!(skill = name, path = ?file_path, "Loading skill file");
342
343    let content =
344        fs::read_to_string(&file_path)
345            .await
346            .map_err(|e| NikaError::ContextLoadError {
347                alias: name.to_string(),
348                path: file_path.display().to_string(),
349                reason: e.to_string(),
350            })?;
351
352    Ok(content)
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use tempfile::tempdir;
359
360    #[tokio::test]
361    async fn test_resolve_assets_empty() {
362        let workflow = crate::ast::Workflow {
363            schema: "nika/workflow@0.12".to_string(),
364            name: None,
365            provider: "claude".to_string(),
366            model: None,
367            mcp: None,
368            context: None,
369            include: None,
370            agents: None,
371            skills: None,
372            artifacts: None,
373            log: None,
374            inputs: None,
375            tasks: vec![],
376        };
377
378        let dir = tempdir().unwrap();
379        let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
380
381        assert!(assets.is_empty());
382        assert!(assets.agents.is_empty());
383        assert!(assets.skills.is_empty());
384    }
385
386    #[tokio::test]
387    async fn test_resolve_inline_agent() {
388        let mut agents = FxHashMap::default();
389        agents.insert(
390            "test_agent".to_string(),
391            AgentDef::Inline {
392                system: "You are a test agent.".to_string(),
393                provider: "openai".to_string(),
394                model: Some("gpt-4o".to_string()),
395                max_turns: Some(5),
396                temperature: Some(0.7),
397                skills: None, // agent-level skills
398            },
399        );
400
401        let workflow = crate::ast::Workflow {
402            schema: "nika/workflow@0.12".to_string(),
403            name: None,
404            provider: "claude".to_string(),
405            model: None,
406            mcp: None,
407            context: None,
408            include: None,
409            agents: Some(agents),
410            skills: None,
411            artifacts: None,
412            log: None,
413            inputs: None,
414            tasks: vec![],
415        };
416
417        let dir = tempdir().unwrap();
418        let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
419
420        assert_eq!(assets.agents.len(), 1);
421        let agent = assets.get_agent("test_agent").unwrap();
422        assert_eq!(agent.system, "You are a test agent.");
423        assert_eq!(agent.provider, "openai");
424        assert_eq!(agent.model, Some("gpt-4o".to_string()));
425        assert_eq!(agent.max_turns, Some(5));
426        assert_eq!(agent.temperature, Some(0.7));
427        assert_eq!(agent.source, AgentSource::Inline);
428    }
429
430    #[tokio::test]
431    async fn test_resolve_external_agent() {
432        let dir = tempdir().unwrap();
433
434        // Create external agent file
435        let agent_content = r#"
436system: "You are an external agent."
437provider: mistral
438model: mistral-large-latest
439max_turns: 10
440temperature: 0.5
441"#;
442        let agent_path = dir.path().join("agents");
443        std::fs::create_dir_all(&agent_path).unwrap();
444        std::fs::write(agent_path.join("external.agent.yaml"), agent_content).unwrap();
445
446        let mut agents = FxHashMap::default();
447        agents.insert(
448            "ext_agent".to_string(),
449            AgentDef::External {
450                file: "agents/external.agent.yaml".to_string(),
451            },
452        );
453
454        let workflow = crate::ast::Workflow {
455            schema: "nika/workflow@0.12".to_string(),
456            name: None,
457            provider: "claude".to_string(),
458            model: None,
459            mcp: None,
460            context: None,
461            include: None,
462            agents: Some(agents),
463            skills: None,
464            artifacts: None,
465            log: None,
466            inputs: None,
467            tasks: vec![],
468        };
469
470        let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
471
472        assert_eq!(assets.agents.len(), 1);
473        let agent = assets.get_agent("ext_agent").unwrap();
474        assert_eq!(agent.system, "You are an external agent.");
475        assert_eq!(agent.provider, "mistral");
476        assert_eq!(agent.model, Some("mistral-large-latest".to_string()));
477        assert_eq!(agent.max_turns, Some(10));
478        assert_eq!(agent.temperature, Some(0.5));
479        assert_eq!(
480            agent.source,
481            AgentSource::External("agents/external.agent.yaml".to_string())
482        );
483    }
484
485    #[tokio::test]
486    async fn test_resolve_external_agent_missing_file() {
487        let dir = tempdir().unwrap();
488
489        let mut agents = FxHashMap::default();
490        agents.insert(
491            "missing".to_string(),
492            AgentDef::External {
493                file: "nonexistent.agent.yaml".to_string(),
494            },
495        );
496
497        let workflow = crate::ast::Workflow {
498            schema: "nika/workflow@0.12".to_string(),
499            name: None,
500            provider: "claude".to_string(),
501            model: None,
502            mcp: None,
503            context: None,
504            include: None,
505            agents: Some(agents),
506            skills: None,
507            artifacts: None,
508            log: None,
509            inputs: None,
510            tasks: vec![],
511        };
512
513        let result = resolve_assets(&workflow, dir.path()).await;
514        assert!(result.is_err());
515        let err = result.unwrap_err();
516        assert!(matches!(err, NikaError::ContextLoadError { .. }));
517    }
518
519    #[tokio::test]
520    async fn test_load_skill() {
521        let dir = tempdir().unwrap();
522
523        // Create skill file
524        let skill_content = r#"# SEO Writer Skill
525
526You are an expert SEO content writer.
527
528## Guidelines
529- Use relevant keywords naturally
530- Write engaging headlines
531- Structure content for readability
532"#;
533        let skills_path = dir.path().join("skills");
534        std::fs::create_dir_all(&skills_path).unwrap();
535        std::fs::write(skills_path.join("seo.skill.md"), skill_content).unwrap();
536
537        let mut skills = FxHashMap::default();
538        skills.insert("seo".to_string(), "skills/seo.skill.md".to_string());
539
540        let workflow = crate::ast::Workflow {
541            schema: "nika/workflow@0.12".to_string(),
542            name: None,
543            provider: "claude".to_string(),
544            model: None,
545            mcp: None,
546            context: None,
547            include: None,
548            agents: None,
549            skills: Some(skills),
550            artifacts: None,
551            log: None,
552            inputs: None,
553            tasks: vec![],
554        };
555
556        let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
557
558        assert_eq!(assets.skills.len(), 1);
559        let skill = assets.get_skill("seo").unwrap();
560        assert!(skill.contains("SEO Writer Skill"));
561        assert!(skill.contains("expert SEO content writer"));
562    }
563
564    #[tokio::test]
565    async fn test_load_skill_missing_file() {
566        let dir = tempdir().unwrap();
567
568        let mut skills = FxHashMap::default();
569        skills.insert("missing".to_string(), "nonexistent.skill.md".to_string());
570
571        let workflow = crate::ast::Workflow {
572            schema: "nika/workflow@0.12".to_string(),
573            name: None,
574            provider: "claude".to_string(),
575            model: None,
576            mcp: None,
577            context: None,
578            include: None,
579            agents: None,
580            skills: Some(skills),
581            artifacts: None,
582            log: None,
583            inputs: None,
584            tasks: vec![],
585        };
586
587        let result = resolve_assets(&workflow, dir.path()).await;
588        assert!(result.is_err());
589        let err = result.unwrap_err();
590        assert!(matches!(err, NikaError::ContextLoadError { .. }));
591    }
592
593    #[tokio::test]
594    async fn test_resolve_mixed_agents_and_skills() {
595        let dir = tempdir().unwrap();
596
597        // Create external agent file
598        let agent_content = r#"
599system: "You are a researcher."
600"#;
601        let agent_path = dir.path().join("agents");
602        std::fs::create_dir_all(&agent_path).unwrap();
603        std::fs::write(agent_path.join("researcher.agent.yaml"), agent_content).unwrap();
604
605        // Create skill files
606        let skill1_content = "# Skill 1\nFirst skill content.";
607        let skill2_content = "# Skill 2\nSecond skill content.";
608        let skills_path = dir.path().join("skills");
609        std::fs::create_dir_all(&skills_path).unwrap();
610        std::fs::write(skills_path.join("skill1.skill.md"), skill1_content).unwrap();
611        std::fs::write(skills_path.join("skill2.skill.md"), skill2_content).unwrap();
612
613        // Build workflow
614        let mut agents = FxHashMap::default();
615        agents.insert(
616            "researcher".to_string(),
617            AgentDef::External {
618                file: "agents/researcher.agent.yaml".to_string(),
619            },
620        );
621        agents.insert(
622            "writer".to_string(),
623            AgentDef::Inline {
624                system: "You are a writer.".to_string(),
625                provider: "claude".to_string(),
626                model: None,
627                max_turns: None,
628                temperature: None,
629                skills: None, // agent-level skills
630            },
631        );
632
633        let mut skills = FxHashMap::default();
634        skills.insert("skill1".to_string(), "skills/skill1.skill.md".to_string());
635        skills.insert("skill2".to_string(), "skills/skill2.skill.md".to_string());
636
637        let workflow = crate::ast::Workflow {
638            schema: "nika/workflow@0.12".to_string(),
639            name: None,
640            provider: "claude".to_string(),
641            model: None,
642            mcp: None,
643            context: None,
644            include: None,
645            agents: Some(agents),
646            skills: Some(skills),
647            artifacts: None,
648            log: None,
649            inputs: None,
650            tasks: vec![],
651        };
652
653        let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
654
655        // Check agents
656        assert_eq!(assets.agents.len(), 2);
657        let researcher = assets.get_agent("researcher").unwrap();
658        assert_eq!(researcher.system, "You are a researcher.");
659        assert_eq!(
660            researcher.source,
661            AgentSource::External("agents/researcher.agent.yaml".to_string())
662        );
663
664        let writer = assets.get_agent("writer").unwrap();
665        assert_eq!(writer.system, "You are a writer.");
666        assert_eq!(writer.source, AgentSource::Inline);
667
668        // Check skills
669        assert_eq!(assets.skills.len(), 2);
670        assert!(assets
671            .get_skill("skill1")
672            .unwrap()
673            .contains("First skill content"));
674        assert!(assets
675            .get_skill("skill2")
676            .unwrap()
677            .contains("Second skill content"));
678    }
679
680    #[tokio::test]
681    async fn test_external_agent_with_defaults() {
682        let dir = tempdir().unwrap();
683
684        // Create minimal external agent file (only required field)
685        let agent_content = r#"
686system: "You are an agent with defaults."
687"#;
688        std::fs::write(dir.path().join("minimal.agent.yaml"), agent_content).unwrap();
689
690        let mut agents = FxHashMap::default();
691        agents.insert(
692            "minimal".to_string(),
693            AgentDef::External {
694                file: "minimal.agent.yaml".to_string(),
695            },
696        );
697
698        let workflow = crate::ast::Workflow {
699            schema: "nika/workflow@0.12".to_string(),
700            name: None,
701            provider: "claude".to_string(),
702            model: None,
703            mcp: None,
704            context: None,
705            include: None,
706            agents: Some(agents),
707            skills: None,
708            artifacts: None,
709            log: None,
710            inputs: None,
711            tasks: vec![],
712        };
713
714        let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
715
716        let agent = assets.get_agent("minimal").unwrap();
717        assert_eq!(agent.system, "You are an agent with defaults.");
718        assert_eq!(agent.provider, "claude"); // default
719        assert!(agent.model.is_none());
720        assert!(agent.max_turns.is_none());
721        assert!(agent.temperature.is_none());
722    }
723
724    #[test]
725    fn test_resolved_agent_clone() {
726        let agent = ResolvedAgent {
727            system: "Test".to_string(),
728            provider: "claude".to_string(),
729            model: None,
730            max_turns: None,
731            temperature: None,
732            source: AgentSource::Inline,
733        };
734
735        let cloned = agent.clone();
736        assert_eq!(cloned.system, "Test");
737    }
738
739    #[test]
740    fn test_resolved_assets_get_methods() {
741        let mut assets = ResolvedAssets::new();
742
743        assets.agents.insert(
744            "test".to_string(),
745            ResolvedAgent {
746                system: "Test system".to_string(),
747                provider: "claude".to_string(),
748                model: None,
749                max_turns: None,
750                temperature: None,
751                source: AgentSource::Inline,
752            },
753        );
754        assets
755            .skills
756            .insert("skill".to_string(), "Skill content".to_string());
757
758        assert!(assets.get_agent("test").is_some());
759        assert!(assets.get_agent("nonexistent").is_none());
760        assert_eq!(assets.get_skill("skill"), Some("Skill content"));
761        assert!(assets.get_skill("nonexistent").is_none());
762        assert!(!assets.is_empty());
763    }
764}