Skip to main content

mars_agents/build/policy/
mod.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use indexmap::IndexMap;
5
6use crate::build::bundle::ExecutionPolicy;
7use crate::compiler::agents::AgentProfile;
8use crate::config::{AgentOverlay, EffectiveProjectConfig, ModelPolicyMatchType, ModelPolicyRule};
9use crate::error::{ConfigError, MarsError};
10use crate::harness::host::{CapabilityCollectionOptions, CapabilitySession};
11use crate::models::{self, ModelAlias};
12use crate::routing;
13
14mod execution;
15mod harness;
16mod model;
17mod runnable;
18use runnable::CursorEffortOutcome;
19
20struct SessionProbeResolver<'a> {
21    session: &'a mut CapabilitySession,
22}
23
24impl crate::routing::ProbeResolver for SessionProbeResolver<'_> {
25    fn opencode_probe_result(&mut self) -> Option<crate::models::probes::OpenCodeProbeResult> {
26        self.session.opencode_probe_result()
27    }
28
29    fn pi_probe_result(&mut self) -> Option<crate::models::probes::PiProbeResult> {
30        self.session.pi_probe_result()
31    }
32
33    fn cursor_probe_result(&mut self) -> Option<crate::models::probes::CursorProbeResult> {
34        self.session.cursor_probe_result()
35    }
36}
37
38pub struct PolicyInput<'a> {
39    pub project_root: &'a Path,
40    pub runtime_aliases: &'a IndexMap<String, ModelAlias>,
41    pub agent: Option<&'a str>,
42    pub profile: &'a AgentProfile,
43    pub model_override: Option<&'a str>,
44    pub harness_override: Option<&'a str>,
45    pub effort_override: Option<&'a str>,
46    pub approval_override: Option<&'a str>,
47    pub sandbox_override: Option<&'a str>,
48    pub models_refresh: models::ModelsRefreshControl,
49}
50
51pub struct ResolvedPolicy {
52    pub routing: crate::build::bundle::Routing,
53    pub execution_policy: ExecutionPolicy,
54    pub provenance: BTreeMap<String, String>,
55    pub warnings: Vec<String>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub(super) enum PolicySource {
60    Cli,
61    Overlay,
62    OverlayModelPolicy,
63    Profile,
64    ProfileModelPolicy,
65    SettingsModelPolicy,
66    Alias,
67    Project,
68    ConfigOrder,
69    Config,
70    Provider,
71    Default,
72    ProfileHarnessOverride,
73    Unset,
74}
75
76impl PolicySource {
77    pub(super) fn label(self) -> &'static str {
78        match self {
79            Self::Cli => "cli",
80            Self::Overlay => "overlay",
81            Self::OverlayModelPolicy => "overlay-model-policy",
82            Self::Profile => "profile",
83            Self::ProfileModelPolicy => "profile-model-policy",
84            Self::SettingsModelPolicy => "settings-model-policy",
85            Self::Alias => "alias",
86            Self::Project => "project",
87            Self::ConfigOrder => "config-order",
88            Self::Config => "config",
89            Self::Provider => "provider",
90            Self::Default => "default",
91            Self::ProfileHarnessOverride => "profile-harness-override",
92            Self::Unset => "unset",
93        }
94    }
95
96    pub(super) fn precedence_rank(self) -> u8 {
97        match self {
98            Self::Cli => 5,
99            Self::Overlay | Self::OverlayModelPolicy => 4,
100            Self::Profile | Self::ProfileModelPolicy | Self::ProfileHarnessOverride => 3,
101            Self::SettingsModelPolicy | Self::Project | Self::Config => 2,
102            Self::Alias => 1,
103            Self::Unset | Self::ConfigOrder | Self::Provider | Self::Default => 0,
104        }
105    }
106}
107
108impl From<crate::routing::RouteSource> for PolicySource {
109    fn from(source: crate::routing::RouteSource) -> Self {
110        match source {
111            crate::routing::RouteSource::Cli => Self::Cli,
112            crate::routing::RouteSource::Profile => Self::Profile,
113            crate::routing::RouteSource::Alias => Self::Alias,
114            crate::routing::RouteSource::ConfigOrder => Self::ConfigOrder,
115            crate::routing::RouteSource::ConfigDefault => Self::Config,
116            crate::routing::RouteSource::Provider => Self::Provider,
117            crate::routing::RouteSource::HardcodedDefault => Self::Default,
118        }
119    }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub(super) enum PolicyLayer {
124    Overlay,
125    Profile,
126    Settings,
127}
128
129impl PolicyLayer {
130    fn matched_rule_layer_label(self) -> &'static str {
131        match self {
132            Self::Overlay => "overlay",
133            Self::Profile => "profile",
134            Self::Settings => "settings",
135        }
136    }
137
138    pub(super) fn field_source(self) -> PolicySource {
139        match self {
140            Self::Overlay => PolicySource::OverlayModelPolicy,
141            Self::Profile => PolicySource::ProfileModelPolicy,
142            Self::Settings => PolicySource::SettingsModelPolicy,
143        }
144    }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub(super) struct MatchedPolicyRuleRef {
149    pub(super) layer: PolicyLayer,
150    pub(super) index: usize,
151}
152
153impl MatchedPolicyRuleRef {
154    pub(super) fn label(self) -> String {
155        format!("{}:{}", self.layer.matched_rule_layer_label(), self.index)
156    }
157}
158
159#[derive(Debug, Clone)]
160pub(super) struct ResolvedField<T> {
161    pub(super) value: T,
162    pub(super) source: PolicySource,
163    pub(super) matched_rule: Option<MatchedPolicyRuleRef>,
164}
165
166#[derive(Debug, Clone)]
167pub(super) struct MatchedModelPolicy {
168    pub(super) layer: PolicyLayer,
169    pub(super) index: usize,
170    pub(super) rule: ModelPolicyRule,
171}
172
173impl MatchedModelPolicy {
174    pub(super) fn matched_rule_ref(&self) -> MatchedPolicyRuleRef {
175        MatchedPolicyRuleRef {
176            layer: self.layer,
177            index: self.index,
178        }
179    }
180}
181
182pub fn resolve_policy(
183    effective_config: &EffectiveProjectConfig,
184    input: PolicyInput<'_>,
185) -> Result<ResolvedPolicy, MarsError> {
186    let mut warnings = Vec::new();
187    let mut provenance = BTreeMap::new();
188
189    let aliases = input.runtime_aliases;
190    let overlay = input
191        .agent
192        .and_then(|name| effective_config.agents.get(name));
193    let settings_model_policies = &effective_config.settings.model_policies;
194    let linked_harnesses = effective_config.settings.linked_harnesses();
195    let default_harness_order = crate::harness::registry::default_harness_order_names();
196    let harness_order = effective_config
197        .settings
198        .harness_order
199        .as_deref()
200        .unwrap_or(default_harness_order.as_slice());
201    let mars_dir = input.project_root.join(".mars");
202    let ttl_hours = effective_config.settings.models_cache_ttl_hours;
203    let (cache, catalog_outcome) =
204        match models::ensure_fresh(&mars_dir, ttl_hours, input.models_refresh.catalog_mode) {
205            Ok(pair) => pair,
206            Err(err) => {
207                warnings.push(format!("models cache unavailable: {err}"));
208                (
209                    model::load_models_cache(input.project_root).unwrap_or(models::ModelsCache {
210                        models: Vec::new(),
211                        fetched_at: None,
212                    }),
213                    models::RefreshOutcome::Offline,
214                )
215            }
216        };
217    if let models::RefreshOutcome::StaleFallback { reason } = catalog_outcome {
218        warnings.push(format!("models cache: {reason}"));
219    }
220    let catalog_slugs = models::catalog_model_slugs(&cache);
221    let resolved_model = model::resolve_model(
222        &input,
223        effective_config.settings.default_model.as_deref(),
224        overlay,
225        aliases,
226        &cache,
227    )?;
228
229    warnings.extend(resolved_model.warnings.iter().cloned());
230    provenance.insert(
231        "model_source".to_string(),
232        resolved_model.model_source.label().to_string(),
233    );
234    let matched_policy = match_model_policy(
235        effective_policies(
236            overlay,
237            &input.profile.model_policies,
238            settings_model_policies,
239        ),
240        &resolved_model.model,
241        &resolved_model.model_token,
242    );
243
244    let mut capability_session = CapabilitySession::collect(&CapabilityCollectionOptions {
245        offline: crate::models::is_mars_offline(),
246        probe_refresh: input.models_refresh.probe_refresh,
247    });
248    let installed_harnesses = capability_session.installed_harnesses();
249    let harness_resolution = {
250        let mut probe_resolver = SessionProbeResolver {
251            session: &mut capability_session,
252        };
253        harness::resolve_harness(
254            &input,
255            resolved_model.alias,
256            overlay,
257            matched_policy.as_ref(),
258            harness::HarnessEvidence {
259                routing: routing::RoutingEvidence {
260                    model_id: &resolved_model.model,
261                    provider_for_order: resolved_model.provider_for_order.as_deref(),
262                    provider_constraint: resolved_model.provider_constraint.as_deref(),
263                    settings_provider_order: effective_config.settings.provider_order.as_deref(),
264                    config_default_harness: effective_config.settings.default_harness.as_deref(),
265                    settings_harness_order: Some(harness_order),
266                    installed_harnesses: &installed_harnesses,
267                    linked_harnesses: (!linked_harnesses.is_empty())
268                        .then_some(linked_harnesses.as_slice()),
269                    opencode_probe_result: None,
270                    pi_probe_result: None,
271                    cursor_probe_result: None,
272                    catalog_model_slugs: Some(catalog_slugs.as_slice()),
273                },
274                model_token: &resolved_model.model_token,
275                model_source: resolved_model.model_source,
276            },
277            &mut probe_resolver,
278            crate::models::harness::native_harness_authenticated,
279        )?
280    };
281
282    warnings.extend(harness_resolution.warnings);
283    provenance.insert(
284        "harness_source".to_string(),
285        harness_resolution.harness.source.label().to_string(),
286    );
287    provenance.insert(
288        "selection_kind".to_string(),
289        harness_resolution
290            .route_trace
291            .selected_selection_kind()
292            .label()
293            .to_string(),
294    );
295    provenance.insert(
296        "match_evidence".to_string(),
297        harness_resolution
298            .route_trace
299            .selected_match_evidence()
300            .label()
301            .to_string(),
302    );
303    provenance.insert(
304        "candidates_tried".to_string(),
305        harness_resolution.candidates_tried.join(","),
306    );
307    if harness_resolution.harness.source == PolicySource::ConfigOrder
308        && let Some(position) = harness_resolution.harness_order_position
309    {
310        provenance.insert("harness_order_position".to_string(), position.to_string());
311    }
312    if harness_resolution.is_experimental {
313        warnings.push(
314            "Cursor is an experimental launch-bundle target. The contract may change without notice.".to_string(),
315        );
316        provenance.insert("harness_stability".to_string(), "experimental".to_string());
317    }
318
319    let matched_harness_override = input
320        .profile
321        .harness_overrides
322        .get(&harness_resolution.resolved_harness);
323    let execution_resolution = execution::resolve_execution_policy(
324        &input,
325        resolved_model.alias,
326        overlay,
327        matched_policy.as_ref(),
328        matched_harness_override,
329    );
330
331    provenance.insert(
332        "effort_source".to_string(),
333        execution_resolution.effort.source.label().to_string(),
334    );
335    provenance.insert(
336        "approval_source".to_string(),
337        execution_resolution.approval.source.label().to_string(),
338    );
339    provenance.insert(
340        "sandbox_source".to_string(),
341        execution_resolution.sandbox.source.label().to_string(),
342    );
343    provenance.insert(
344        "autocompact_source".to_string(),
345        execution_resolution.autocompact.source.label().to_string(),
346    );
347    provenance.insert(
348        "autocompact_pct_source".to_string(),
349        execution_resolution
350            .autocompact_pct
351            .source
352            .label()
353            .to_string(),
354    );
355    if execution_resolution.native_config.is_some() {
356        provenance.insert(
357            "native_config_source".to_string(),
358            PolicySource::ProfileHarnessOverride.label().to_string(),
359        );
360    }
361    let matched_rule = harness_resolution
362        .harness
363        .matched_rule
364        .or(execution_resolution.effort.matched_rule)
365        .or(execution_resolution.approval.matched_rule)
366        .or(execution_resolution.sandbox.matched_rule)
367        .or(execution_resolution.autocompact.matched_rule)
368        .or(execution_resolution.autocompact_pct.matched_rule)
369        .or_else(|| {
370            matched_policy
371                .as_ref()
372                .map(MatchedModelPolicy::matched_rule_ref)
373        });
374    if let Some(matched_rule) = matched_rule {
375        provenance.insert("matched_policy_rule".to_string(), matched_rule.label());
376    }
377
378    let selected_harness = harness_resolution.harness.value.clone();
379    let needs_opencode_probe = selected_harness.eq_ignore_ascii_case("opencode");
380    let needs_pi_probe = selected_harness.eq_ignore_ascii_case("pi");
381    let needs_cursor_probe = selected_harness.eq_ignore_ascii_case("cursor");
382    let opencode_probe_result = needs_opencode_probe
383        .then(|| capability_session.opencode_probe_result())
384        .flatten();
385    let pi_probe_result = needs_pi_probe
386        .then(|| capability_session.pi_probe_result())
387        .flatten();
388    let cursor_probe_result = needs_cursor_probe
389        .then(|| capability_session.cursor_probe_result())
390        .flatten();
391    let (
392        effective_model,
393        effective_model_token,
394        effective_provider_constraint,
395        effective_provider_for_order,
396    ) = if harness_resolution.model_override.is_some() {
397        (String::new(), String::new(), None::<String>, None::<String>)
398    } else {
399        (
400            resolved_model.model.clone(),
401            resolved_model.model_token.clone(),
402            resolved_model.provider_constraint.clone(),
403            resolved_model.provider_for_order.clone(),
404        )
405    };
406
407    let routing_resolution = runnable::resolve_routing(runnable::RoutingInput {
408        model: effective_model,
409        model_token: effective_model_token,
410        harness: selected_harness.clone(),
411        selection_kind: harness_resolution
412            .route_trace
413            .selected_selection_kind()
414            .label()
415            .to_string(),
416        match_evidence: harness_resolution
417            .route_trace
418            .selected_match_evidence()
419            .label()
420            .to_string(),
421        provider_constraint: effective_provider_constraint.as_deref(),
422        provider_for_order: effective_provider_for_order.as_deref(),
423        settings_provider_order: effective_config.settings.provider_order.as_deref(),
424        effort: execution_resolution.effort.value.clone(),
425        opencode_probe_result: opencode_probe_result.as_ref(),
426        pi_probe_result: pi_probe_result.as_ref(),
427        cursor_probe_result: cursor_probe_result.as_ref(),
428        alias_resolution_failed: resolved_model.alias_resolution_failed,
429        route_trace: harness_resolution.route_trace,
430    })?;
431
432    warnings.extend(routing_resolution.warnings);
433
434    let mut effort = execution_resolution.effort.value;
435    if routing_resolution.effort_consumed {
436        effort = None;
437        provenance.insert(
438            "effort_applied_to_harness_model".to_string(),
439            "true".to_string(),
440        );
441    } else if harness_resolution
442        .harness
443        .value
444        .eq_ignore_ascii_case("cursor")
445        && let Some(cursor_effort) = effort
446            .as_deref()
447            .map(str::trim)
448            .filter(|value| !value.is_empty())
449    {
450        let actor = input
451            .agent
452            .map(|agent| format!("agent {agent}"))
453            .unwrap_or_else(|| "requested model".to_string());
454        let message = match routing_resolution.cursor_effort_outcome {
455            CursorEffortOutcome::NoEffortVariant => Some(format!(
456                "{actor} selected effort {cursor_effort}; Cursor model {} has no {cursor_effort} variant; try --effort medium/none.",
457                resolved_model.model
458            )),
459            CursorEffortOutcome::ProbeUnavailable => Some(format!(
460                "{actor} selected effort {cursor_effort}; Cursor model list was unavailable; rerun without --no-refresh-models or with --refresh-models."
461            )),
462            CursorEffortOutcome::ProbeFailed { error } => {
463                let detail = error
464                    .as_deref()
465                    .map(str::trim)
466                    .filter(|value| !value.is_empty())
467                    .map(|value| format!(" ({value})"))
468                    .unwrap_or_default();
469                Some(format!(
470                    "{actor} selected effort {cursor_effort}; Cursor model list probe failed{detail}."
471                ))
472            }
473            CursorEffortOutcome::ProbeReturnedNoSlugs => Some(format!(
474                "{actor} selected effort {cursor_effort}; Cursor model list returned no model slugs; rerun without --no-refresh-models or with --refresh-models."
475            )),
476            CursorEffortOutcome::NoModelPrefixMatch => Some(format!(
477                "{actor} selected effort {cursor_effort}; Cursor model catalog has no matching model slug for `{}`.",
478                resolved_model.model
479            )),
480            CursorEffortOutcome::NotRequested | CursorEffortOutcome::Applied => None,
481        };
482
483        if let Some(message) = message {
484            return Err(MarsError::Config(ConfigError::Invalid { message }));
485        }
486    }
487
488    Ok(ResolvedPolicy {
489        routing: routing_resolution.routing,
490        execution_policy: ExecutionPolicy {
491            effort,
492            approval: execution_resolution.approval.value,
493            sandbox: execution_resolution.sandbox.value,
494            autocompact: execution_resolution.autocompact.value,
495            autocompact_pct: execution_resolution.autocompact_pct.value,
496            timeout: None,
497            native_config: execution_resolution.native_config,
498            codex_rules: None,
499        },
500        provenance,
501        warnings,
502    })
503}
504
505pub(super) fn policy_override_string(rule: &ModelPolicyRule, key: &str) -> Option<String> {
506    let value = rule
507        .overrides
508        .get(serde_yaml::Value::String(key.to_string()))?
509        .as_str()?;
510    let trimmed = value.trim();
511    (!trimmed.is_empty()).then(|| trimmed.to_string())
512}
513
514pub(super) fn policy_override_u32(rule: &ModelPolicyRule, key: &str) -> Option<u32> {
515    let value = rule
516        .overrides
517        .get(serde_yaml::Value::String(key.to_string()))?;
518    match value {
519        serde_yaml::Value::Number(number) => {
520            let parsed = number.as_u64()?;
521            u32::try_from(parsed).ok()
522        }
523        _ => None,
524    }
525}
526
527pub(super) fn policy_override_u8(rule: &ModelPolicyRule, key: &str) -> Option<u8> {
528    let value = rule
529        .overrides
530        .get(serde_yaml::Value::String(key.to_string()))?;
531    match value {
532        serde_yaml::Value::Number(number) => {
533            let parsed = number.as_u64()?;
534            let percent = u8::try_from(parsed).ok()?;
535            (1..=100).contains(&percent).then_some(percent)
536        }
537        _ => None,
538    }
539}
540
541pub(super) fn matched_policy_string_override(
542    matched_policy: Option<&MatchedModelPolicy>,
543    key: &str,
544) -> Option<ResolvedField<String>> {
545    let policy = matched_policy?;
546    let value = policy_override_string(&policy.rule, key)?;
547    Some(ResolvedField {
548        value,
549        source: policy.layer.field_source(),
550        matched_rule: Some(policy.matched_rule_ref()),
551    })
552}
553
554pub(super) fn matched_policy_u32_override(
555    matched_policy: Option<&MatchedModelPolicy>,
556    key: &str,
557) -> Option<ResolvedField<u32>> {
558    let policy = matched_policy?;
559    let value = policy_override_u32(&policy.rule, key)?;
560    Some(ResolvedField {
561        value,
562        source: policy.layer.field_source(),
563        matched_rule: Some(policy.matched_rule_ref()),
564    })
565}
566
567pub(super) fn matched_policy_u8_override(
568    matched_policy: Option<&MatchedModelPolicy>,
569    key: &str,
570) -> Option<ResolvedField<u8>> {
571    let policy = matched_policy?;
572    let value = policy_override_u8(&policy.rule, key)?;
573    Some(ResolvedField {
574        value,
575        source: policy.layer.field_source(),
576        matched_rule: Some(policy.matched_rule_ref()),
577    })
578}
579
580fn effective_policies<'a>(
581    overlay: Option<&'a AgentOverlay>,
582    profile_policies: &'a [ModelPolicyRule],
583    settings_policies: &'a [ModelPolicyRule],
584) -> impl Iterator<Item = (PolicyLayer, usize, &'a ModelPolicyRule)> + 'a {
585    overlay
586        .into_iter()
587        .flat_map(|agent_overlay| {
588            agent_overlay
589                .model_policies
590                .iter()
591                .enumerate()
592                .map(|(index, rule)| (PolicyLayer::Overlay, index, rule))
593        })
594        .chain(
595            profile_policies
596                .iter()
597                .enumerate()
598                .map(|(index, rule)| (PolicyLayer::Profile, index, rule)),
599        )
600        .chain(
601            settings_policies
602                .iter()
603                .enumerate()
604                .map(|(index, rule)| (PolicyLayer::Settings, index, rule)),
605        )
606}
607
608fn match_model_policy<'a>(
609    policies: impl Iterator<Item = (PolicyLayer, usize, &'a ModelPolicyRule)>,
610    canonical_model_id: &str,
611    selected_model_token: &str,
612) -> Option<MatchedModelPolicy> {
613    if canonical_model_id.is_empty() || selected_model_token.is_empty() {
614        return None;
615    }
616
617    for (layer, index, rule) in policies {
618        let matched = match rule.match_type {
619            ModelPolicyMatchType::Model => rule.match_value == canonical_model_id,
620            ModelPolicyMatchType::Alias => rule.match_value == selected_model_token,
621            ModelPolicyMatchType::ModelGlob => {
622                crate::models::glob_match(&rule.match_value, canonical_model_id)
623            }
624        };
625        if matched {
626            return Some(MatchedModelPolicy {
627                layer,
628                index,
629                rule: rule.clone(),
630            });
631        }
632    }
633
634    None
635}