roboticus-agent 0.10.0

Agent core with ReAct loop, policy engine, injection defense, memory system, and skill loader
Documentation
//! Subagent composition analysis — compatibility checks and skill suggestions.
//!
//! Called when composing a new subagent to validate that the skill combination
//! makes sense: no policy conflicts, no redundant tools, and suggests
//! complementary skills that might improve coverage.

use crate::skills::LoadedSkill;
use roboticus_core::types::RiskLevel;

/// Result of analyzing a skill composition for a subagent.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CompositionAnalysis {
    /// Whether the composition has no blocking conflicts.
    pub compatible: bool,
    /// Blocking or warning-level conflicts found.
    pub conflicts: Vec<String>,
    /// Complementary skills the operator might want to include.
    pub suggestions: Vec<String>,
    /// Rough coverage score (0.0–1.0) based on tool chain diversity.
    pub coverage_score: f64,
}

/// Analyze a set of skills for composition compatibility.
///
/// Checks:
/// 1. **Policy conflicts**: Mixed risk levels (e.g., Safe + Dangerous) flag a warning
/// 2. **Tool overlap**: Multiple skills using the same tools suggest consolidation
/// 3. **Complementary skills**: Skills whose keywords overlap with the agent description
///    but aren't included are suggested
pub fn analyze_composition(
    skill_names: &[String],
    agent_description: &str,
    available_skills: &[LoadedSkill],
) -> CompositionAnalysis {
    let mut conflicts = Vec::new();
    let mut suggestions = Vec::new();

    // Resolve selected skills from the full registry
    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,
        };
    }

    // 1. Policy conflict detection — mixed risk levels
    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(),
        );
    }

    // 2. Tool overlap detection
    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(", ")
            ));
        }
    }

    // 3. Complementary skill suggestions
    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
            ));
        }
    }

    // Cap suggestions to avoid noise
    suggestions.truncate(5);

    // Coverage score: unique tools / total tools referenced in description
    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);

    // Compatible if no Forbidden skills and skill_names could be resolved
    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() {
        // Create 10 available skills that all match the description
        let mut available: Vec<LoadedSkill> = (0..10)
            .map(|i| {
                make_instruction_skill(
                    &format!("match-skill-{i}"),
                    &["deploy", "release", "staging"],
                )
            })
            .collect();
        // Also add the selected skill so it's resolved
        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, &[]),
        ];
        // "ghost" doesn't exist, but real-a and real-b do
        let result = analyze_composition(
            &[
                "real-a".to_string(),
                "real-b".to_string(),
                "ghost".to_string(),
            ],
            "test",
            &available,
        );
        // The function only checks `selected.is_empty() && !skill_names.is_empty()`
        // Since 2 of 3 resolved, selected is non-empty, so compatible = true (no forbidden)
        assert!(result.compatible);
    }
}