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}