use crate::ast::analyzed::AnalyzedWorkflow;
use crate::ast::{AgentDef, SkillDef, Workflow};
use crate::error::NikaError;
use crate::registry::resolver; use crate::serde_yaml;
use rustc_hash::FxHashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::debug;
pub type ResolvedAgents = FxHashMap<String, ResolvedAgent>;
pub type ResolvedSkills = FxHashMap<String, String>;
#[derive(Debug, Clone)]
pub struct ResolvedAgent {
pub system: String,
pub provider: String,
pub model: Option<String>,
pub max_turns: Option<u32>,
pub temperature: Option<f32>,
pub source: AgentSource,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AgentSource {
Inline,
External(String),
}
#[derive(Debug, Default)]
pub struct ResolvedAssets {
pub agents: ResolvedAgents,
pub skills: ResolvedSkills,
}
impl ResolvedAssets {
pub fn new() -> Self {
Self::default()
}
pub fn get_agent(&self, name: &str) -> Option<&ResolvedAgent> {
self.agents.get(name)
}
pub fn get_skill(&self, name: &str) -> Option<&str> {
self.skills.get(name).map(String::as_str)
}
pub fn is_empty(&self) -> bool {
self.agents.is_empty() && self.skills.is_empty()
}
}
pub async fn resolve_assets(
workflow: &Workflow,
base_path: &Path,
) -> Result<ResolvedAssets, NikaError> {
let mut assets = ResolvedAssets::new();
if let Some(agents) = &workflow.agents {
for (name, def) in agents {
let resolved = resolve_agent(name, def, base_path).await?;
assets.agents.insert(name.clone(), resolved);
}
}
if let Some(skills) = &workflow.skills {
for (name, path) in skills {
let content = load_skill(name, path, base_path).await?;
assets.skills.insert(name.clone(), content);
}
}
debug!(
agents = assets.agents.len(),
skills = assets.skills.len(),
"Resolved workflow assets"
);
Ok(assets)
}
pub async fn resolve_assets_analyzed(
workflow: &AnalyzedWorkflow,
base_path: &Path,
) -> Result<ResolvedAssets, NikaError> {
let mut assets = ResolvedAssets::new();
if let Some(agents) = &workflow.agents {
for (name, def) in agents {
let resolved = resolve_agent(name, def, base_path).await?;
assets.agents.insert(name.clone(), resolved);
}
}
debug!(
agents = assets.agents.len(),
"Resolved workflow assets (analyzed)"
);
Ok(assets)
}
async fn resolve_agent(
name: &str,
def: &AgentDef,
base_path: &Path,
) -> Result<ResolvedAgent, NikaError> {
match def {
AgentDef::From { from } => {
use crate::ast::loader::{load_definition, DefinitionKind};
let source_path: PathBuf = if from.starts_with('@') {
debug!(agent = name, package = from, "Resolving agent from package");
let resolved = resolver::resolve_package_path(from).map_err(|e| {
NikaError::ContextLoadError {
alias: name.to_string(),
path: from.clone(),
reason: format!("Package not found: {}. Try: nika add {}", e, from),
}
})?;
let agent_md = resolved.path.join("agent.md");
let agent_yaml = resolved.path.join("agent.yaml");
if agent_md.exists() {
agent_md
} else if agent_yaml.exists() {
agent_yaml
} else {
return Err(NikaError::ContextLoadError {
alias: name.to_string(),
path: from.clone(),
reason: format!(
"Package {} exists but missing agent.md or agent.yaml at {}",
from,
resolved.path.display()
),
});
}
} else {
base_path.join(from)
};
debug!(agent = name, path = ?source_path, "Loading agent via multi-format loader");
let loaded = load_definition(&source_path, DefinitionKind::Agent)?;
Ok(ResolvedAgent {
system: loaded.system,
provider: loaded.provider.unwrap_or_else(|| "claude".to_string()),
model: loaded.model,
max_turns: loaded.max_turns,
temperature: loaded.temperature,
source: AgentSource::External(from.clone()),
})
}
AgentDef::External { file } => {
let file_path = base_path.join(file);
debug!(agent = name, path = ?file_path, "Loading external agent definition");
let content =
fs::read_to_string(&file_path)
.await
.map_err(|e| NikaError::ContextLoadError {
alias: name.to_string(),
path: file_path.display().to_string(),
reason: e.to_string(),
})?;
let parsed: ExternalAgentFile =
serde_yaml::from_str(&content).map_err(|e| NikaError::ParseError {
details: format!("Failed to parse agent file '{}': {}", file, e),
})?;
Ok(ResolvedAgent {
system: parsed.system,
provider: parsed.provider,
model: parsed.model,
max_turns: parsed.max_turns,
temperature: parsed.temperature,
source: AgentSource::External(file.clone()),
})
}
AgentDef::Inline {
system,
provider,
model,
max_turns,
temperature,
skills: _, } => Ok(ResolvedAgent {
system: system.clone(),
provider: provider.clone(),
model: model.clone(),
max_turns: *max_turns,
temperature: *temperature,
source: AgentSource::Inline,
}),
}
}
#[derive(Debug, serde::Deserialize)]
struct ExternalAgentFile {
system: String,
#[serde(default = "default_provider")]
provider: String,
model: Option<String>,
max_turns: Option<u32>,
temperature: Option<f32>,
}
fn default_provider() -> String {
"claude".to_string()
}
async fn load_skill(name: &str, path: &SkillDef, base_path: &Path) -> Result<String, NikaError> {
let file_path: PathBuf = if path.starts_with('@') {
debug!(
skill = name,
package = path,
"Resolving skill/prompt from package"
);
let resolved =
resolver::resolve_package_path(path).map_err(|e| NikaError::ContextLoadError {
alias: name.to_string(),
path: path.to_string(),
reason: format!("Package not found: {}. Try: nika add {}", e, path),
})?;
let skill_md = resolved.path.join("skill.md");
let prompt_md = resolved.path.join("prompt.md");
if skill_md.exists() {
skill_md
} else if prompt_md.exists() {
prompt_md
} else {
return Err(NikaError::ContextLoadError {
alias: name.to_string(),
path: path.to_string(),
reason: format!(
"Package {} exists but missing skill.md or prompt.md at {}",
path,
resolved.path.display()
),
});
}
} else {
base_path.join(path)
};
debug!(skill = name, path = ?file_path, "Loading skill file");
let content =
fs::read_to_string(&file_path)
.await
.map_err(|e| NikaError::ContextLoadError {
alias: name.to_string(),
path: file_path.display().to_string(),
reason: e.to_string(),
})?;
Ok(content)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_resolve_assets_empty() {
let workflow = crate::ast::Workflow {
schema: "nika/workflow@0.12".to_string(),
name: None,
provider: "claude".to_string(),
model: None,
mcp: None,
context: None,
include: None,
agents: None,
skills: None,
artifacts: None,
log: None,
inputs: None,
tasks: vec![],
};
let dir = tempdir().unwrap();
let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
assert!(assets.is_empty());
assert!(assets.agents.is_empty());
assert!(assets.skills.is_empty());
}
#[tokio::test]
async fn test_resolve_inline_agent() {
let mut agents = FxHashMap::default();
agents.insert(
"test_agent".to_string(),
AgentDef::Inline {
system: "You are a test agent.".to_string(),
provider: "openai".to_string(),
model: Some("gpt-4o".to_string()),
max_turns: Some(5),
temperature: Some(0.7),
skills: None, },
);
let workflow = crate::ast::Workflow {
schema: "nika/workflow@0.12".to_string(),
name: None,
provider: "claude".to_string(),
model: None,
mcp: None,
context: None,
include: None,
agents: Some(agents),
skills: None,
artifacts: None,
log: None,
inputs: None,
tasks: vec![],
};
let dir = tempdir().unwrap();
let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
assert_eq!(assets.agents.len(), 1);
let agent = assets.get_agent("test_agent").unwrap();
assert_eq!(agent.system, "You are a test agent.");
assert_eq!(agent.provider, "openai");
assert_eq!(agent.model, Some("gpt-4o".to_string()));
assert_eq!(agent.max_turns, Some(5));
assert_eq!(agent.temperature, Some(0.7));
assert_eq!(agent.source, AgentSource::Inline);
}
#[tokio::test]
async fn test_resolve_external_agent() {
let dir = tempdir().unwrap();
let agent_content = r#"
system: "You are an external agent."
provider: mistral
model: mistral-large-latest
max_turns: 10
temperature: 0.5
"#;
let agent_path = dir.path().join("agents");
std::fs::create_dir_all(&agent_path).unwrap();
std::fs::write(agent_path.join("external.agent.yaml"), agent_content).unwrap();
let mut agents = FxHashMap::default();
agents.insert(
"ext_agent".to_string(),
AgentDef::External {
file: "agents/external.agent.yaml".to_string(),
},
);
let workflow = crate::ast::Workflow {
schema: "nika/workflow@0.12".to_string(),
name: None,
provider: "claude".to_string(),
model: None,
mcp: None,
context: None,
include: None,
agents: Some(agents),
skills: None,
artifacts: None,
log: None,
inputs: None,
tasks: vec![],
};
let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
assert_eq!(assets.agents.len(), 1);
let agent = assets.get_agent("ext_agent").unwrap();
assert_eq!(agent.system, "You are an external agent.");
assert_eq!(agent.provider, "mistral");
assert_eq!(agent.model, Some("mistral-large-latest".to_string()));
assert_eq!(agent.max_turns, Some(10));
assert_eq!(agent.temperature, Some(0.5));
assert_eq!(
agent.source,
AgentSource::External("agents/external.agent.yaml".to_string())
);
}
#[tokio::test]
async fn test_resolve_external_agent_missing_file() {
let dir = tempdir().unwrap();
let mut agents = FxHashMap::default();
agents.insert(
"missing".to_string(),
AgentDef::External {
file: "nonexistent.agent.yaml".to_string(),
},
);
let workflow = crate::ast::Workflow {
schema: "nika/workflow@0.12".to_string(),
name: None,
provider: "claude".to_string(),
model: None,
mcp: None,
context: None,
include: None,
agents: Some(agents),
skills: None,
artifacts: None,
log: None,
inputs: None,
tasks: vec![],
};
let result = resolve_assets(&workflow, dir.path()).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, NikaError::ContextLoadError { .. }));
}
#[tokio::test]
async fn test_load_skill() {
let dir = tempdir().unwrap();
let skill_content = r#"# SEO Writer Skill
You are an expert SEO content writer.
## Guidelines
- Use relevant keywords naturally
- Write engaging headlines
- Structure content for readability
"#;
let skills_path = dir.path().join("skills");
std::fs::create_dir_all(&skills_path).unwrap();
std::fs::write(skills_path.join("seo.skill.md"), skill_content).unwrap();
let mut skills = FxHashMap::default();
skills.insert("seo".to_string(), "skills/seo.skill.md".to_string());
let workflow = crate::ast::Workflow {
schema: "nika/workflow@0.12".to_string(),
name: None,
provider: "claude".to_string(),
model: None,
mcp: None,
context: None,
include: None,
agents: None,
skills: Some(skills),
artifacts: None,
log: None,
inputs: None,
tasks: vec![],
};
let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
assert_eq!(assets.skills.len(), 1);
let skill = assets.get_skill("seo").unwrap();
assert!(skill.contains("SEO Writer Skill"));
assert!(skill.contains("expert SEO content writer"));
}
#[tokio::test]
async fn test_load_skill_missing_file() {
let dir = tempdir().unwrap();
let mut skills = FxHashMap::default();
skills.insert("missing".to_string(), "nonexistent.skill.md".to_string());
let workflow = crate::ast::Workflow {
schema: "nika/workflow@0.12".to_string(),
name: None,
provider: "claude".to_string(),
model: None,
mcp: None,
context: None,
include: None,
agents: None,
skills: Some(skills),
artifacts: None,
log: None,
inputs: None,
tasks: vec![],
};
let result = resolve_assets(&workflow, dir.path()).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, NikaError::ContextLoadError { .. }));
}
#[tokio::test]
async fn test_resolve_mixed_agents_and_skills() {
let dir = tempdir().unwrap();
let agent_content = r#"
system: "You are a researcher."
"#;
let agent_path = dir.path().join("agents");
std::fs::create_dir_all(&agent_path).unwrap();
std::fs::write(agent_path.join("researcher.agent.yaml"), agent_content).unwrap();
let skill1_content = "# Skill 1\nFirst skill content.";
let skill2_content = "# Skill 2\nSecond skill content.";
let skills_path = dir.path().join("skills");
std::fs::create_dir_all(&skills_path).unwrap();
std::fs::write(skills_path.join("skill1.skill.md"), skill1_content).unwrap();
std::fs::write(skills_path.join("skill2.skill.md"), skill2_content).unwrap();
let mut agents = FxHashMap::default();
agents.insert(
"researcher".to_string(),
AgentDef::External {
file: "agents/researcher.agent.yaml".to_string(),
},
);
agents.insert(
"writer".to_string(),
AgentDef::Inline {
system: "You are a writer.".to_string(),
provider: "claude".to_string(),
model: None,
max_turns: None,
temperature: None,
skills: None, },
);
let mut skills = FxHashMap::default();
skills.insert("skill1".to_string(), "skills/skill1.skill.md".to_string());
skills.insert("skill2".to_string(), "skills/skill2.skill.md".to_string());
let workflow = crate::ast::Workflow {
schema: "nika/workflow@0.12".to_string(),
name: None,
provider: "claude".to_string(),
model: None,
mcp: None,
context: None,
include: None,
agents: Some(agents),
skills: Some(skills),
artifacts: None,
log: None,
inputs: None,
tasks: vec![],
};
let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
assert_eq!(assets.agents.len(), 2);
let researcher = assets.get_agent("researcher").unwrap();
assert_eq!(researcher.system, "You are a researcher.");
assert_eq!(
researcher.source,
AgentSource::External("agents/researcher.agent.yaml".to_string())
);
let writer = assets.get_agent("writer").unwrap();
assert_eq!(writer.system, "You are a writer.");
assert_eq!(writer.source, AgentSource::Inline);
assert_eq!(assets.skills.len(), 2);
assert!(assets
.get_skill("skill1")
.unwrap()
.contains("First skill content"));
assert!(assets
.get_skill("skill2")
.unwrap()
.contains("Second skill content"));
}
#[tokio::test]
async fn test_external_agent_with_defaults() {
let dir = tempdir().unwrap();
let agent_content = r#"
system: "You are an agent with defaults."
"#;
std::fs::write(dir.path().join("minimal.agent.yaml"), agent_content).unwrap();
let mut agents = FxHashMap::default();
agents.insert(
"minimal".to_string(),
AgentDef::External {
file: "minimal.agent.yaml".to_string(),
},
);
let workflow = crate::ast::Workflow {
schema: "nika/workflow@0.12".to_string(),
name: None,
provider: "claude".to_string(),
model: None,
mcp: None,
context: None,
include: None,
agents: Some(agents),
skills: None,
artifacts: None,
log: None,
inputs: None,
tasks: vec![],
};
let assets = resolve_assets(&workflow, dir.path()).await.unwrap();
let agent = assets.get_agent("minimal").unwrap();
assert_eq!(agent.system, "You are an agent with defaults.");
assert_eq!(agent.provider, "claude"); assert!(agent.model.is_none());
assert!(agent.max_turns.is_none());
assert!(agent.temperature.is_none());
}
#[test]
fn test_resolved_agent_clone() {
let agent = ResolvedAgent {
system: "Test".to_string(),
provider: "claude".to_string(),
model: None,
max_turns: None,
temperature: None,
source: AgentSource::Inline,
};
let cloned = agent.clone();
assert_eq!(cloned.system, "Test");
}
#[test]
fn test_resolved_assets_get_methods() {
let mut assets = ResolvedAssets::new();
assets.agents.insert(
"test".to_string(),
ResolvedAgent {
system: "Test system".to_string(),
provider: "claude".to_string(),
model: None,
max_turns: None,
temperature: None,
source: AgentSource::Inline,
},
);
assets
.skills
.insert("skill".to_string(), "Skill content".to_string());
assert!(assets.get_agent("test").is_some());
assert!(assets.get_agent("nonexistent").is_none());
assert_eq!(assets.get_skill("skill"), Some("Skill content"));
assert!(assets.get_skill("nonexistent").is_none());
assert!(!assets.is_empty());
}
}