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