Skip to main content

mars_agents/build/
mod.rs

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