Skip to main content

mars_agents/build/
prompt.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::build::bundle::{AvailableSkill, LoadedSkill, 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, 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#[derive(Debug, Clone)]
47struct ParsedAgentInventory {
48    name: String,
49    description: String,
50    model: Option<String>,
51    fanout: Vec<String>,
52    mode: AgentMode,
53}
54
55#[allow(clippy::too_many_arguments)]
56pub fn compile_prompt_surface(
57    mars_dir: &Path,
58    agent_body: &str,
59    profile_skills: &SkillsSpec,
60    extra_skills: &[String],
61    harness_id: &str,
62    selected_model_token: &str,
63    canonical_model_id: &str,
64    subagents_filter: &[String],
65) -> Result<PromptCompilation, MarsError> {
66    let _ = (selected_model_token, canonical_model_id);
67
68    let requested_load_skills = requested_skill_order(&profile_skills.load, extra_skills);
69    let requested_available_skills = requested_available_skill_order(
70        &profile_skills.available,
71        requested_load_skills.iter().map(String::as_str),
72    );
73
74    let mut loaded_documents = Vec::new();
75    let mut missing_skills = Vec::new();
76    let mut warnings = Vec::new();
77
78    for (requested_index, skill) in requested_load_skills.iter().enumerate() {
79        match load_skill_document(mars_dir, skill, harness_id) {
80            Ok(SkillLoadOutcome::Loaded(data)) => {
81                loaded_documents.push(LoadedSkillDocument {
82                    requested_index,
83                    document: data.document,
84                    body: data.body,
85                });
86            }
87            Ok(SkillLoadOutcome::Missing) => missing_skills.push(skill.clone()),
88
89            Err(err) => {
90                warnings.push(err);
91                missing_skills.push(skill.clone());
92            }
93        }
94    }
95
96    loaded_documents.sort_by(|left, right| {
97        let left_key = (
98            skill_type_priority(&left.document.skill_type),
99            left.requested_index,
100        );
101        let right_key = (
102            skill_type_priority(&right.document.skill_type),
103            right.requested_index,
104        );
105        left_key.cmp(&right_key)
106    });
107
108    let supplemental_documents = loaded_documents
109        .iter()
110        .map(|loaded| loaded.document.clone())
111        .collect::<Vec<_>>();
112
113    let loaded_skills = loaded_documents
114        .iter()
115        .map(|loaded| LoadedSkill {
116            name: loaded.document.name.clone(),
117            skill_type: loaded.document.skill_type.clone(),
118            body: loaded.body.clone(),
119        })
120        .collect::<Vec<_>>();
121
122    let mut available_skills = Vec::new();
123    for skill in &requested_available_skills {
124        match resolve_available_skill(mars_dir, skill, harness_id) {
125            Ok(AvailableSkillOutcome::Available(skill)) => available_skills.push(skill),
126            Ok(AvailableSkillOutcome::Missing) => missing_skills.push(skill.clone()),
127
128            Err(err) => {
129                warnings.push(err);
130                missing_skills.push(skill.clone());
131            }
132        }
133    }
134
135    let inventory_prompt = build_inventory_prompt(mars_dir, subagents_filter, &mut warnings)?;
136    let system_instruction = compose_system_instruction(
137        agent_body,
138        &supplemental_documents,
139        &available_skills,
140        &inventory_prompt,
141        REPORT_INSTRUCTION,
142    );
143
144    Ok(PromptCompilation {
145        system_instruction,
146        supplemental_documents,
147        inventory_prompt,
148        loaded_skills,
149        available_skills,
150        missing_skills,
151        warnings,
152    })
153}
154
155fn requested_skill_order(profile_skills: &[String], extra_skills: &[String]) -> Vec<String> {
156    let mut seen = HashSet::new();
157    let mut ordered = Vec::new();
158
159    for name in profile_skills.iter().chain(extra_skills.iter()) {
160        let normalized = name.trim();
161        if normalized.is_empty() {
162            continue;
163        }
164        if seen.insert(normalized.to_string()) {
165            ordered.push(normalized.to_string());
166        }
167    }
168
169    ordered
170}
171
172fn requested_available_skill_order<'a>(
173    available_skills: &[String],
174    loaded_skills: impl Iterator<Item = &'a str>,
175) -> Vec<String> {
176    let mut blocked = loaded_skills
177        .map(|name| name.trim().to_string())
178        .collect::<HashSet<_>>();
179    blocked.remove("");
180
181    let mut seen = HashSet::new();
182    let mut ordered = Vec::new();
183    for name in available_skills {
184        let normalized = name.trim();
185        if normalized.is_empty() || blocked.contains(normalized) {
186            continue;
187        }
188        if seen.insert(normalized.to_string()) {
189            ordered.push(normalized.to_string());
190        }
191    }
192    ordered
193}
194
195fn skill_type_priority(skill_type: &str) -> u8 {
196    match skill_type {
197        "principle" => 0,
198        "guardrail" => 1,
199        "reference" => 2,
200        _ => 2,
201    }
202}
203
204fn load_skill_document(
205    mars_dir: &Path,
206    skill_name: &str,
207    harness_id: &str,
208) -> Result<SkillLoadOutcome, String> {
209    let skill_dir = mars_dir.join("skills").join(skill_name);
210    let base_skill_path = skill_dir.join("SKILL.md");
211    if !base_skill_path.is_file() {
212        return Ok(SkillLoadOutcome::Missing);
213    }
214
215    // model-invocable gates global discovery, not explicit profile references.
216    // If the agent profile lists a skill, it loads regardless.
217    let (_base_profile, base_frontmatter) = parse_skill_file(skill_name, &base_skill_path)?;
218
219    let selected_skill_path =
220        harness_skill_variant_path(&skill_dir, harness_id).unwrap_or(base_skill_path);
221    let (_, selected_frontmatter) = parse_skill_file(skill_name, &selected_skill_path)?;
222
223    let skill_type = skill_type_from_frontmatter(&selected_frontmatter)
224        .or_else(|| skill_type_from_frontmatter(&base_frontmatter));
225    let skill_type = skill_type.unwrap_or_else(|| "reference".to_string());
226
227    let body = selected_frontmatter.body().trim().to_string();
228    let content = render_skill_content_block(skill_name, &body);
229
230    Ok(SkillLoadOutcome::Loaded(LoadedSkillData {
231        document: SupplementalDoc {
232            kind: "skill".to_string(),
233            name: skill_name.to_string(),
234            content,
235            skill_type,
236        },
237        body,
238    }))
239}
240
241fn resolve_available_skill(
242    mars_dir: &Path,
243    skill_name: &str,
244    harness_id: &str,
245) -> Result<AvailableSkillOutcome, String> {
246    let skill_dir = mars_dir.join("skills").join(skill_name);
247    let base_skill_path = skill_dir.join("SKILL.md");
248    if !base_skill_path.is_file() {
249        return Ok(AvailableSkillOutcome::Missing);
250    }
251
252    // model-invocable gates global discovery, not explicit profile references.
253    let (base_profile, base_frontmatter) = parse_skill_file(skill_name, &base_skill_path)?;
254
255    let selected_skill_path =
256        harness_skill_variant_path(&skill_dir, harness_id).unwrap_or(base_skill_path);
257    let (selected_profile, selected_frontmatter) =
258        parse_skill_file(skill_name, &selected_skill_path)?;
259
260    let skill_type = skill_type_from_frontmatter(&selected_frontmatter)
261        .or_else(|| skill_type_from_frontmatter(&base_frontmatter))
262        .unwrap_or_else(|| "reference".to_string());
263    let description = selected_profile
264        .description
265        .or(base_profile.description)
266        .unwrap_or_default();
267
268    Ok(AvailableSkillOutcome::Available(AvailableSkill {
269        name: skill_name.to_string(),
270        skill_type,
271        description,
272    }))
273}
274
275fn parse_skill_file(
276    skill_name: &str,
277    skill_path: &Path,
278) -> Result<(crate::compiler::skills::SkillProfile, Frontmatter), String> {
279    let raw = std::fs::read_to_string(skill_path).map_err(|err| {
280        format!(
281            "failed to read skill `{skill_name}` from {}: {err}",
282            skill_path.display()
283        )
284    })?;
285
286    let mut skill_diags = Vec::new();
287    let parsed = parse_skill_content(&raw, &mut skill_diags).map_err(|err| {
288        format!(
289            "failed to parse skill `{skill_name}` from {}: {err}",
290            skill_path.display()
291        )
292    })?;
293
294    if let Some(diag) = skill_diags.first() {
295        return Err(format!(
296            "skill `{skill_name}` has invalid frontmatter in {}: {}",
297            skill_path.display(),
298            diag.message()
299        ));
300    }
301
302    Ok(parsed)
303}
304
305fn skill_type_from_frontmatter(frontmatter: &Frontmatter) -> Option<String> {
306    frontmatter
307        .get("type")
308        .and_then(|value| value.as_str())
309        .map(|value| value.trim().to_string())
310        .filter(|value| !value.is_empty())
311}
312
313fn render_skill_content_block(skill_name: &str, body: &str) -> String {
314    if body.is_empty() {
315        format!("# Skill: {skill_name}")
316    } else {
317        format!("# Skill: {skill_name}\n\n{body}")
318    }
319}
320
321fn compose_system_instruction(
322    agent_body: &str,
323    supplemental_documents: &[SupplementalDoc],
324    available_skills: &[AvailableSkill],
325    inventory_prompt: &str,
326    report_instruction: &str,
327) -> String {
328    let mut blocks: Vec<String> = Vec::new();
329
330    let body = agent_body.trim();
331    if !body.is_empty() {
332        blocks.push(format!("# Agent Profile\n\n{body}"));
333    }
334
335    // Auto-loaded skills: full content, already sorted by type priority
336    // (principles first, then guardrails, then others).
337    // Each skill has its own `# Skill: name` heading — no intermediate
338    // wrapper headings that would break markdown hierarchy.
339    for doc in supplemental_documents {
340        let content = doc.content.trim();
341        if !content.is_empty() {
342            blocks.push(content.to_string());
343        }
344    }
345
346    // Available skills: names only, grouped by type.
347    // NOTE: meridian-cli recomposes this block independently in
348    // `composition.py::_render_available_skills_block`. Keep format in sync.
349    if !available_skills.is_empty() {
350        let mut avail_block = String::from(
351            "# Available Skills\n\nNot yet loaded. Load proactively when the task fits.",
352        );
353        for (type_label, type_key, description) in &[
354            (
355                "Principles",
356                "principle",
357                "Override other guidance when loaded.",
358            ),
359            (
360                "Guardrails",
361                "guardrail",
362                "Load before acting in sensitive areas.",
363            ),
364            (
365                "Mode-shift",
366                "mode-shift",
367                "Change how you operate when loaded.",
368            ),
369            (
370                "Checkpoint",
371                "checkpoint",
372                "Load at decision points to verify before continuing.",
373            ),
374        ] {
375            let skills: Vec<_> = available_skills
376                .iter()
377                .filter(|s| s.skill_type == *type_key)
378                .collect();
379            if !skills.is_empty() {
380                avail_block.push_str(&format!("\n\n## {type_label}\n{description}"));
381                for skill in skills {
382                    avail_block.push_str(&format!("\n- {}", skill.name));
383                }
384            }
385        }
386        // Remaining types: each gets its own heading, no description.
387        let other_skills: Vec<_> = available_skills
388            .iter()
389            .filter(|s| {
390                s.skill_type != "principle"
391                    && s.skill_type != "guardrail"
392                    && s.skill_type != "mode-shift"
393                    && s.skill_type != "checkpoint"
394            })
395            .collect();
396        if !other_skills.is_empty() {
397            let mut seen_types: Vec<&str> = Vec::new();
398            for s in &other_skills {
399                if !seen_types.contains(&s.skill_type.as_str()) {
400                    seen_types.push(&s.skill_type);
401                }
402            }
403            for type_key in &seen_types {
404                let group: Vec<_> = other_skills
405                    .iter()
406                    .filter(|s| s.skill_type == *type_key)
407                    .collect();
408                let mut capitalized = type_key.to_string();
409                if let Some(first) = capitalized.get_mut(0..1) {
410                    first.make_ascii_uppercase();
411                }
412                avail_block.push_str(&format!("\n\n## {capitalized}"));
413                for skill in group {
414                    avail_block.push_str(&format!("\n- {}", skill.name));
415                }
416            }
417        }
418        blocks.push(avail_block);
419    }
420
421    let inventory = inventory_prompt.trim();
422    if !inventory.is_empty() {
423        blocks.push(inventory.to_string());
424    }
425
426    blocks.push(report_instruction.to_string());
427
428    blocks.join("\n\n")
429}
430
431fn build_inventory_prompt(
432    mars_dir: &Path,
433    subagents_filter: &[String],
434    warnings: &mut Vec<String>,
435) -> Result<String, MarsError> {
436    let agents_dir = mars_dir.join("agents");
437    if !agents_dir.is_dir() {
438        return Ok(String::new());
439    }
440
441    let read_dir = match std::fs::read_dir(&agents_dir) {
442        Ok(entries) => entries,
443        Err(err) => {
444            warnings.push(format!(
445                "failed to read agent inventory from {}: {err}",
446                agents_dir.display()
447            ));
448            return Ok(String::new());
449        }
450    };
451
452    let mut agent_paths: Vec<PathBuf> = read_dir
453        .filter_map(Result::ok)
454        .map(|entry| entry.path())
455        .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
456        .collect();
457    agent_paths.sort();
458
459    let mut primary_agents = Vec::new();
460    let mut subagent_agents = Vec::new();
461
462    for path in agent_paths {
463        match parse_inventory_agent(&path) {
464            Ok((Some(agent), agent_warnings)) => {
465                warnings.extend(agent_warnings);
466                if agent.mode == AgentMode::Primary {
467                    primary_agents.push(agent);
468                } else {
469                    subagent_agents.push(agent);
470                }
471            }
472            Ok((None, agent_warnings)) => warnings.extend(agent_warnings),
473            Err(err) => {
474                return Err(MarsError::Config(ConfigError::Invalid { message: err }));
475            }
476        }
477    }
478
479    if !subagents_filter.is_empty() {
480        primary_agents.retain(|agent| {
481            subagents_filter
482                .iter()
483                .any(|f| f.eq_ignore_ascii_case(&agent.name))
484        });
485        subagent_agents.retain(|agent| {
486            subagents_filter
487                .iter()
488                .any(|f| f.eq_ignore_ascii_case(&agent.name))
489        });
490    }
491
492    if primary_agents.is_empty() && subagent_agents.is_empty() {
493        return Ok(String::new());
494    }
495
496    primary_agents.sort_by(|left, right| left.name.cmp(&right.name));
497    subagent_agents.sort_by(|left, right| left.name.cmp(&right.name));
498
499    let mut lines = vec![
500        "# Meridian Agents".to_string(),
501        "".to_string(),
502        "Installed Meridian agents available at launch time.".to_string(),
503    ];
504
505    if !primary_agents.is_empty() {
506        lines.extend(["".to_string(), "## Primary".to_string()]);
507        for agent in &primary_agents {
508            lines.push(render_inventory_line(agent));
509        }
510    }
511
512    if !subagent_agents.is_empty() {
513        lines.extend(["".to_string(), "## Subagent".to_string()]);
514        for agent in &subagent_agents {
515            lines.push(render_inventory_line(agent));
516        }
517    }
518
519    Ok(lines.join("\n").trim().to_string())
520}
521
522fn parse_inventory_agent(
523    path: &Path,
524) -> Result<(Option<ParsedAgentInventory>, Vec<String>), String> {
525    let content = std::fs::read_to_string(path).map_err(|err| {
526        format!(
527            "failed to read agent inventory file {}: {err}",
528            path.display()
529        )
530    })?;
531
532    let mut parse_diags = Vec::new();
533    let (profile, _frontmatter) =
534        parse_agent_content(&content, &mut parse_diags).map_err(|err| {
535            format!(
536                "failed to parse agent inventory file {}: {err}",
537                path.display()
538            )
539        })?;
540
541    let mut warnings = Vec::new();
542    for diag in parse_diags {
543        if diag.is_error() {
544            return Err(format!(
545                "agent inventory file {} has invalid frontmatter: {}",
546                path.display(),
547                diag.message()
548            ));
549        }
550        warnings.push(format!(
551            "agent inventory parse warning in {}: {}",
552            path.display(),
553            diag.message()
554        ));
555    }
556    if !profile.model_invocable {
557        return Ok((None, warnings));
558    }
559
560    let fallback_name = path
561        .file_stem()
562        .and_then(|stem| stem.to_str())
563        .unwrap_or("unknown-agent")
564        .to_string();
565    let fanout = fallback_model_policies_for_inventory(&profile);
566    let name = profile.name.unwrap_or(fallback_name);
567    let description = profile.description.unwrap_or_default();
568    let mode = profile.mode.clone().unwrap_or(AgentMode::Subagent);
569
570    Ok((
571        Some(ParsedAgentInventory {
572            name,
573            description,
574            model: profile.model,
575            fanout,
576            mode,
577        }),
578        warnings,
579    ))
580}
581
582fn fallback_model_policies_for_inventory(
583    profile: &crate::compiler::agents::AgentProfile,
584) -> Vec<String> {
585    let mut entries = Vec::new();
586    let mut seen = HashSet::new();
587
588    // Limitation: this deduplicates exact fallback labels only. Alias-to-model
589    // canonical dedupe requires alias catalog context not currently loaded here.
590    for policy in &profile.model_policies {
591        if policy.no_fallback {
592            continue;
593        }
594        if !matches!(
595            policy.match_type,
596            ModelPolicyMatchType::Alias | ModelPolicyMatchType::Model
597        ) {
598            continue;
599        }
600        let value = policy.match_value.trim();
601        if value.is_empty() {
602            continue;
603        }
604        if seen.insert(value.to_string()) {
605            entries.push(value.to_string());
606        }
607    }
608
609    entries
610}
611
612fn render_inventory_line(agent: &ParsedAgentInventory) -> String {
613    let description = agent.description.trim();
614    let mut line = if description.is_empty() {
615        format!("- {}", agent.name)
616    } else {
617        format!("- {}: {}", agent.name, description)
618    };
619
620    if let Some(model) = agent.model.as_ref().map(|value| value.trim())
621        && !model.is_empty()
622    {
623        line.push_str(" | Model: ");
624        line.push_str(model);
625    }
626
627    if !agent.fanout.is_empty() {
628        line.push_str(" | Fan-out: ");
629        line.push_str(&agent.fanout.join(", "));
630    }
631
632    line
633}