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}