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 )?;
118
119 warnings.extend(prompt.warnings);
120 let (resolved_tools, tool_warnings) = resolve_bundle_tools(&profile, &policy.routing.harness)?;
121 warnings.extend(tool_warnings);
122
123 Ok(LaunchBundle {
124 version: LAUNCH_BUNDLE_VERSION,
125 agent: request.agent,
126 agent_body,
127 routing: policy.routing,
128 execution_policy: policy.execution_policy,
129 prompt_surface: bundle::PromptSurface {
130 system_instruction: prompt.system_instruction,
131 supplemental_documents: prompt.supplemental_documents,
132 inventory_prompt: prompt.inventory_prompt,
133 },
134 scaffold_slots: ScaffoldSlots::placeholders(),
135 tools: resolved_tools,
136 skills_metadata: SkillsMetadata {
137 loaded: prompt.loaded_skills,
138 missing: prompt.missing_skills,
139 },
140 provenance: policy.provenance,
141 warnings,
142 })
143}
144
145fn empty_agent_profile() -> AgentProfile {
146 AgentProfile {
147 name: None,
148 description: None,
149 harness: None,
150 model: None,
151 mode: None,
152 model_invocable: true,
153 approval: None,
154 sandbox: None,
155 effort: None,
156 autocompact: None,
157 autocompact_pct: None,
158 skills: Vec::new(),
159 tools: Vec::new(),
160 tools_denied: Vec::new(),
161 disallowed_tools: Vec::new(),
162 mcp_tools: Vec::new(),
163 harness_overrides: Default::default(),
164 model_policies: Vec::new(),
165 fanout: Vec::new(),
166 }
167}
168
169fn load_effective_project_config_or_default(
170 project_root: &std::path::Path,
171) -> Result<EffectiveProjectConfig, MarsError> {
172 match crate::config::load_effective_project_config(project_root) {
173 Ok(config) => Ok(config),
174 Err(MarsError::Config(ConfigError::NotFound { .. })) => {
175 Ok(EffectiveProjectConfig::default())
176 }
177 Err(err) => Err(err),
178 }
179}
180
181fn agent_file_path(project_root: &std::path::Path, agent: &str) -> PathBuf {
182 project_root
183 .join(".mars")
184 .join("agents")
185 .join(format!("{agent}.md"))
186}
187
188fn resolve_bundle_tools(
189 profile: &crate::compiler::agents::AgentProfile,
190 harness: &str,
191) -> Result<(ToolsSpec, Vec<String>), MarsError> {
192 let harness_kind = parse_harness_kind(harness)?;
193
194 let effective_tools = profile.effective_tool_policy(&harness_kind);
195 let mut warnings = Vec::new();
196
197 let allowed = normalize_and_dedupe_tools(
198 &effective_tools.allowed,
199 harness,
200 ToolPolicyKind::Allowed,
201 &mut warnings,
202 );
203 let disallowed = normalize_and_dedupe_tools(
204 &effective_tools.disallowed,
205 harness,
206 ToolPolicyKind::Disallowed,
207 &mut warnings,
208 );
209
210 Ok((
211 ToolsSpec {
212 allowed,
213 disallowed,
214 mcp: effective_tools.mcp,
215 },
216 warnings,
217 ))
218}
219
220fn normalize_and_dedupe_tools(
221 tools: &[String],
222 harness: &str,
223 kind: ToolPolicyKind,
224 warnings: &mut Vec<String>,
225) -> Vec<String> {
226 let mut seen = std::collections::HashSet::new();
227 let mut projected = Vec::new();
228
229 for tool in tools {
230 let normalized = normalize_tool_for_harness(tool, harness);
231 if normalized.status == ToolProjectionStatus::Unknown && is_first_class_harness(harness) {
232 match kind {
233 ToolPolicyKind::Allowed => warnings.push(format!(
234 "tool '{tool}' is not a known {harness} tool; passing through verbatim"
235 )),
236 ToolPolicyKind::Disallowed => continue,
237 }
238 }
239
240 let trimmed = normalized.name.trim();
241 if trimmed.is_empty() {
242 continue;
243 }
244 if seen.insert(trimmed.to_string()) {
245 projected.push(trimmed.to_string());
246 }
247 }
248
249 projected
250}
251
252#[derive(Clone, Copy, PartialEq, Eq)]
253enum ToolPolicyKind {
254 Allowed,
255 Disallowed,
256}
257
258fn resolve_effective_skills(
259 profile: &crate::compiler::agents::AgentProfile,
260 harness: &str,
261) -> Result<Vec<String>, MarsError> {
262 let harness_kind = parse_harness_kind(harness)?;
263 Ok(profile.effective_skills(&harness_kind).to_vec())
264}
265
266fn parse_harness_kind(harness: &str) -> Result<HarnessKind, MarsError> {
267 HarnessKind::from_str(harness).ok_or_else(|| {
268 MarsError::Config(ConfigError::Invalid {
269 message: format!(
270 "invalid harness `{harness}` for launch bundle resolution; expected one of: claude, codex, opencode, cursor, pi"
271 ),
272 })
273 })
274}