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