Skip to main content

mars_agents/build/
mod.rs

1pub mod bundle;
2pub mod policy;
3pub mod prompt;
4pub mod tool_normalize;
5
6use std::path::PathBuf;
7
8use bundle::{LaunchBundle, ScaffoldSlots, SkillsMetadata, ToolsSpec};
9use policy::{PolicyInput, resolve_policy};
10use prompt::compile_prompt_surface;
11use tool_normalize::{ToolProjectionStatus, is_first_class_harness, normalize_tool_for_harness};
12
13use crate::cli::MarsContext;
14use crate::compiler::agents::{AgentProfile, HarnessKind, parse_agent_content};
15use crate::error::{ConfigError, MarsError};
16
17pub const LAUNCH_BUNDLE_VERSION: u32 = 2;
18
19pub struct LaunchBundleRequest {
20    pub agent: Option<String>,
21    pub model: Option<String>,
22    pub harness: Option<String>,
23    pub effort: Option<String>,
24    pub approval: Option<String>,
25    pub sandbox: Option<String>,
26    pub extra_skills: Vec<String>,
27    pub models_refresh: crate::models::ModelsRefreshControl,
28}
29
30pub fn build_launch_bundle(
31    ctx: &MarsContext,
32    request: LaunchBundleRequest,
33) -> Result<LaunchBundle, MarsError> {
34    let mut warnings: Vec<String> = Vec::new();
35    let profile: AgentProfile;
36    let agent_body: Option<String>;
37
38    if let Some(agent) = request.agent.as_deref() {
39        let agent_path = agent_file_path(&ctx.project_root, agent);
40        let agent_content =
41            std::fs::read_to_string(&agent_path).map_err(|source| MarsError::Io {
42                operation: "read launch bundle agent".to_string(),
43                path: agent_path.clone(),
44                source,
45            })?;
46
47        let mut parse_diags = Vec::new();
48        let (parsed_profile, frontmatter) = parse_agent_content(&agent_content, &mut parse_diags)
49            .map_err(|err| {
50            MarsError::Config(ConfigError::Invalid {
51                message: format!(
52                    "failed to parse agent `{agent}` from {}: {err}",
53                    agent_path.display()
54                ),
55            })
56        })?;
57
58        if let Some(fatal) = parse_diags.iter().find(|diag| diag.is_error()) {
59            return Err(MarsError::Config(ConfigError::Invalid {
60                message: format!(
61                    "agent `{agent}` has invalid frontmatter in {}: {}",
62                    agent_path.display(),
63                    fatal.message()
64                ),
65            }));
66        }
67
68        warnings.extend(
69            parse_diags
70                .iter()
71                .map(|diag| format!("agent `{agent}`: {}", diag.message())),
72        );
73        agent_body = Some(frontmatter.body().to_string());
74        profile = parsed_profile;
75    } else {
76        profile = empty_agent_profile();
77        agent_body = None;
78    }
79
80    let policy = resolve_policy(PolicyInput {
81        project_root: &ctx.project_root,
82        agent: request.agent.as_deref(),
83        profile: &profile,
84        model_override: request.model.as_deref(),
85        config_default_model: None,
86        harness_override: request.harness.as_deref(),
87        effort_override: request.effort.as_deref(),
88        approval_override: request.approval.as_deref(),
89        sandbox_override: request.sandbox.as_deref(),
90        models_refresh: request.models_refresh,
91    })?;
92
93    warnings.extend(policy.warnings);
94
95    let mars_dir = ctx.project_root.join(".mars");
96    let effective_skills = resolve_effective_skills(&profile, &policy.routing.harness)?;
97
98    let prompt = compile_prompt_surface(
99        &mars_dir,
100        agent_body.as_deref().unwrap_or(""),
101        &effective_skills,
102        &request.extra_skills,
103        &policy.routing.harness,
104        &policy.routing.model_token,
105        &policy.routing.model,
106    )?;
107
108    warnings.extend(prompt.warnings);
109    let (resolved_tools, tool_warnings) = resolve_bundle_tools(&profile, &policy.routing.harness)?;
110    warnings.extend(tool_warnings);
111
112    Ok(LaunchBundle {
113        version: LAUNCH_BUNDLE_VERSION,
114        agent: request.agent,
115        agent_body,
116        routing: policy.routing,
117        execution_policy: policy.execution_policy,
118        prompt_surface: bundle::PromptSurface {
119            system_instruction: prompt.system_instruction,
120            supplemental_documents: prompt.supplemental_documents,
121            inventory_prompt: prompt.inventory_prompt,
122        },
123        scaffold_slots: ScaffoldSlots::placeholders(),
124        tools: resolved_tools,
125        skills_metadata: SkillsMetadata {
126            loaded: prompt.loaded_skills,
127            missing: prompt.missing_skills,
128        },
129        provenance: policy.provenance,
130        warnings,
131    })
132}
133
134fn empty_agent_profile() -> AgentProfile {
135    AgentProfile {
136        name: None,
137        description: None,
138        harness: None,
139        model: None,
140        mode: None,
141        model_invocable: true,
142        approval: None,
143        sandbox: None,
144        effort: None,
145        autocompact: None,
146        autocompact_pct: None,
147        skills: Vec::new(),
148        tools: Vec::new(),
149        tools_denied: Vec::new(),
150        disallowed_tools: Vec::new(),
151        mcp_tools: Vec::new(),
152        harness_overrides: Default::default(),
153        model_policies: Vec::new(),
154        fanout: Vec::new(),
155    }
156}
157
158fn agent_file_path(project_root: &std::path::Path, agent: &str) -> PathBuf {
159    project_root
160        .join(".mars")
161        .join("agents")
162        .join(format!("{agent}.md"))
163}
164
165fn resolve_bundle_tools(
166    profile: &crate::compiler::agents::AgentProfile,
167    harness: &str,
168) -> Result<(ToolsSpec, Vec<String>), MarsError> {
169    let harness_kind = parse_harness_kind(harness)?;
170
171    let effective_tools = profile.effective_tool_policy(&harness_kind);
172    let mut warnings = Vec::new();
173
174    let allowed = normalize_and_dedupe_tools(
175        &effective_tools.allowed,
176        harness,
177        ToolPolicyKind::Allowed,
178        &mut warnings,
179    );
180    let disallowed = normalize_and_dedupe_tools(
181        &effective_tools.disallowed,
182        harness,
183        ToolPolicyKind::Disallowed,
184        &mut warnings,
185    );
186
187    Ok((
188        ToolsSpec {
189            allowed,
190            disallowed,
191            mcp: effective_tools.mcp,
192        },
193        warnings,
194    ))
195}
196
197fn normalize_and_dedupe_tools(
198    tools: &[String],
199    harness: &str,
200    kind: ToolPolicyKind,
201    warnings: &mut Vec<String>,
202) -> Vec<String> {
203    let mut seen = std::collections::HashSet::new();
204    let mut projected = Vec::new();
205
206    for tool in tools {
207        let normalized = normalize_tool_for_harness(tool, harness);
208        if normalized.status == ToolProjectionStatus::Unknown && is_first_class_harness(harness) {
209            match kind {
210                ToolPolicyKind::Allowed => warnings.push(format!(
211                    "tool '{tool}' is not a known {harness} tool; passing through verbatim"
212                )),
213                ToolPolicyKind::Disallowed => continue,
214            }
215        }
216
217        let trimmed = normalized.name.trim();
218        if trimmed.is_empty() {
219            continue;
220        }
221        if seen.insert(trimmed.to_string()) {
222            projected.push(trimmed.to_string());
223        }
224    }
225
226    projected
227}
228
229#[derive(Clone, Copy, PartialEq, Eq)]
230enum ToolPolicyKind {
231    Allowed,
232    Disallowed,
233}
234
235fn resolve_effective_skills(
236    profile: &crate::compiler::agents::AgentProfile,
237    harness: &str,
238) -> Result<Vec<String>, MarsError> {
239    let harness_kind = parse_harness_kind(harness)?;
240    Ok(profile.effective_skills(&harness_kind).to_vec())
241}
242
243fn parse_harness_kind(harness: &str) -> Result<HarnessKind, MarsError> {
244    HarnessKind::from_str(harness).ok_or_else(|| {
245        MarsError::Config(ConfigError::Invalid {
246            message: format!(
247                "invalid harness `{harness}` for launch bundle resolution; expected one of: claude, codex, opencode, cursor, pi"
248            ),
249        })
250    })
251}