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