mars-agents 0.4.7-rc.1

Agent package manager for .agents/ directories
Documentation
use std::collections::BTreeMap;
use std::path::Path;

use indexmap::IndexMap;

use crate::build::bundle::{ExecutionPolicy, Routing};
use crate::compiler::agents::{
    AgentProfile, ApprovalMode, EffortLevel, HarnessKind, OverrideFields, SandboxMode,
};
use crate::error::{ConfigError, MarsError};
use crate::models::{self, ModelAlias, ModelsCache};

pub struct PolicyInput<'a> {
    pub project_root: &'a Path,
    pub profile: &'a AgentProfile,
    pub model_override: Option<&'a str>,
    pub harness_override: Option<&'a str>,
    pub effort_override: Option<&'a str>,
    pub approval_override: Option<&'a str>,
    pub sandbox_override: Option<&'a str>,
}

pub struct ResolvedPolicy {
    pub routing: Routing,
    pub execution_policy: ExecutionPolicy,
    pub provenance: BTreeMap<String, String>,
    pub warnings: Vec<String>,
}

pub fn resolve_policy(input: PolicyInput<'_>) -> Result<ResolvedPolicy, MarsError> {
    let mut warnings = Vec::new();
    let mut provenance = BTreeMap::new();

    let model_config = load_model_resolution_config(input.project_root)?;
    let aliases = model_config.aliases;
    let cache = load_models_cache(input.project_root)?;

    let (model_token, model_source) = match input.model_override {
        Some(model) => (model.to_string(), "cli".to_string()),
        None => match input.profile.model.as_deref() {
            Some(model) => (model.to_string(), "profile".to_string()),
            None => {
                return Err(MarsError::Config(ConfigError::Invalid {
                    message: "launch-bundle requires a model (set `model:` in the agent profile or pass `--model`)"
                        .to_string(),
                }));
            }
        },
    };

    let alias = aliases.get(&model_token);
    let model = if let Some(alias) = alias {
        match models::resolve_model_id_for_alias(alias, &cache) {
            Some(model_id) => model_id,
            None => {
                warnings.push(format!(
                    "model alias `{model_token}` did not resolve from cached catalog; using token as model id"
                ));
                model_token.clone()
            }
        }
    } else {
        model_token.clone()
    };
    provenance.insert("model_source".to_string(), model_source);

    let provider = alias
        .and_then(|entry| models::resolve_provider_for_alias(entry, &cache))
        .or_else(|| models::infer_provider_from_model_id(&model).map(str::to_string));

    let profile_harness = input.profile.harness.as_ref().map(harness_kind_to_str);
    let alias_harness = alias.and_then(|entry| entry.harness.as_deref());
    let provider_harness = provider
        .as_deref()
        .and_then(models::harness::preferred_harness_for_provider);
    let config_default_harness = match model_config.default_harness.as_deref() {
        Some(value) => match normalize_harness_name(value) {
            Some(valid) => Some(valid.to_string()),
            None => {
                warnings.push(format!(
                    "settings.default_harness `{value}` is invalid; expected one of: claude, codex, opencode, cursor, pi"
                ));
                None
            }
        },
        None => None,
    };

    let model_from_cli = input.model_override.is_some();
    let (harness, harness_source) = if let Some(harness) = input.harness_override {
        (harness.to_string(), "cli")
    } else if model_from_cli {
        if let Some(harness) = alias_harness {
            (harness.to_string(), "alias")
        } else if let Some(harness) = provider_harness {
            (harness, "provider")
        } else if let Some(harness) = config_default_harness {
            (harness, "config")
        } else {
            warnings.push(
                "harness not set by CLI/profile/alias/provider/config; defaulting to `claude`"
                    .to_string(),
            );
            ("claude".to_string(), "default")
        }
    } else if let Some(harness) = profile_harness {
        (harness.to_string(), "profile")
    } else if let Some(harness) = alias_harness {
        (harness.to_string(), "alias")
    } else if let Some(harness) = provider_harness {
        (harness, "provider")
    } else if let Some(harness) = config_default_harness {
        (harness, "config")
    } else {
        warnings.push(
            "harness not set by CLI/profile/alias/provider/config; defaulting to `claude`"
                .to_string(),
        );
        ("claude".to_string(), "default")
    };
    provenance.insert("harness_source".to_string(), harness_source.to_string());
    if harness == "cursor" {
        warnings.push(
            "Cursor is an experimental launch-bundle target. The contract may change without notice.".to_string(),
        );
        provenance.insert("harness_stability".to_string(), "experimental".to_string());
    }
    let resolved_harness = HarnessKind::from_str(&harness).ok_or_else(|| {
        MarsError::Config(ConfigError::Invalid {
            message: format!(
                "resolved harness `{harness}` is invalid; expected one of: claude, codex, opencode, cursor, pi"
            ),
        })
    })?;
    let matched_harness_override = input.profile.harness_overrides.get(&resolved_harness);
    let native_config = matched_harness_override
        .and_then(|fields| fields.native_config.clone())
        .filter(|map| !map.is_empty());
    if native_config.is_some() {
        provenance.insert(
            "native_config_source".to_string(),
            "profile-harness-override".to_string(),
        );
    }

    let (effort, effort_source) = resolve_effort(&input, alias, matched_harness_override);
    provenance.insert("effort_source".to_string(), effort_source);

    let (approval, approval_source) = resolve_approval(&input, matched_harness_override);
    provenance.insert("approval_source".to_string(), approval_source);

    let (sandbox, sandbox_source) = resolve_sandbox(&input, matched_harness_override);
    provenance.insert("sandbox_source".to_string(), sandbox_source);

    let (autocompact, autocompact_source) =
        resolve_autocompact(&input, alias, matched_harness_override);
    provenance.insert("autocompact_source".to_string(), autocompact_source);

    let (autocompact_pct, autocompact_pct_source) =
        resolve_autocompact_pct(&input, alias, matched_harness_override);
    provenance.insert("autocompact_pct_source".to_string(), autocompact_pct_source);

    Ok(ResolvedPolicy {
        routing: Routing {
            model,
            model_token,
            harness,
        },
        execution_policy: ExecutionPolicy {
            effort,
            approval,
            sandbox,
            autocompact,
            autocompact_pct,
            timeout: None,
            native_config,
        },
        provenance,
        warnings,
    })
}

