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