1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::build::bundle::SupplementalDoc;
5use crate::compiler::agents::{AgentMode, ModelPolicyMatchType, parse_agent_content};
6use crate::compiler::skills::parse_skill_content;
7use crate::compiler::variants::harness_skill_variant_path;
8use crate::error::{ConfigError, MarsError};
9use crate::frontmatter::Frontmatter;
10
11const REPORT_INSTRUCTION: &str = "# Report\n\n**IMPORTANT - Your final assistant message must be the run report.**\n\nProvide a plain markdown report in your final assistant message.\n\nInclude: what was done, key decisions made, files created/modified, verification results, and any issues or blockers.";
12
13pub struct PromptCompilation {
14 pub system_instruction: String,
15 pub supplemental_documents: Vec<SupplementalDoc>,
16 pub inventory_prompt: String,
17 pub loaded_skills: Vec<String>,
18 pub missing_skills: Vec<String>,
19 pub warnings: Vec<String>,
20}
21
22struct LoadedSkillDocument {
23 requested_index: usize,
24 document: SupplementalDoc,
25}
26
27enum SkillLoadOutcome {
28 Loaded(SupplementalDoc),
29 Missing,
30 SkippedModelInvocableFalse,
31}
32
33#[derive(Debug, Clone)]
34struct ParsedAgentInventory {
35 name: String,
36 description: String,
37 model: Option<String>,
38 fanout: Vec<String>,
39 mode: AgentMode,
40}
41
42#[allow(clippy::too_many_arguments)]
43pub fn compile_prompt_surface(
44 mars_dir: &Path,
45 agent_body: &str,
46 profile_skills: &[String],
47 extra_skills: &[String],
48 harness_id: &str,
49 selected_model_token: &str,
50 canonical_model_id: &str,
51 subagents_filter: &[String],
52) -> Result<PromptCompilation, MarsError> {
53 let _ = (selected_model_token, canonical_model_id);
54
55 let requested_skills = requested_skill_order(profile_skills, extra_skills);
56
57 let mut loaded_documents = Vec::new();
58 let mut missing_skills = Vec::new();
59 let mut warnings = Vec::new();
60
61 for (requested_index, skill) in requested_skills.iter().enumerate() {
62 match load_skill_document(mars_dir, skill, harness_id) {
63 Ok(SkillLoadOutcome::Loaded(document)) => {
64 loaded_documents.push(LoadedSkillDocument {
65 requested_index,
66 document,
67 });
68 }
69 Ok(SkillLoadOutcome::Missing) => missing_skills.push(skill.clone()),
70 Ok(SkillLoadOutcome::SkippedModelInvocableFalse) => {}
71 Err(err) => {
72 warnings.push(err);
73 missing_skills.push(skill.clone());
74 }
75 }
76 }
77
78 loaded_documents.sort_by(|left, right| {
79 let left_key = (
80 skill_type_priority(&left.document.skill_type),
81 left.requested_index,
82 );
83 let right_key = (
84 skill_type_priority(&right.document.skill_type),
85 right.requested_index,
86 );
87 left_key.cmp(&right_key)
88 });
89
90 let supplemental_documents = loaded_documents
91 .iter()
92 .map(|loaded| loaded.document.clone())
93 .collect::<Vec<_>>();
94
95 let loaded_skills = loaded_documents
96 .iter()
97 .map(|loaded| loaded.document.name.clone())
98 .collect::<Vec<_>>();
99
100 let inventory_prompt = build_inventory_prompt(mars_dir, subagents_filter, &mut warnings)?;
101 let system_instruction = compose_system_instruction(
102 agent_body,
103 &supplemental_documents,
104 &inventory_prompt,
105 REPORT_INSTRUCTION,
106 );
107
108 Ok(PromptCompilation {
109 system_instruction,
110 supplemental_documents,
111 inventory_prompt,
112 loaded_skills,
113 missing_skills,
114 warnings,
115 })
116}
117
118fn requested_skill_order(profile_skills: &[String], extra_skills: &[String]) -> Vec<String> {
119 let mut seen = HashSet::new();
120 let mut ordered = Vec::new();
121
122 for name in profile_skills.iter().chain(extra_skills.iter()) {
123 let normalized = name.trim();
124 if normalized.is_empty() {
125 continue;
126 }
127 if seen.insert(normalized.to_string()) {
128 ordered.push(normalized.to_string());
129 }
130 }
131
132 ordered
133}
134
135fn skill_type_priority(skill_type: &str) -> u8 {
136 match skill_type {
137 "principle" => 0,
138 "guardrail" => 1,
139 "reference" => 2,
140 _ => 2,
141 }
142}
143
144fn load_skill_document(
145 mars_dir: &Path,
146 skill_name: &str,
147 harness_id: &str,
148) -> Result<SkillLoadOutcome, String> {
149 let skill_dir = mars_dir.join("skills").join(skill_name);
150 let base_skill_path = skill_dir.join("SKILL.md");
151 if !base_skill_path.is_file() {
152 return Ok(SkillLoadOutcome::Missing);
153 }
154
155 let (base_profile, base_frontmatter) = parse_skill_file(skill_name, &base_skill_path)?;
156 if !base_profile.model_invocable {
157 return Ok(SkillLoadOutcome::SkippedModelInvocableFalse);
158 }
159
160 let selected_skill_path =
161 harness_skill_variant_path(&skill_dir, harness_id).unwrap_or(base_skill_path);
162 let (_, selected_frontmatter) = parse_skill_file(skill_name, &selected_skill_path)?;
163
164 let skill_type = skill_type_from_frontmatter(&selected_frontmatter)
165 .or_else(|| skill_type_from_frontmatter(&base_frontmatter));
166 let skill_type = skill_type.unwrap_or_else(|| "reference".to_string());
167
168 let content = render_skill_content_block(skill_name, selected_frontmatter.body().trim());
169
170 Ok(SkillLoadOutcome::Loaded(SupplementalDoc {
171 kind: "skill".to_string(),
172 name: skill_name.to_string(),
173 content,
174 skill_type,
175 detail: base_profile.detail.clone().unwrap_or_default(),
176 }))
177}
178
179fn parse_skill_file(
180 skill_name: &str,
181 skill_path: &Path,
182) -> Result<(crate::compiler::skills::SkillProfile, Frontmatter), String> {
183 let raw = std::fs::read_to_string(skill_path).map_err(|err| {
184 format!(
185 "failed to read skill `{skill_name}` from {}: {err}",
186 skill_path.display()
187 )
188 })?;
189
190 let mut skill_diags = Vec::new();
191 let parsed = parse_skill_content(&raw, &mut skill_diags).map_err(|err| {
192 format!(
193 "failed to parse skill `{skill_name}` from {}: {err}",
194 skill_path.display()
195 )
196 })?;
197
198 if let Some(diag) = skill_diags.first() {
199 return Err(format!(
200 "skill `{skill_name}` has invalid frontmatter in {}: {}",
201 skill_path.display(),
202 diag.message()
203 ));
204 }
205
206 Ok(parsed)
207}
208
209fn skill_type_from_frontmatter(frontmatter: &Frontmatter) -> Option<String> {
210 frontmatter
211 .get("type")
212 .and_then(|value| value.as_str())
213 .map(|value| value.trim().to_string())
214 .filter(|value| !value.is_empty())
215}
216
217fn render_skill_content_block(skill_name: &str, body: &str) -> String {
218 if body.is_empty() {
219 format!("# Skill: {skill_name}")
220 } else {
221 format!("# Skill: {skill_name}\n\n{body}")
222 }
223}
224
225fn compose_system_instruction(
226 agent_body: &str,
227 supplemental_documents: &[SupplementalDoc],
228 inventory_prompt: &str,
229 report_instruction: &str,
230) -> String {
231 let mut blocks: Vec<String> = Vec::new();
232
233 let body = agent_body.trim();
234 if !body.is_empty() {
235 blocks.push(format!("# Agent Profile\n\n{body}"));
236 }
237
238 for doc in supplemental_documents {
239 let content = doc.content.trim();
240 if !content.is_empty() {
241 blocks.push(content.to_string());
242 }
243 }
244
245 let inventory = inventory_prompt.trim();
246 if !inventory.is_empty() {
247 blocks.push(inventory.to_string());
248 }
249
250 blocks.push(report_instruction.to_string());
251
252 for doc in supplemental_documents
253 .iter()
254 .filter(|doc| doc.skill_type == "principle")
255 {
256 let content = doc.content.trim();
257 if !content.is_empty() {
258 blocks.push(content.to_string());
259 }
260 }
261
262 blocks.join("\n\n")
263}
264
265fn build_inventory_prompt(
266 mars_dir: &Path,
267 subagents_filter: &[String],
268 warnings: &mut Vec<String>,
269) -> Result<String, MarsError> {
270 let agents_dir = mars_dir.join("agents");
271 if !agents_dir.is_dir() {
272 return Ok(String::new());
273 }
274
275 let read_dir = match std::fs::read_dir(&agents_dir) {
276 Ok(entries) => entries,
277 Err(err) => {
278 warnings.push(format!(
279 "failed to read agent inventory from {}: {err}",
280 agents_dir.display()
281 ));
282 return Ok(String::new());
283 }
284 };
285
286 let mut agent_paths: Vec<PathBuf> = read_dir
287 .filter_map(Result::ok)
288 .map(|entry| entry.path())
289 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
290 .collect();
291 agent_paths.sort();
292
293 let mut primary_agents = Vec::new();
294 let mut subagent_agents = Vec::new();
295
296 for path in agent_paths {
297 match parse_inventory_agent(&path) {
298 Ok((Some(agent), agent_warnings)) => {
299 warnings.extend(agent_warnings);
300 if agent.mode == AgentMode::Primary {
301 primary_agents.push(agent);
302 } else {
303 subagent_agents.push(agent);
304 }
305 }
306 Ok((None, agent_warnings)) => warnings.extend(agent_warnings),
307 Err(err) => {
308 return Err(MarsError::Config(ConfigError::Invalid { message: err }));
309 }
310 }
311 }
312
313 if !subagents_filter.is_empty() {
314 primary_agents.retain(|agent| {
315 subagents_filter
316 .iter()
317 .any(|f| f.eq_ignore_ascii_case(&agent.name))
318 });
319 subagent_agents.retain(|agent| {
320 subagents_filter
321 .iter()
322 .any(|f| f.eq_ignore_ascii_case(&agent.name))
323 });
324 }
325
326 if primary_agents.is_empty() && subagent_agents.is_empty() {
327 return Ok(String::new());
328 }
329
330 primary_agents.sort_by(|left, right| left.name.cmp(&right.name));
331 subagent_agents.sort_by(|left, right| left.name.cmp(&right.name));
332
333 let mut lines = vec![
334 "# Meridian Agents".to_string(),
335 "".to_string(),
336 "Installed Meridian agents available at launch time.".to_string(),
337 ];
338
339 if !primary_agents.is_empty() {
340 lines.extend(["".to_string(), "## Primary".to_string()]);
341 for agent in &primary_agents {
342 lines.push(render_inventory_line(agent));
343 }
344 }
345
346 if !subagent_agents.is_empty() {
347 lines.extend(["".to_string(), "## Subagent".to_string()]);
348 for agent in &subagent_agents {
349 lines.push(render_inventory_line(agent));
350 }
351 }
352
353 Ok(lines.join("\n").trim().to_string())
354}
355
356fn parse_inventory_agent(
357 path: &Path,
358) -> Result<(Option<ParsedAgentInventory>, Vec<String>), String> {
359 let content = std::fs::read_to_string(path).map_err(|err| {
360 format!(
361 "failed to read agent inventory file {}: {err}",
362 path.display()
363 )
364 })?;
365
366 let mut parse_diags = Vec::new();
367 let (profile, _frontmatter) =
368 parse_agent_content(&content, &mut parse_diags).map_err(|err| {
369 format!(
370 "failed to parse agent inventory file {}: {err}",
371 path.display()
372 )
373 })?;
374
375 let mut warnings = Vec::new();
376 for diag in parse_diags {
377 if diag.is_error() {
378 return Err(format!(
379 "agent inventory file {} has invalid frontmatter: {}",
380 path.display(),
381 diag.message()
382 ));
383 }
384 warnings.push(format!(
385 "agent inventory parse warning in {}: {}",
386 path.display(),
387 diag.message()
388 ));
389 }
390 if !profile.model_invocable {
391 return Ok((None, warnings));
392 }
393
394 let fallback_name = path
395 .file_stem()
396 .and_then(|stem| stem.to_str())
397 .unwrap_or("unknown-agent")
398 .to_string();
399 let fanout = fallback_model_policies_for_inventory(&profile);
400 let name = profile.name.unwrap_or(fallback_name);
401 let description = profile.description.unwrap_or_default();
402 let mode = profile.mode.clone().unwrap_or(AgentMode::Subagent);
403
404 Ok((
405 Some(ParsedAgentInventory {
406 name,
407 description,
408 model: profile.model,
409 fanout,
410 mode,
411 }),
412 warnings,
413 ))
414}
415
416fn fallback_model_policies_for_inventory(
417 profile: &crate::compiler::agents::AgentProfile,
418) -> Vec<String> {
419 let mut entries = Vec::new();
420 let mut seen = HashSet::new();
421
422 for policy in &profile.model_policies {
425 if policy.no_fallback {
426 continue;
427 }
428 if !matches!(
429 policy.match_type,
430 ModelPolicyMatchType::Alias | ModelPolicyMatchType::Model
431 ) {
432 continue;
433 }
434 let value = policy.match_value.trim();
435 if value.is_empty() {
436 continue;
437 }
438 if seen.insert(value.to_string()) {
439 entries.push(value.to_string());
440 }
441 }
442
443 entries
444}
445
446fn render_inventory_line(agent: &ParsedAgentInventory) -> String {
447 let description = agent.description.trim();
448 let mut line = if description.is_empty() {
449 format!("- {}", agent.name)
450 } else {
451 format!("- {}: {}", agent.name, description)
452 };
453
454 if let Some(model) = agent.model.as_ref().map(|value| value.trim())
455 && !model.is_empty()
456 {
457 line.push_str(" | Model: ");
458 line.push_str(model);
459 }
460
461 if !agent.fanout.is_empty() {
462 line.push_str(" | Fan-out: ");
463 line.push_str(&agent.fanout.join(", "));
464 }
465
466 line
467}