1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::build::bundle::{AvailableSkill, LoadedSkill, 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, SkillsSpec};
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<LoadedSkill>,
18 pub available_skills: Vec<AvailableSkill>,
19 pub missing_skills: Vec<String>,
20 pub warnings: Vec<String>,
21}
22
23struct LoadedSkillDocument {
24 requested_index: usize,
25 document: SupplementalDoc,
26 body: String,
28}
29
30struct LoadedSkillData {
31 document: SupplementalDoc,
32 body: String,
33}
34
35enum SkillLoadOutcome {
36 Loaded(LoadedSkillData),
37 Missing,
38}
39
40#[derive(Debug, Clone)]
41enum AvailableSkillOutcome {
42 Available(AvailableSkill),
43 Missing,
44}
45
46#[derive(Debug, Clone)]
47struct ParsedAgentInventory {
48 name: String,
49 description: String,
50 model: Option<String>,
51 fanout: Vec<String>,
52 mode: AgentMode,
53}
54
55#[allow(clippy::too_many_arguments)]
56pub fn compile_prompt_surface(
57 mars_dir: &Path,
58 agent_body: &str,
59 profile_skills: &SkillsSpec,
60 extra_skills: &[String],
61 harness_id: &str,
62 selected_model_token: &str,
63 canonical_model_id: &str,
64 subagents_filter: &[String],
65) -> Result<PromptCompilation, MarsError> {
66 let _ = (selected_model_token, canonical_model_id);
67
68 let requested_load_skills = requested_skill_order(&profile_skills.load, extra_skills);
69 let requested_available_skills = requested_available_skill_order(
70 &profile_skills.available,
71 requested_load_skills.iter().map(String::as_str),
72 );
73
74 let mut loaded_documents = Vec::new();
75 let mut missing_skills = Vec::new();
76 let mut warnings = Vec::new();
77
78 for (requested_index, skill) in requested_load_skills.iter().enumerate() {
79 match load_skill_document(mars_dir, skill, harness_id) {
80 Ok(SkillLoadOutcome::Loaded(data)) => {
81 loaded_documents.push(LoadedSkillDocument {
82 requested_index,
83 document: data.document,
84 body: data.body,
85 });
86 }
87 Ok(SkillLoadOutcome::Missing) => missing_skills.push(skill.clone()),
88
89 Err(err) => {
90 warnings.push(err);
91 missing_skills.push(skill.clone());
92 }
93 }
94 }
95
96 loaded_documents.sort_by(|left, right| {
97 let left_key = (
98 skill_type_priority(&left.document.skill_type),
99 left.requested_index,
100 );
101 let right_key = (
102 skill_type_priority(&right.document.skill_type),
103 right.requested_index,
104 );
105 left_key.cmp(&right_key)
106 });
107
108 let supplemental_documents = loaded_documents
109 .iter()
110 .map(|loaded| loaded.document.clone())
111 .collect::<Vec<_>>();
112
113 let loaded_skills = loaded_documents
114 .iter()
115 .map(|loaded| LoadedSkill {
116 name: loaded.document.name.clone(),
117 skill_type: loaded.document.skill_type.clone(),
118 body: loaded.body.clone(),
119 })
120 .collect::<Vec<_>>();
121
122 let mut available_skills = Vec::new();
123 for skill in &requested_available_skills {
124 match resolve_available_skill(mars_dir, skill, harness_id) {
125 Ok(AvailableSkillOutcome::Available(skill)) => available_skills.push(skill),
126 Ok(AvailableSkillOutcome::Missing) => missing_skills.push(skill.clone()),
127
128 Err(err) => {
129 warnings.push(err);
130 missing_skills.push(skill.clone());
131 }
132 }
133 }
134
135 let inventory_prompt = build_inventory_prompt(mars_dir, subagents_filter, &mut warnings)?;
136 let system_instruction = compose_system_instruction(
137 agent_body,
138 &supplemental_documents,
139 &available_skills,
140 &inventory_prompt,
141 REPORT_INSTRUCTION,
142 );
143
144 Ok(PromptCompilation {
145 system_instruction,
146 supplemental_documents,
147 inventory_prompt,
148 loaded_skills,
149 available_skills,
150 missing_skills,
151 warnings,
152 })
153}
154
155fn requested_skill_order(profile_skills: &[String], extra_skills: &[String]) -> Vec<String> {
156 let mut seen = HashSet::new();
157 let mut ordered = Vec::new();
158
159 for name in profile_skills.iter().chain(extra_skills.iter()) {
160 let normalized = name.trim();
161 if normalized.is_empty() {
162 continue;
163 }
164 if seen.insert(normalized.to_string()) {
165 ordered.push(normalized.to_string());
166 }
167 }
168
169 ordered
170}
171
172fn requested_available_skill_order<'a>(
173 available_skills: &[String],
174 loaded_skills: impl Iterator<Item = &'a str>,
175) -> Vec<String> {
176 let mut blocked = loaded_skills
177 .map(|name| name.trim().to_string())
178 .collect::<HashSet<_>>();
179 blocked.remove("");
180
181 let mut seen = HashSet::new();
182 let mut ordered = Vec::new();
183 for name in available_skills {
184 let normalized = name.trim();
185 if normalized.is_empty() || blocked.contains(normalized) {
186 continue;
187 }
188 if seen.insert(normalized.to_string()) {
189 ordered.push(normalized.to_string());
190 }
191 }
192 ordered
193}
194
195fn skill_type_priority(skill_type: &str) -> u8 {
196 match skill_type {
197 "principle" => 0,
198 "guardrail" => 1,
199 "reference" => 2,
200 _ => 2,
201 }
202}
203
204fn load_skill_document(
205 mars_dir: &Path,
206 skill_name: &str,
207 harness_id: &str,
208) -> Result<SkillLoadOutcome, String> {
209 let skill_dir = mars_dir.join("skills").join(skill_name);
210 let base_skill_path = skill_dir.join("SKILL.md");
211 if !base_skill_path.is_file() {
212 return Ok(SkillLoadOutcome::Missing);
213 }
214
215 let (_base_profile, base_frontmatter) = parse_skill_file(skill_name, &base_skill_path)?;
218
219 let selected_skill_path =
220 harness_skill_variant_path(&skill_dir, harness_id).unwrap_or(base_skill_path);
221 let (_, selected_frontmatter) = parse_skill_file(skill_name, &selected_skill_path)?;
222
223 let skill_type = skill_type_from_frontmatter(&selected_frontmatter)
224 .or_else(|| skill_type_from_frontmatter(&base_frontmatter));
225 let skill_type = skill_type.unwrap_or_else(|| "reference".to_string());
226
227 let body = selected_frontmatter.body().trim().to_string();
228 let content = render_skill_content_block(skill_name, &body);
229
230 Ok(SkillLoadOutcome::Loaded(LoadedSkillData {
231 document: SupplementalDoc {
232 kind: "skill".to_string(),
233 name: skill_name.to_string(),
234 content,
235 skill_type,
236 },
237 body,
238 }))
239}
240
241fn resolve_available_skill(
242 mars_dir: &Path,
243 skill_name: &str,
244 harness_id: &str,
245) -> Result<AvailableSkillOutcome, String> {
246 let skill_dir = mars_dir.join("skills").join(skill_name);
247 let base_skill_path = skill_dir.join("SKILL.md");
248 if !base_skill_path.is_file() {
249 return Ok(AvailableSkillOutcome::Missing);
250 }
251
252 let (base_profile, base_frontmatter) = parse_skill_file(skill_name, &base_skill_path)?;
254
255 let selected_skill_path =
256 harness_skill_variant_path(&skill_dir, harness_id).unwrap_or(base_skill_path);
257 let (selected_profile, selected_frontmatter) =
258 parse_skill_file(skill_name, &selected_skill_path)?;
259
260 let skill_type = skill_type_from_frontmatter(&selected_frontmatter)
261 .or_else(|| skill_type_from_frontmatter(&base_frontmatter))
262 .unwrap_or_else(|| "reference".to_string());
263 let description = selected_profile
264 .description
265 .or(base_profile.description)
266 .unwrap_or_default();
267
268 Ok(AvailableSkillOutcome::Available(AvailableSkill {
269 name: skill_name.to_string(),
270 skill_type,
271 description,
272 }))
273}
274
275fn parse_skill_file(
276 skill_name: &str,
277 skill_path: &Path,
278) -> Result<(crate::compiler::skills::SkillProfile, Frontmatter), String> {
279 let raw = std::fs::read_to_string(skill_path).map_err(|err| {
280 format!(
281 "failed to read skill `{skill_name}` from {}: {err}",
282 skill_path.display()
283 )
284 })?;
285
286 let mut skill_diags = Vec::new();
287 let parsed = parse_skill_content(&raw, &mut skill_diags).map_err(|err| {
288 format!(
289 "failed to parse skill `{skill_name}` from {}: {err}",
290 skill_path.display()
291 )
292 })?;
293
294 if let Some(diag) = skill_diags.first() {
295 return Err(format!(
296 "skill `{skill_name}` has invalid frontmatter in {}: {}",
297 skill_path.display(),
298 diag.message()
299 ));
300 }
301
302 Ok(parsed)
303}
304
305fn skill_type_from_frontmatter(frontmatter: &Frontmatter) -> Option<String> {
306 frontmatter
307 .get("type")
308 .and_then(|value| value.as_str())
309 .map(|value| value.trim().to_string())
310 .filter(|value| !value.is_empty())
311}
312
313fn render_skill_content_block(skill_name: &str, body: &str) -> String {
314 if body.is_empty() {
315 format!("# Skill: {skill_name}")
316 } else {
317 format!("# Skill: {skill_name}\n\n{body}")
318 }
319}
320
321fn compose_system_instruction(
322 agent_body: &str,
323 supplemental_documents: &[SupplementalDoc],
324 available_skills: &[AvailableSkill],
325 inventory_prompt: &str,
326 report_instruction: &str,
327) -> String {
328 let mut blocks: Vec<String> = Vec::new();
329
330 let body = agent_body.trim();
331 if !body.is_empty() {
332 blocks.push(format!("# Agent Profile\n\n{body}"));
333 }
334
335 for doc in supplemental_documents {
340 let content = doc.content.trim();
341 if !content.is_empty() {
342 blocks.push(content.to_string());
343 }
344 }
345
346 if !available_skills.is_empty() {
350 let mut avail_block = String::from(
351 "# Available Skills\n\nNot yet loaded. Load proactively when the task fits.",
352 );
353 for (type_label, type_key, description) in &[
354 (
355 "Principles",
356 "principle",
357 "Override other guidance when loaded.",
358 ),
359 (
360 "Guardrails",
361 "guardrail",
362 "Load before acting in sensitive areas.",
363 ),
364 (
365 "Mode-shift",
366 "mode-shift",
367 "Change how you operate when loaded.",
368 ),
369 (
370 "Checkpoint",
371 "checkpoint",
372 "Load at decision points to verify before continuing.",
373 ),
374 ] {
375 let skills: Vec<_> = available_skills
376 .iter()
377 .filter(|s| s.skill_type == *type_key)
378 .collect();
379 if !skills.is_empty() {
380 avail_block.push_str(&format!("\n\n## {type_label}\n{description}"));
381 for skill in skills {
382 avail_block.push_str(&format!("\n- {}", skill.name));
383 }
384 }
385 }
386 let other_skills: Vec<_> = available_skills
388 .iter()
389 .filter(|s| {
390 s.skill_type != "principle"
391 && s.skill_type != "guardrail"
392 && s.skill_type != "mode-shift"
393 && s.skill_type != "checkpoint"
394 })
395 .collect();
396 if !other_skills.is_empty() {
397 let mut seen_types: Vec<&str> = Vec::new();
398 for s in &other_skills {
399 if !seen_types.contains(&s.skill_type.as_str()) {
400 seen_types.push(&s.skill_type);
401 }
402 }
403 for type_key in &seen_types {
404 let group: Vec<_> = other_skills
405 .iter()
406 .filter(|s| s.skill_type == *type_key)
407 .collect();
408 let mut capitalized = type_key.to_string();
409 if let Some(first) = capitalized.get_mut(0..1) {
410 first.make_ascii_uppercase();
411 }
412 avail_block.push_str(&format!("\n\n## {capitalized}"));
413 for skill in group {
414 avail_block.push_str(&format!("\n- {}", skill.name));
415 }
416 }
417 }
418 blocks.push(avail_block);
419 }
420
421 let inventory = inventory_prompt.trim();
422 if !inventory.is_empty() {
423 blocks.push(inventory.to_string());
424 }
425
426 blocks.push(report_instruction.to_string());
427
428 blocks.join("\n\n")
429}
430
431fn build_inventory_prompt(
432 mars_dir: &Path,
433 subagents_filter: &[String],
434 warnings: &mut Vec<String>,
435) -> Result<String, MarsError> {
436 let agents_dir = mars_dir.join("agents");
437 if !agents_dir.is_dir() {
438 return Ok(String::new());
439 }
440
441 let read_dir = match std::fs::read_dir(&agents_dir) {
442 Ok(entries) => entries,
443 Err(err) => {
444 warnings.push(format!(
445 "failed to read agent inventory from {}: {err}",
446 agents_dir.display()
447 ));
448 return Ok(String::new());
449 }
450 };
451
452 let mut agent_paths: Vec<PathBuf> = read_dir
453 .filter_map(Result::ok)
454 .map(|entry| entry.path())
455 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
456 .collect();
457 agent_paths.sort();
458
459 let mut primary_agents = Vec::new();
460 let mut subagent_agents = Vec::new();
461
462 for path in agent_paths {
463 match parse_inventory_agent(&path) {
464 Ok((Some(agent), agent_warnings)) => {
465 warnings.extend(agent_warnings);
466 if agent.mode == AgentMode::Primary {
467 primary_agents.push(agent);
468 } else {
469 subagent_agents.push(agent);
470 }
471 }
472 Ok((None, agent_warnings)) => warnings.extend(agent_warnings),
473 Err(err) => {
474 return Err(MarsError::Config(ConfigError::Invalid { message: err }));
475 }
476 }
477 }
478
479 if !subagents_filter.is_empty() {
480 primary_agents.retain(|agent| {
481 subagents_filter
482 .iter()
483 .any(|f| f.eq_ignore_ascii_case(&agent.name))
484 });
485 subagent_agents.retain(|agent| {
486 subagents_filter
487 .iter()
488 .any(|f| f.eq_ignore_ascii_case(&agent.name))
489 });
490 }
491
492 if primary_agents.is_empty() && subagent_agents.is_empty() {
493 return Ok(String::new());
494 }
495
496 primary_agents.sort_by(|left, right| left.name.cmp(&right.name));
497 subagent_agents.sort_by(|left, right| left.name.cmp(&right.name));
498
499 let mut lines = vec![
500 "# Meridian Agents".to_string(),
501 "".to_string(),
502 "Installed Meridian agents available at launch time.".to_string(),
503 ];
504
505 if !primary_agents.is_empty() {
506 lines.extend(["".to_string(), "## Primary".to_string()]);
507 for agent in &primary_agents {
508 lines.push(render_inventory_line(agent));
509 }
510 }
511
512 if !subagent_agents.is_empty() {
513 lines.extend(["".to_string(), "## Subagent".to_string()]);
514 for agent in &subagent_agents {
515 lines.push(render_inventory_line(agent));
516 }
517 }
518
519 Ok(lines.join("\n").trim().to_string())
520}
521
522fn parse_inventory_agent(
523 path: &Path,
524) -> Result<(Option<ParsedAgentInventory>, Vec<String>), String> {
525 let content = std::fs::read_to_string(path).map_err(|err| {
526 format!(
527 "failed to read agent inventory file {}: {err}",
528 path.display()
529 )
530 })?;
531
532 let mut parse_diags = Vec::new();
533 let (profile, _frontmatter) =
534 parse_agent_content(&content, &mut parse_diags).map_err(|err| {
535 format!(
536 "failed to parse agent inventory file {}: {err}",
537 path.display()
538 )
539 })?;
540
541 let mut warnings = Vec::new();
542 for diag in parse_diags {
543 if diag.is_error() {
544 return Err(format!(
545 "agent inventory file {} has invalid frontmatter: {}",
546 path.display(),
547 diag.message()
548 ));
549 }
550 warnings.push(format!(
551 "agent inventory parse warning in {}: {}",
552 path.display(),
553 diag.message()
554 ));
555 }
556 if !profile.model_invocable {
557 return Ok((None, warnings));
558 }
559
560 let fallback_name = path
561 .file_stem()
562 .and_then(|stem| stem.to_str())
563 .unwrap_or("unknown-agent")
564 .to_string();
565 let fanout = fallback_model_policies_for_inventory(&profile);
566 let name = profile.name.unwrap_or(fallback_name);
567 let description = profile.description.unwrap_or_default();
568 let mode = profile.mode.clone().unwrap_or(AgentMode::Subagent);
569
570 Ok((
571 Some(ParsedAgentInventory {
572 name,
573 description,
574 model: profile.model,
575 fanout,
576 mode,
577 }),
578 warnings,
579 ))
580}
581
582fn fallback_model_policies_for_inventory(
583 profile: &crate::compiler::agents::AgentProfile,
584) -> Vec<String> {
585 let mut entries = Vec::new();
586 let mut seen = HashSet::new();
587
588 for policy in &profile.model_policies {
591 if policy.no_fallback {
592 continue;
593 }
594 if !matches!(
595 policy.match_type,
596 ModelPolicyMatchType::Alias | ModelPolicyMatchType::Model
597 ) {
598 continue;
599 }
600 let value = policy.match_value.trim();
601 if value.is_empty() {
602 continue;
603 }
604 if seen.insert(value.to_string()) {
605 entries.push(value.to_string());
606 }
607 }
608
609 entries
610}
611
612fn render_inventory_line(agent: &ParsedAgentInventory) -> String {
613 let description = agent.description.trim();
614 let mut line = if description.is_empty() {
615 format!("- {}", agent.name)
616 } else {
617 format!("- {}: {}", agent.name, description)
618 };
619
620 if let Some(model) = agent.model.as_ref().map(|value| value.trim())
621 && !model.is_empty()
622 {
623 line.push_str(" | Model: ");
624 line.push_str(model);
625 }
626
627 if !agent.fanout.is_empty() {
628 line.push_str(" | Fan-out: ");
629 line.push_str(&agent.fanout.join(", "));
630 }
631
632 line
633}