Skip to main content

mars_agents/build/
mod.rs

1pub mod bundle;
2pub mod inventory;
3pub mod policy;
4pub mod prompt;
5
6use std::path::PathBuf;
7
8use crate::compiler::tool_names::{ToolProjectionStatus, project_tool_for_harness};
9use bundle::{LaunchBundle, ScaffoldSlots, Skills, ToolsSpec};
10use policy::{PolicyInput, resolve_policy};
11use prompt::compile_prompt_surface;
12
13use crate::cli::MarsContext;
14use crate::compiler::agents::{AgentProfile, parse_agent_content};
15use crate::compiler::harness_descriptor::CompilerHarnessDescriptor;
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    if let Some(message) = crate::compiler::agent_copy::deprecated_fanout_agents_warning(
85        effective_project_config.settings.meridian_agent_copy(),
86    ) {
87        warnings.push(message);
88    }
89    let lock = crate::lock::load_for_runtime_aliases(&ctx.project_root)?;
90    let runtime_aliases = crate::models::merged_runtime_aliases(
91        &lock.dependency_model_aliases,
92        Some(&effective_project_config.models),
93    );
94
95    let policy = resolve_policy(
96        &effective_project_config,
97        PolicyInput {
98            project_root: &ctx.project_root,
99            runtime_aliases: &runtime_aliases,
100            agent: request.agent.as_deref(),
101            profile: &profile,
102            model_override: request.model.as_deref(),
103            harness_override: request.harness.as_deref(),
104            effort_override: request.effort.as_deref(),
105            approval_override: request.approval.as_deref(),
106            sandbox_override: request.sandbox.as_deref(),
107            models_refresh: request.models_refresh,
108        },
109    )?;
110
111    warnings.extend(policy.warnings);
112
113    let mars_dir = ctx.project_root.join(".mars");
114    let effective_skills = resolve_effective_skills(&profile, &policy.routing.harness)?;
115
116    let prompt = compile_prompt_surface(
117        &mars_dir,
118        agent_body.as_deref().unwrap_or(""),
119        &effective_skills,
120        &request.extra_skills,
121        &policy.routing.harness,
122        &policy.routing.model_token,
123        &policy.routing.model,
124        &profile.subagents,
125        effective_project_config.settings.meridian_fanout_agents(),
126    )?;
127
128    warnings.extend(prompt.warnings);
129    let (resolved_tools, tool_warnings) = resolve_bundle_tools(&profile, &policy.routing.harness)?;
130    warnings.extend(tool_warnings);
131
132    Ok(LaunchBundle {
133        version: LAUNCH_BUNDLE_VERSION,
134        agent: request.agent,
135        agent_body,
136        routing: policy.routing,
137        execution_policy: policy.execution_policy,
138        prompt_surface: bundle::PromptSurface {
139            system_instruction: prompt.system_instruction,
140            supplemental_documents: prompt.supplemental_documents,
141            inventory_prompt: prompt.inventory_prompt,
142        },
143        scaffold_slots: ScaffoldSlots::placeholders(),
144        tools: resolved_tools,
145        skills: Skills {
146            loaded: prompt.loaded_skills,
147            available: prompt.available_skills,
148            missing: prompt.missing_skills,
149        },
150        provenance: policy.provenance,
151        warnings,
152    })
153}
154
155fn empty_agent_profile() -> AgentProfile {
156    AgentProfile {
157        name: None,
158        description: None,
159        harness: None,
160        model: None,
161        mode: None,
162        model_invocable: true,
163        user_invocable: true,
164        had_model_invocable_field: false,
165        had_user_invocable_field: false,
166        approval: None,
167        sandbox: None,
168        effort: None,
169        autocompact: None,
170        autocompact_pct: None,
171        skills: SkillsSpec::default(),
172        subagents: Vec::new(),
173        tools: Vec::new(),
174        tools_denied: Vec::new(),
175        disallowed_tools: Vec::new(),
176        harness_overrides: Default::default(),
177        model_policies: Vec::new(),
178        fanout: Vec::new(),
179    }
180}
181
182fn load_effective_project_config_or_default(
183    project_root: &std::path::Path,
184) -> Result<EffectiveProjectConfig, MarsError> {
185    match crate::config::load_effective_project_config(project_root) {
186        Ok(config) => Ok(config),
187        Err(MarsError::Config(ConfigError::NotFound { .. })) => {
188            Ok(EffectiveProjectConfig::default())
189        }
190        Err(err) => Err(err),
191    }
192}
193
194fn agent_file_path(project_root: &std::path::Path, agent: &str) -> PathBuf {
195    project_root
196        .join(".mars")
197        .join("agents")
198        .join(format!("{agent}.md"))
199}
200
201fn resolve_bundle_tools(
202    profile: &crate::compiler::agents::AgentProfile,
203    harness: &str,
204) -> Result<(ToolsSpec, Vec<String>), MarsError> {
205    use crate::compiler::mcp_ref::project_mcp_refs_for_emission;
206
207    let descriptor = parse_harness_descriptor(harness)?;
208    let harness_kind = descriptor.kind;
209    let harness = descriptor.canonical_id;
210
211    let effective_tools = profile.effective_tool_policy(&harness_kind);
212    let mut warnings = Vec::new();
213
214    let allowed = normalize_and_dedupe_tools(
215        &effective_tools.allowed,
216        descriptor,
217        ToolPolicyKind::Allowed,
218        &mut warnings,
219    );
220    let mut disallowed = normalize_and_dedupe_tools(
221        &effective_tools.disallowed,
222        descriptor,
223        ToolPolicyKind::Disallowed,
224        &mut warnings,
225    );
226
227    // Harness-native MCP tokens: allowed refs → `tools.mcp`; disallowed refs fold into
228    // `tools.disallowed` with the same per-harness projection (never broaden on unsupported).
229    let mcp_allowed = project_mcp_refs_for_emission(
230        &effective_tools.mcp_allowed,
231        harness_kind,
232        |canonical, reason| {
233            warnings.push(format!(
234                "MCP ref `{canonical}` cannot be represented for {harness}: {}",
235                reason.message()
236            ));
237        },
238    );
239
240    let mcp_disallowed = project_mcp_refs_for_emission(
241        &effective_tools.mcp_disallowed,
242        harness_kind,
243        |canonical, reason| {
244            warnings.push(format!(
245                "disallowed MCP ref `{canonical}` cannot be represented for {harness}: {}",
246                reason.message()
247            ));
248        },
249    );
250    disallowed.extend(mcp_disallowed);
251
252    Ok((
253        ToolsSpec {
254            allowed,
255            disallowed,
256            mcp: mcp_allowed,
257        },
258        warnings,
259    ))
260}
261
262fn normalize_and_dedupe_tools(
263    tools: &[String],
264    descriptor: &CompilerHarnessDescriptor,
265    kind: ToolPolicyKind,
266    warnings: &mut Vec<String>,
267) -> Vec<String> {
268    let harness = descriptor.canonical_id;
269    let mut seen = std::collections::HashSet::new();
270    let mut projected = Vec::new();
271
272    for tool in tools {
273        let normalized = project_tool_for_harness(tool, descriptor.kind);
274        if normalized.status == ToolProjectionStatus::UnknownProjected {
275            match kind {
276                ToolPolicyKind::Allowed => warnings.push(format!(
277                    "tool '{tool}' is not a known {harness} tool; projected via {harness} naming convention (verify it exists)"
278                )),
279                ToolPolicyKind::Disallowed => warnings.push(format!(
280                    "disallowed tool '{tool}' is not a known {harness} tool; projected via {harness} naming convention (verify it exists)"
281                )),
282            }
283        }
284
285        let trimmed = normalized.name.trim();
286        if trimmed.is_empty() {
287            continue;
288        }
289        if seen.insert(trimmed.to_string()) {
290            projected.push(trimmed.to_string());
291        }
292    }
293
294    projected
295}
296
297#[derive(Clone, Copy, PartialEq, Eq)]
298enum ToolPolicyKind {
299    Allowed,
300    Disallowed,
301}
302
303fn resolve_effective_skills(
304    profile: &crate::compiler::agents::AgentProfile,
305    harness: &str,
306) -> Result<SkillsSpec, MarsError> {
307    let descriptor = parse_harness_descriptor(harness)?;
308    Ok(profile.effective_skills(&descriptor.kind).clone())
309}
310
311fn parse_harness_descriptor(
312    harness: &str,
313) -> Result<&'static CompilerHarnessDescriptor, MarsError> {
314    crate::compiler::harness_descriptor::descriptor_for_canonical_id(harness).ok_or_else(|| {
315        MarsError::Config(ConfigError::Invalid {
316            message: format!(
317                "invalid harness `{harness}` for launch bundle resolution; expected one of: {}",
318                crate::compiler::harness_descriptor::known_canonical_ids()
319                    .collect::<Vec<_>>()
320                    .join(", ")
321            ),
322        })
323    })
324}