collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use crate::config::AgentDef;

/// Task complexity levels for model routing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskComplexity {
    /// Simple: single file read, search, or short question.
    Simple,
    /// Medium: multi-file edits, debugging, moderate reasoning.
    Medium,
    /// Complex: architecture design, large refactors, deep analysis.
    Complex,
}

/// Analyzes a user message and returns the estimated complexity.
pub fn classify_complexity(message: &str) -> TaskComplexity {
    let lower = message.to_lowercase();
    let word_count = message.split_whitespace().count();

    // Complex indicators
    let complex_keywords = [
        "refactor",
        "redesign",
        "architect",
        "migrate",
        "rewrite entire",
        "across all files",
        "全面",
        "리팩터",
        "performance optimization",
        "benchmark",
        "security audit",
        "implement from scratch",
        "design pattern",
    ];
    let complex_score: usize = complex_keywords
        .iter()
        .filter(|k| lower.contains(*k))
        .count();

    // Simple indicators
    let simple_keywords = [
        "read",
        "show",
        "what is",
        "list",
        "find",
        "check",
        "status",
        "version",
        "help",
        "뭐야",
        "보여줘",
        "확인",
    ];
    let simple_score: usize = simple_keywords
        .iter()
        .filter(|k| lower.contains(*k))
        .count();

    // Heuristics
    if complex_score >= 2 || (word_count > 100 && complex_score >= 1) {
        TaskComplexity::Complex
    } else if simple_score >= 2 || word_count < 10 {
        TaskComplexity::Simple
    } else {
        TaskComplexity::Medium
    }
}

/// Find the first agent matching the given tier and return its model.
/// Falls back to `default_model` if no agent with that tier is registered.
fn model_for_tier<'a>(tier: &str, agents: &'a [AgentDef], default_model: &'a str) -> &'a str {
    agents
        .iter()
        .find(|a| a.tier.as_deref() == Some(tier) && a.model != "default")
        .map(|a| a.model.as_str())
        .unwrap_or(default_model)
}

/// Select the best model for a given complexity level using the registered agent tiers.
///
/// - `Simple`  → first agent with `tier: light`  (e.g. `ask`)
/// - `Medium`  → `default_model` unchanged
/// - `Complex` → first agent with `tier: heavy`  (e.g. `architect`)
///
/// Falls back to `default_model` when no matching tier agent is found.
pub fn select_model<'a>(
    complexity: TaskComplexity,
    default_model: &'a str,
    agents: &'a [AgentDef],
) -> &'a str {
    match complexity {
        TaskComplexity::Simple => model_for_tier("light", agents, default_model),
        TaskComplexity::Medium => default_model,
        TaskComplexity::Complex => model_for_tier("heavy", agents, default_model),
    }
}

/// Model routing decision with explanation.
#[derive(Debug, Clone)]
pub struct RoutingDecision {
    pub model: String,
    pub complexity: TaskComplexity,
    pub reason: String,
}

/// Full routing pipeline: classify + select + explain.
pub fn route(
    message: &str,
    default_model: &str,
    auto_route: bool,
    agents: &[AgentDef],
) -> RoutingDecision {
    let complexity = classify_complexity(message);

    if !auto_route {
        return RoutingDecision {
            model: default_model.to_string(),
            complexity,
            reason: "Auto-routing disabled".to_string(),
        };
    }

    let selected = select_model(complexity, default_model, agents);
    let reason = match complexity {
        TaskComplexity::Simple => format!("Simple task → using light-tier model ({selected})"),
        TaskComplexity::Medium => format!("Medium complexity → using default model ({selected})"),
        TaskComplexity::Complex => format!("Complex task → using heavy-tier model ({selected})"),
    };

    RoutingDecision {
        model: selected.to_string(),
        complexity,
        reason,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_simple_classification() {
        assert_eq!(
            classify_complexity("show me the file"),
            TaskComplexity::Simple,
        );
        assert_eq!(classify_complexity("what is this?"), TaskComplexity::Simple,);
    }

    #[test]
    fn test_complex_classification() {
        assert_eq!(
            classify_complexity("refactor the entire architecture and redesign the module system"),
            TaskComplexity::Complex,
        );
    }

    fn make_agents(light: &str, heavy: &str) -> Vec<AgentDef> {
        vec![
            AgentDef {
                name: "ask".into(),
                model: light.into(),
                tier: Some("light".into()),
                ..Default::default()
            },
            AgentDef {
                name: "code".into(),
                model: "glm-4.7".into(),
                tier: Some("medium".into()),
                ..Default::default()
            },
            AgentDef {
                name: "architect".into(),
                model: heavy.into(),
                tier: Some("heavy".into()),
                ..Default::default()
            },
        ]
    }

    #[test]
    fn test_model_selection_tier() {
        let agents = make_agents("glm-5-turbo", "glm-5.1");
        assert_eq!(
            select_model(TaskComplexity::Simple, "glm-4.7", &agents),
            "glm-5-turbo"
        );
        assert_eq!(
            select_model(TaskComplexity::Complex, "glm-4.7", &agents),
            "glm-5.1"
        );
        assert_eq!(
            select_model(TaskComplexity::Medium, "glm-4.7", &agents),
            "glm-4.7"
        );
    }

    #[test]
    fn test_model_selection_fallback() {
        // No agents registered → fall back to default_model
        assert_eq!(
            select_model(TaskComplexity::Simple, "glm-4.7", &[]),
            "glm-4.7"
        );
        assert_eq!(
            select_model(TaskComplexity::Complex, "glm-4.7", &[]),
            "glm-4.7"
        );
    }
}