use crate::skills::LoadedSkill;
use roboticus_core::types::RiskLevel;
#[derive(Debug, Clone, serde::Serialize)]
pub struct CompositionAnalysis {
pub compatible: bool,
pub conflicts: Vec<String>,
pub suggestions: Vec<String>,
pub coverage_score: f64,
}
pub fn analyze_composition(
skill_names: &[String],
agent_description: &str,
available_skills: &[LoadedSkill],
) -> CompositionAnalysis {
let mut conflicts = Vec::new();
let mut suggestions = Vec::new();
let selected: Vec<&LoadedSkill> = skill_names
.iter()
.filter_map(|name| {
available_skills
.iter()
.find(|s| s.name().eq_ignore_ascii_case(name))
})
.collect();
if selected.is_empty() && !skill_names.is_empty() {
conflicts.push("Specified skills not found in the registry.".to_string());
return CompositionAnalysis {
compatible: false,
conflicts,
suggestions,
coverage_score: 0.0,
};
}
let risk_levels: Vec<RiskLevel> = selected
.iter()
.filter_map(|s| s.structured_manifest().map(|m| m.risk_level))
.collect();
let has_safe = risk_levels.contains(&RiskLevel::Safe);
let has_dangerous =
risk_levels.contains(&RiskLevel::Dangerous) || risk_levels.contains(&RiskLevel::Forbidden);
if has_safe && has_dangerous {
conflicts.push(
"Mixed risk levels detected: combining Safe and Dangerous/Forbidden skills \
may cause unexpected policy blocks."
.to_string(),
);
}
if risk_levels.contains(&RiskLevel::Forbidden) {
conflicts.push(
"Composition includes a Forbidden-risk skill which will be blocked by policy."
.to_string(),
);
}
let mut tool_counts: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for skill in &selected {
if let Some(manifest) = skill.structured_manifest()
&& let Some(chain) = &manifest.tool_chain
{
for step in chain {
tool_counts
.entry(step.tool_name.clone())
.or_default()
.push(skill.name().to_string());
}
}
}
for (tool, skills) in &tool_counts {
if skills.len() > 1 {
conflicts.push(format!(
"Tool '{}' is used by multiple skills ({}). Consider consolidating.",
tool,
skills.join(", ")
));
}
}
let desc_tokens: std::collections::HashSet<String> = agent_description
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| t.len() >= 4)
.map(|s| s.to_string())
.collect();
let selected_names: std::collections::HashSet<String> =
selected.iter().map(|s| s.name().to_lowercase()).collect();
for skill in available_skills {
if selected_names.contains(&skill.name().to_lowercase()) {
continue;
}
let skill_keywords: Vec<String> = skill
.triggers()
.keywords
.iter()
.map(|k| k.to_lowercase())
.collect();
let overlap_count = skill_keywords
.iter()
.filter(|k| desc_tokens.contains(k.as_str()))
.count();
if overlap_count >= 2 {
suggestions.push(format!(
"{} — {} keyword(s) match agent description",
skill.name(),
overlap_count
));
}
}
suggestions.truncate(5);
let unique_tools = tool_counts.len();
let desc_tool_refs = desc_tokens.len().max(1);
let coverage_score = (unique_tools as f64 / desc_tool_refs as f64).min(1.0);
let compatible = !risk_levels.contains(&RiskLevel::Forbidden)
&& (skill_names.is_empty() || !selected.is_empty());
CompositionAnalysis {
compatible,
conflicts,
suggestions,
coverage_score,
}
}
#[cfg(test)]
mod tests {
use super::*;
use roboticus_core::types::{InstructionSkill, SkillTrigger};
use std::path::PathBuf;
fn make_instruction_skill(name: &str, keywords: &[&str]) -> LoadedSkill {
LoadedSkill::Instruction(
InstructionSkill {
name: name.to_string(),
description: format!("Test skill: {name}"),
triggers: SkillTrigger {
keywords: keywords.iter().map(|k| k.to_string()).collect(),
tool_names: vec![],
regex_patterns: vec![],
},
priority: 5,
body: "Test body".to_string(),
version: "0.0.0".to_string(),
author: "test".to_string(),
},
"hash".to_string(),
PathBuf::from("/test"),
)
}
#[test]
fn empty_composition_is_compatible() {
let result = analyze_composition(&[], "test agent", &[]);
assert!(result.compatible);
assert!(result.conflicts.is_empty());
}
#[test]
fn unresolved_skills_flag_conflict() {
let result = analyze_composition(&["nonexistent-skill".to_string()], "test agent", &[]);
assert!(!result.compatible);
assert!(result.conflicts.iter().any(|c| c.contains("not found")));
}
#[test]
fn suggests_complementary_skills() {
let available = vec![
make_instruction_skill("code-review", &["code", "review", "lint"]),
make_instruction_skill("deploy-tool", &["deploy", "release", "staging"]),
];
let result = analyze_composition(&[], "code review and lint checking", &available);
assert!(
result.suggestions.iter().any(|s| s.contains("code-review")),
"should suggest code-review: {:?}",
result.suggestions
);
}
#[test]
fn no_suggestions_for_unrelated_description() {
let available = vec![make_instruction_skill(
"deploy-tool",
&["deploy", "release", "staging"],
)];
let result = analyze_composition(&[], "parse json files", &available);
assert!(result.suggestions.is_empty());
}
fn make_structured_skill(
name: &str,
keywords: &[&str],
risk: RiskLevel,
tools: &[&str],
) -> LoadedSkill {
use roboticus_core::types::{SkillKind, SkillManifest, ToolChainStep};
let tool_chain = if tools.is_empty() {
None
} else {
Some(
tools
.iter()
.map(|t| ToolChainStep {
tool_name: t.to_string(),
params: serde_json::json!({}),
})
.collect(),
)
};
LoadedSkill::Structured(
SkillManifest {
name: name.to_string(),
description: format!("Test structured skill: {name}"),
kind: SkillKind::Structured,
triggers: SkillTrigger {
keywords: keywords.iter().map(|k| k.to_string()).collect(),
tool_names: vec![],
regex_patterns: vec![],
},
priority: 5,
tool_chain,
policy_overrides: None,
script_path: None,
risk_level: risk,
version: "0.0.0".to_string(),
author: "test".to_string(),
},
"hash".to_string(),
PathBuf::from("/test"),
)
}
#[test]
fn forbidden_skill_marks_incompatible() {
let available = vec![make_structured_skill(
"nuke-it",
&["nuke"],
RiskLevel::Forbidden,
&[],
)];
let result = analyze_composition(&["nuke-it".to_string()], "test", &available);
assert!(!result.compatible);
assert!(result.conflicts.iter().any(|c| c.contains("Forbidden")));
}
#[test]
fn mixed_safe_dangerous_warns() {
let available = vec![
make_structured_skill("safe-skill", &["safe"], RiskLevel::Safe, &[]),
make_structured_skill("danger-skill", &["danger"], RiskLevel::Dangerous, &[]),
];
let result = analyze_composition(
&["safe-skill".to_string(), "danger-skill".to_string()],
"test",
&available,
);
assert!(
result
.conflicts
.iter()
.any(|c| c.contains("Mixed risk levels"))
);
}
#[test]
fn tool_overlap_flags_consolidation() {
let available = vec![
make_structured_skill("skill-a", &["alpha"], RiskLevel::Caution, &["read_file"]),
make_structured_skill("skill-b", &["bravo"], RiskLevel::Caution, &["read_file"]),
];
let result = analyze_composition(
&["skill-a".to_string(), "skill-b".to_string()],
"test",
&available,
);
assert!(
result
.conflicts
.iter()
.any(|c| c.contains("read_file") && c.contains("consolidat"))
);
}
#[test]
fn suggestions_capped_at_five() {
let mut available: Vec<LoadedSkill> = (0..10)
.map(|i| {
make_instruction_skill(
&format!("match-skill-{i}"),
&["deploy", "release", "staging"],
)
})
.collect();
available.push(make_instruction_skill("selected", &["selected"]));
let result = analyze_composition(
&["selected".to_string()],
"deploy release staging pipeline",
&available,
);
assert!(
result.suggestions.len() <= 5,
"suggestions should be capped at 5, got {}",
result.suggestions.len()
);
}
#[test]
fn coverage_score_bounded_zero_one() {
let available = vec![
make_structured_skill("s1", &["code"], RiskLevel::Safe, &["read_file", "bash"]),
make_structured_skill("s2", &["test"], RiskLevel::Safe, &["grep"]),
];
let result = analyze_composition(
&["s1".to_string(), "s2".to_string()],
"code testing framework",
&available,
);
assert!(
(0.0..=1.0).contains(&result.coverage_score),
"coverage_score {} out of bounds",
result.coverage_score
);
}
#[test]
fn all_skills_resolved_no_conflict() {
let available = vec![
make_structured_skill("a", &["alpha"], RiskLevel::Caution, &["bash"]),
make_structured_skill("b", &["bravo"], RiskLevel::Caution, &["grep"]),
];
let result = analyze_composition(&["a".to_string(), "b".to_string()], "test", &available);
assert!(result.compatible);
}
#[test]
fn partial_resolution_still_compatible() {
let available = vec![
make_structured_skill("real-a", &["alpha"], RiskLevel::Caution, &[]),
make_structured_skill("real-b", &["bravo"], RiskLevel::Caution, &[]),
];
let result = analyze_composition(
&[
"real-a".to_string(),
"real-b".to_string(),
"ghost".to_string(),
],
"test",
&available,
);
assert!(result.compatible);
}
}