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}