fn load_models_cache(project_root: &Path) -> Result<ModelsCache, MarsError> {
    let mars_dir = project_root.join(".mars");
    models::read_cache(&mars_dir)
}

struct ModelResolutionConfig {
    aliases: IndexMap<String, ModelAlias>,
    default_harness: Option<String>,
}

fn load_model_resolution_config(project_root: &Path) -> Result<ModelResolutionConfig, MarsError> {
    let mut merged = models::builtin_aliases();
    let mut default_harness = None;

    let merged_path = project_root.join(".mars").join("models-merged.json");
    if let Ok(content) = std::fs::read_to_string(&merged_path)
        && let Ok(cached) = serde_json::from_str::<IndexMap<String, ModelAlias>>(&content)
    {
        for (name, alias) in cached {
            merged.insert(name, alias);
        }
    }

    match crate::config::load(project_root) {
        Ok(config) => {
            default_harness = config.settings.default_harness.clone();
            for (name, alias) in &config.models {
                merged.insert(name.clone(), alias.clone());
            }
        }
        Err(MarsError::Config(ConfigError::NotFound { .. })) => {}
        Err(err) => return Err(err),
    }

    Ok(ModelResolutionConfig {
        aliases: merged,
        default_harness,
    })
}

fn normalize_harness_name(value: &str) -> Option<&'static str> {
    match value.trim() {
        "claude" => Some("claude"),
        "codex" => Some("codex"),
        "opencode" => Some("opencode"),
        "cursor" => Some("cursor"),
        "pi" => Some("pi"),
        _ => None,
    }
}

fn resolve_effort(
    input: &PolicyInput<'_>,
    alias: Option<&ModelAlias>,
    matched_harness_override: Option<&OverrideFields>,
) -> (Option<String>, String) {
    if let Some(effort) = input.effort_override {
        return (Some(effort.to_string()), "cli".to_string());
    }
    if let Some(effort) = matched_harness_override.and_then(|entry| entry.effort.as_ref()) {
        return (
            Some(effort_level_to_str(effort).to_string()),
            "profile-harness-override".to_string(),
        );
    }
    if let Some(effort) = input.profile.effort.as_ref() {
        return (
            Some(effort_level_to_str(effort).to_string()),
            "profile".to_string(),
        );
    }
    if let Some(effort) = alias.and_then(|entry| entry.default_effort.clone()) {
        return (Some(effort), "alias".to_string());
    }
    (None, "unset".to_string())
}

