1use crate::ast::analyzed::AnalyzedWorkflow;
28use crate::ast::{AgentDef, SkillDef, Workflow};
29use crate::error::NikaError;
30use crate::registry::resolver; use crate::serde_yaml;
32use rustc_hash::FxHashMap;
33use std::path::{Path, PathBuf};
34use tokio::fs;
35use tracing::debug;
36
37pub type ResolvedAgents = FxHashMap<String, ResolvedAgent>;
39
40pub type ResolvedSkills = FxHashMap<String, String>;
42
43#[derive(Debug, Clone)]
45pub struct ResolvedAgent {
46 pub system: String,
48 pub provider: String,
50 pub model: Option<String>,
52 pub max_turns: Option<u32>,
54 pub temperature: Option<f32>,
56 pub source: AgentSource,
58}
59
60#[derive(Debug, Clone, PartialEq)]
62pub enum AgentSource {
63 Inline,
65 External(String),
67}
68
69#[derive(Debug, Default)]
71pub struct ResolvedAssets {
72 pub agents: ResolvedAgents,
74 pub skills: ResolvedSkills,
76}
77
78impl ResolvedAssets {
79 pub fn new() -> Self {
81 Self::default()
82 }
83
84 pub fn get_agent(&self, name: &str) -> Option<&ResolvedAgent> {
86 self.agents.get(name)
87 }
88
89 pub fn get_skill(&self, name: &str) -> Option<&str> {
91 self.skills.get(name).map(String::as_str)
92 }
93
94 pub fn is_empty(&self) -> bool {
96 self.agents.is_empty() && self.skills.is_empty()
97 }
98}
99
100pub async fn resolve_assets(
112 workflow: &Workflow,
113 base_path: &Path,
114) -> Result<ResolvedAssets, NikaError> {
115 let mut assets = ResolvedAssets::new();
116
117 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 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
142pub async fn resolve_assets_analyzed(
147 workflow: &AnalyzedWorkflow,
148 base_path: &Path,
149) -> Result<ResolvedAssets, NikaError> {
150 let mut assets = ResolvedAssets::new();
151
152 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 debug!(
163 agents = assets.agents.len(),
164 "Resolved workflow assets (analyzed)"
165 );
166
167 Ok(assets)
168}
169
170async 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 use crate::ast::loader::{load_definition, DefinitionKind};
183
184 let source_path: PathBuf = if from.starts_with('@') {
185 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 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 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 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: _, } => 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#[derive(Debug, serde::Deserialize)]
281struct ExternalAgentFile {
282 system: String,
284 #[serde(default = "default_provider")]
286 provider: String,
287 model: Option<String>,
289 max_turns: Option<u32>,
291 temperature: Option<f32>,
293}
294
295fn default_provider() -> String {
296 "claude".to_string()
297}
298
299async fn load_skill(name: &str, path: &SkillDef, base_path: &Path) -> Result<String, NikaError> {
301 let file_path: PathBuf = if path.starts_with('@') {
303 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 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 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, },
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 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 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 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 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 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, },
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 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 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 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"); 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}