1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::build::bundle::{AvailableSkill, LoadedSkill, SupplementalDoc};
5use crate::build::inventory::build_inventory_prompt;
6use crate::compiler::skills::parse_skill_content;
7use crate::compiler::variants::harness_skill_variant_path;
8use crate::error::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#[allow(clippy::too_many_arguments)]
47pub fn compile_prompt_surface(
48 mars_dir: &Path,
49 agent_body: &str,
50 profile_skills: &SkillsSpec,
51 extra_skills: &[String],
52 harness_id: &str,
53 selected_model_token: &str,
54 canonical_model_id: &str,
55 subagents_filter: &[String],
56 fanout_agents: &[String],
57) -> Result<PromptCompilation, MarsError> {
58 let _ = (selected_model_token, canonical_model_id);
59
60 let requested_load_skills = requested_skill_order(&profile_skills.load, extra_skills);
61 let requested_available_skills = requested_available_skill_order(
62 &profile_skills.available,
63 requested_load_skills.iter().map(String::as_str),
64 );
65
66 let mut loaded_documents = Vec::new();
67 let mut missing_skills = Vec::new();
68 let mut warnings = Vec::new();
69
70 for (requested_index, skill) in requested_load_skills.iter().enumerate() {
71 match load_skill_document(mars_dir, skill, harness_id, &mut warnings) {
72 Ok(SkillLoadOutcome::Loaded(data)) => {
73 loaded_documents.push(LoadedSkillDocument {
74 requested_index,
75 document: data.document,
76 body: data.body,
77 });
78 }
79 Ok(SkillLoadOutcome::Missing) => missing_skills.push(skill.clone()),
80
81 Err(err) => {
82 warnings.push(err);
83 missing_skills.push(skill.clone());
84 }
85 }
86 }
87
88 loaded_documents.sort_by(|left, right| {
89 let left_key = (
90 skill_type_priority(&left.document.skill_type),
91 left.requested_index,
92 );
93 let right_key = (
94 skill_type_priority(&right.document.skill_type),
95 right.requested_index,
96 );
97 left_key.cmp(&right_key)
98 });
99
100 let supplemental_documents = loaded_documents
101 .iter()
102 .map(|loaded| loaded.document.clone())
103 .collect::<Vec<_>>();
104
105 let loaded_skills = loaded_documents
106 .iter()
107 .map(|loaded| LoadedSkill {
108 name: loaded.document.name.clone(),
109 skill_type: loaded.document.skill_type.clone(),
110 body: loaded.body.clone(),
111 })
112 .collect::<Vec<_>>();
113
114 let mut available_skills = Vec::new();
115 for skill in &requested_available_skills {
116 match resolve_available_skill(mars_dir, skill, &mut warnings) {
117 Ok(AvailableSkillOutcome::Available(skill)) => available_skills.push(skill),
118 Ok(AvailableSkillOutcome::Missing) => missing_skills.push(skill.clone()),
119
120 Err(err) => {
121 warnings.push(err);
122 missing_skills.push(skill.clone());
123 }
124 }
125 }
126
127 let inventory_prompt = build_inventory_prompt(
128 mars_dir,
129 subagents_filter,
130 harness_id,
131 fanout_agents,
132 &mut warnings,
133 )?;
134 let system_instruction = compose_system_instruction(
135 agent_body,
136 &supplemental_documents,
137 &available_skills,
138 &inventory_prompt,
139 REPORT_INSTRUCTION,
140 );
141
142 Ok(PromptCompilation {
143 system_instruction,
144 supplemental_documents,
145 inventory_prompt,
146 loaded_skills,
147 available_skills,
148 missing_skills,
149 warnings,
150 })
151}
152
153fn requested_skill_order(profile_skills: &[String], extra_skills: &[String]) -> Vec<String> {
154 let mut seen = HashSet::new();
155 let mut ordered = Vec::new();
156
157 for name in profile_skills.iter().chain(extra_skills.iter()) {
158 let normalized = name.trim();
159 if normalized.is_empty() {
160 continue;
161 }
162 if seen.insert(normalized.to_string()) {
163 ordered.push(normalized.to_string());
164 }
165 }
166
167 ordered
168}
169
170fn requested_available_skill_order<'a>(
171 available_skills: &[String],
172 loaded_skills: impl Iterator<Item = &'a str>,
173) -> Vec<String> {
174 let mut blocked = loaded_skills
175 .map(|name| name.trim().to_string())
176 .collect::<HashSet<_>>();
177 blocked.remove("");
178
179 let mut seen = HashSet::new();
180 let mut ordered = Vec::new();
181 for name in available_skills {
182 let normalized = name.trim();
183 if normalized.is_empty() || blocked.contains(normalized) {
184 continue;
185 }
186 if seen.insert(normalized.to_string()) {
187 ordered.push(normalized.to_string());
188 }
189 }
190 ordered
191}
192
193fn skill_type_priority(skill_type: &str) -> u8 {
194 match skill_type {
195 "principle" => 0,
196 "guardrail" => 1,
197 "reference" => 2,
198 _ => 2,
199 }
200}
201
202fn load_skill_document(
203 mars_dir: &Path,
204 skill_name: &str,
205 harness_id: &str,
206 warnings: &mut Vec<String>,
207) -> Result<SkillLoadOutcome, String> {
208 let skill_dir = mars_dir.join("skills").join(skill_name);
209 let base_skill_path = skill_dir.join("SKILL.md");
210 if !base_skill_path.is_file() {
211 return Ok(SkillLoadOutcome::Missing);
212 }
213
214 let (_base_profile, base_frontmatter) =
217 parse_skill_file(skill_name, &base_skill_path, warnings)?;
218
219 let selected_skill_path = harness_skill_variant_path(&skill_dir, harness_id)
220 .unwrap_or_else(|| base_skill_path.clone());
221 let selected_body = if selected_skill_path == base_skill_path {
222 base_frontmatter.body().trim().to_string()
223 } else {
224 read_skill_body(skill_name, &selected_skill_path)?
225 };
226
227 let skill_type = skill_type_from_frontmatter(&base_frontmatter);
228 let skill_type = skill_type.unwrap_or_else(|| "reference".to_string());
229
230 let content = render_skill_content_block(skill_name, &selected_body);
231
232 Ok(SkillLoadOutcome::Loaded(LoadedSkillData {
233 document: SupplementalDoc {
234 kind: "skill".to_string(),
235 name: skill_name.to_string(),
236 content,
237 skill_type,
238 },
239 body: selected_body,
240 }))
241}
242
243fn resolve_available_skill(
244 mars_dir: &Path,
245 skill_name: &str,
246 warnings: &mut Vec<String>,
247) -> Result<AvailableSkillOutcome, String> {
248 let skill_dir = mars_dir.join("skills").join(skill_name);
249 let base_skill_path = skill_dir.join("SKILL.md");
250 if !base_skill_path.is_file() {
251 return Ok(AvailableSkillOutcome::Missing);
252 }
253
254 let (base_profile, base_frontmatter) =
256 parse_skill_file(skill_name, &base_skill_path, warnings)?;
257
258 let skill_type =
259 skill_type_from_frontmatter(&base_frontmatter).unwrap_or_else(|| "reference".to_string());
260 let description = base_profile.description.unwrap_or_default();
261
262 Ok(AvailableSkillOutcome::Available(AvailableSkill {
263 name: skill_name.to_string(),
264 skill_type,
265 description,
266 }))
267}
268
269fn read_skill_body(skill_name: &str, skill_path: &Path) -> Result<String, String> {
270 let raw = std::fs::read_to_string(skill_path).map_err(|err| {
271 format!(
272 "failed to read skill `{skill_name}` from {}: {err}",
273 skill_path.display()
274 )
275 })?;
276 let frontmatter = Frontmatter::parse(&raw).map_err(|err| {
277 format!(
278 "failed to parse skill `{skill_name}` from {}: {err}",
279 skill_path.display()
280 )
281 })?;
282 Ok(frontmatter.body().trim().to_string())
283}
284
285fn parse_skill_file(
286 skill_name: &str,
287 skill_path: &Path,
288 warnings: &mut Vec<String>,
289) -> Result<(crate::compiler::skills::SkillProfile, Frontmatter), String> {
290 let raw = std::fs::read_to_string(skill_path).map_err(|err| {
291 format!(
292 "failed to read skill `{skill_name}` from {}: {err}",
293 skill_path.display()
294 )
295 })?;
296
297 let mut skill_diags = Vec::new();
298 let parsed = parse_skill_content(&raw, &mut skill_diags).map_err(|err| {
299 format!(
300 "failed to parse skill `{skill_name}` from {}: {err}",
301 skill_path.display()
302 )
303 })?;
304
305 for diag in skill_diags {
306 if diag.is_error() {
307 return Err(format!(
308 "skill `{skill_name}` has invalid frontmatter in {}: {}",
309 skill_path.display(),
310 diag.message()
311 ));
312 }
313 warnings.push(format!(
314 "skill `{skill_name}` has frontmatter warning in {}: {}",
315 skill_path.display(),
316 diag.message()
317 ));
318 }
319
320 Ok(parsed)
321}
322
323fn skill_type_from_frontmatter(frontmatter: &Frontmatter) -> Option<String> {
324 frontmatter
325 .get("type")
326 .and_then(|value| value.as_str())
327 .map(|value| value.trim().to_string())
328 .filter(|value| !value.is_empty())
329}
330
331fn render_skill_content_block(skill_name: &str, body: &str) -> String {
332 if body.is_empty() {
333 format!("# Skill: {skill_name}")
334 } else {
335 format!("# Skill: {skill_name}\n\n{body}")
336 }
337}
338
339fn compose_system_instruction(
340 agent_body: &str,
341 supplemental_documents: &[SupplementalDoc],
342 available_skills: &[AvailableSkill],
343 inventory_prompt: &str,
344 report_instruction: &str,
345) -> String {
346 let mut blocks: Vec<String> = Vec::new();
347
348 let body = agent_body.trim();
349 if !body.is_empty() {
350 blocks.push(format!("# Agent Profile\n\n{body}"));
351 }
352
353 for doc in supplemental_documents {
358 let content = doc.content.trim();
359 if !content.is_empty() {
360 blocks.push(content.to_string());
361 }
362 }
363
364 if !available_skills.is_empty() {
368 let mut avail_block = String::from(
369 "# Available Skills\n\nNot yet loaded. Load proactively when the task fits.",
370 );
371 for (type_label, type_key, description) in &[
372 (
373 "Principles",
374 "principle",
375 "Override other guidance when loaded.",
376 ),
377 (
378 "Guardrails",
379 "guardrail",
380 "Load before acting in sensitive areas.",
381 ),
382 (
383 "Mode-shift",
384 "mode-shift",
385 "Change how you operate when loaded.",
386 ),
387 (
388 "Checkpoint",
389 "checkpoint",
390 "Load at decision points to verify before continuing.",
391 ),
392 ] {
393 let skills: Vec<_> = available_skills
394 .iter()
395 .filter(|s| s.skill_type == *type_key)
396 .collect();
397 if !skills.is_empty() {
398 avail_block.push_str(&format!("\n\n## {type_label}\n{description}"));
399 for skill in skills {
400 avail_block.push_str(&format!("\n- {}", skill.name));
401 }
402 }
403 }
404 let other_skills: Vec<_> = available_skills
406 .iter()
407 .filter(|s| {
408 s.skill_type != "principle"
409 && s.skill_type != "guardrail"
410 && s.skill_type != "mode-shift"
411 && s.skill_type != "checkpoint"
412 })
413 .collect();
414 if !other_skills.is_empty() {
415 let mut seen_types: Vec<&str> = Vec::new();
416 for s in &other_skills {
417 if !seen_types.contains(&s.skill_type.as_str()) {
418 seen_types.push(&s.skill_type);
419 }
420 }
421 for type_key in &seen_types {
422 let group: Vec<_> = other_skills
423 .iter()
424 .filter(|s| s.skill_type == *type_key)
425 .collect();
426 let mut capitalized = type_key.to_string();
427 if let Some(first) = capitalized.get_mut(0..1) {
428 first.make_ascii_uppercase();
429 }
430 avail_block.push_str(&format!("\n\n## {capitalized}"));
431 for skill in group {
432 avail_block.push_str(&format!("\n- {}", skill.name));
433 }
434 }
435 }
436 blocks.push(avail_block);
437 }
438
439 let inventory = inventory_prompt.trim();
440 if !inventory.is_empty() {
441 blocks.push(inventory.to_string());
442 }
443
444 blocks.push(report_instruction.to_string());
445
446 blocks.join("\n\n")
447}