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