Skip to main content

mars_agents/build/
prompt.rs

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    /// Raw body without `# Skill: name` heading.
27    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    // model-invocable gates global discovery, not explicit profile references.
215    // If the agent profile lists a skill, it loads regardless.
216    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    // model-invocable gates global discovery, not explicit profile references.
255    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    // Auto-loaded skills: full content, already sorted by type priority
354    // (principles first, then guardrails, then others).
355    // Each skill has its own `# Skill: name` heading — no intermediate
356    // wrapper headings that would break markdown hierarchy.
357    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    // Available skills: names only, grouped by type.
365    // NOTE: meridian-cli recomposes this block independently in
366    // `composition.py::_render_available_skills_block`. Keep format in sync.
367    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        // Remaining types: each gets its own heading, no description.
405        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}