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) -> 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    // model-invocable gates global discovery, not explicit profile references.
208    // If the agent profile lists a skill, it loads regardless.
209    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    // model-invocable gates global discovery, not explicit profile references.
245    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    // Auto-loaded skills: full content, already sorted by type priority
328    // (principles first, then guardrails, then others).
329    // Each skill has its own `# Skill: name` heading — no intermediate
330    // wrapper headings that would break markdown hierarchy.
331    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    // Available skills: names only, grouped by type.
339    // NOTE: meridian-cli recomposes this block independently in
340    // `composition.py::_render_available_skills_block`. Keep format in sync.
341    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        // Remaining types: each gets its own heading, no description.
379        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}