fn resolve_approval(
    input: &PolicyInput<'_>,
    matched_harness_override: Option<&OverrideFields>,
) -> (Option<String>, String) {
    if let Some(approval) = input.approval_override {
        return (Some(approval.to_string()), "cli".to_string());
    }
    if let Some(approval) = matched_harness_override.and_then(|entry| entry.approval.as_ref()) {
        return (
            Some(approval_mode_to_str(approval).to_string()),
            "profile-harness-override".to_string(),
        );
    }
    if let Some(approval) = input.profile.approval.as_ref() {
        return (
            Some(approval_mode_to_str(approval).to_string()),
            "profile".to_string(),
        );
    }
    (None, "unset".to_string())
}

fn resolve_sandbox(
    input: &PolicyInput<'_>,
    matched_harness_override: Option<&OverrideFields>,
) -> (Option<String>, String) {
    if let Some(sandbox) = input.sandbox_override {
        return (Some(sandbox.to_string()), "cli".to_string());
    }
    if let Some(sandbox) = matched_harness_override.and_then(|entry| entry.sandbox.as_ref()) {
        return (
            Some(sandbox_mode_to_str(sandbox).to_string()),
            "profile-harness-override".to_string(),
        );
    }
    if let Some(sandbox) = input.profile.sandbox.as_ref() {
        return (
            Some(sandbox_mode_to_str(sandbox).to_string()),
            "profile".to_string(),
        );
    }
    (None, "unset".to_string())
}

fn resolve_autocompact(
    input: &PolicyInput<'_>,
    alias: Option<&ModelAlias>,
    matched_harness_override: Option<&OverrideFields>,
) -> (Option<u32>, String) {
    if let Some(autocompact) = matched_harness_override.and_then(|entry| entry.autocompact) {
        return (Some(autocompact), "profile-harness-override".to_string());
    }
    if let Some(autocompact) = input.profile.autocompact {
        return (Some(autocompact), "profile".to_string());
    }
    if let Some(autocompact) = alias.and_then(|entry| entry.autocompact) {
        return (Some(autocompact), "alias".to_string());
    }
    (None, "unset".to_string())
}

fn resolve_autocompact_pct(
    input: &PolicyInput<'_>,
    alias: Option<&ModelAlias>,
    matched_harness_override: Option<&OverrideFields>,
) -> (Option<u8>, String) {
    if let Some(autocompact_pct) = matched_harness_override.and_then(|entry| entry.autocompact_pct)
    {
        return (
            Some(autocompact_pct),
            "profile-harness-override".to_string(),
        );
    }
    if let Some(autocompact_pct) = input.profile.autocompact_pct {
        return (Some(autocompact_pct), "profile".to_string());
    }
    if let Some(autocompact_pct) = alias.and_then(|entry| entry.autocompact_pct) {
        return (Some(autocompact_pct), "alias".to_string());
    }
    (None, "unset".to_string())
}

fn harness_kind_to_str(harness: &HarnessKind) -> &'static str {
    match harness {
        HarnessKind::Claude => "claude",
        HarnessKind::Codex => "codex",
        HarnessKind::OpenCode => "opencode",
        HarnessKind::Cursor => "cursor",
        HarnessKind::Pi => "pi",
    }
}

fn effort_level_to_str(effort: &EffortLevel) -> &'static str {
    match effort {
        EffortLevel::Low => "low",
        EffortLevel::Medium => "medium",
        EffortLevel::High => "high",
        EffortLevel::XHigh => "xhigh",
    }
}

fn approval_mode_to_str(mode: &ApprovalMode) -> &'static str {
    match mode {
        ApprovalMode::Default => "default",
        ApprovalMode::Auto => "auto",
        ApprovalMode::Confirm => "confirm",
        ApprovalMode::Yolo => "yolo",
    }
}

fn sandbox_mode_to_str(mode: &SandboxMode) -> &'static str {
    match mode {
        SandboxMode::Default => "default",
        SandboxMode::ReadOnly => "read-only",
        SandboxMode::WorkspaceWrite => "workspace-write",
        SandboxMode::DangerFullAccess => "danger-full-access",
    }
}