Skip to main content

mars_agents/build/policy/
mod.rs

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