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}