Skip to main content

mars_agents/build/
prompt.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::build::bundle::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;
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<String>,
18    pub missing_skills: Vec<String>,
19    pub warnings: Vec<String>,
20}
21
22struct LoadedSkillDocument {
23    requested_index: usize,
24    document: SupplementalDoc,
25}
26
27enum SkillLoadOutcome {
28    Loaded(SupplementalDoc),
29    Missing,
30    SkippedModelInvocableFalse,
31}
32
33#[derive(Debug, Clone)]
34struct ParsedAgentInventory {
35    name: String,
36    description: String,
37    model: Option<String>,
38    fanout: Vec<String>,
39    mode: AgentMode,
40}
41
42#[allow(clippy::too_many_arguments)]
43pub fn compile_prompt_surface(
44    mars_dir: &Path,
45    agent_body: &str,
46    profile_skills: &[String],
47    extra_skills: &[String],
48    harness_id: &str,
49    selected_model_token: &str,
50    canonical_model_id: &str,
51    subagents_filter: &[String],
52) -> Result<PromptCompilation, MarsError> {
53    let _ = (selected_model_token, canonical_model_id);
54
55    let requested_skills = requested_skill_order(profile_skills, extra_skills);
56
57    let mut loaded_documents = Vec::new();
58    let mut missing_skills = Vec::new();
59    let mut warnings = Vec::new();
60
61    for (requested_index, skill) in requested_skills.iter().enumerate() {
62        match load_skill_document(mars_dir, skill, harness_id) {
63            Ok(SkillLoadOutcome::Loaded(document)) => {
64                loaded_documents.push(LoadedSkillDocument {
65                    requested_index,
66                    document,
67                });
68            }
69            Ok(SkillLoadOutcome::Missing) => missing_skills.push(skill.clone()),
70            Ok(SkillLoadOutcome::SkippedModelInvocableFalse) => {}
71            Err(err) => {
72                warnings.push(err);
73                missing_skills.push(skill.clone());
74            }
75        }
76    }
77
78    loaded_documents.sort_by(|left, right| {
79        let left_key = (
80            skill_type_priority(&left.document.skill_type),
81            left.requested_index,
82        );
83        let right_key = (
84            skill_type_priority(&right.document.skill_type),
85            right.requested_index,
86        );
87        left_key.cmp(&right_key)
88    });
89
90    let supplemental_documents = loaded_documents
91        .iter()
92        .map(|loaded| loaded.document.clone())
93        .collect::<Vec<_>>();
94
95    let loaded_skills = loaded_documents
96        .iter()
97        .map(|loaded| loaded.document.name.clone())
98        .collect::<Vec<_>>();
99
100    let inventory_prompt = build_inventory_prompt(mars_dir, subagents_filter, &mut warnings)?;
101    let system_instruction = compose_system_instruction(
102        agent_body,
103        &supplemental_documents,
104        &inventory_prompt,
105        REPORT_INSTRUCTION,
106    );
107
108    Ok(PromptCompilation {
109        system_instruction,
110        supplemental_documents,
111        inventory_prompt,
112        loaded_skills,
113        missing_skills,
114        warnings,
115    })
116}
117
118fn requested_skill_order(profile_skills: &[String], extra_skills: &[String]) -> Vec<String> {
119    let mut seen = HashSet::new();
120    let mut ordered = Vec::new();
121
122    for name in profile_skills.iter().chain(extra_skills.iter()) {
123        let normalized = name.trim();
124        if normalized.is_empty() {
125            continue;
126        }
127        if seen.insert(normalized.to_string()) {
128            ordered.push(normalized.to_string());
129        }
130    }
131
132    ordered
133}
134
135fn skill_type_priority(skill_type: &str) -> u8 {
136    match skill_type {
137        "principle" => 0,
138        "guardrail" => 1,
139        "reference" => 2,
140        _ => 2,
141    }
142}
143
144fn load_skill_document(
145    mars_dir: &Path,
146    skill_name: &str,
147    harness_id: &str,
148) -> Result<SkillLoadOutcome, String> {
149    let skill_dir = mars_dir.join("skills").join(skill_name);
150    let base_skill_path = skill_dir.join("SKILL.md");
151    if !base_skill_path.is_file() {
152        return Ok(SkillLoadOutcome::Missing);
153    }
154
155    let (base_profile, base_frontmatter) = parse_skill_file(skill_name, &base_skill_path)?;
156    if !base_profile.model_invocable {
157        return Ok(SkillLoadOutcome::SkippedModelInvocableFalse);
158    }
159
160    let selected_skill_path =
161        harness_skill_variant_path(&skill_dir, harness_id).unwrap_or(base_skill_path);
162    let (_, selected_frontmatter) = parse_skill_file(skill_name, &selected_skill_path)?;
163
164    let skill_type = skill_type_from_frontmatter(&selected_frontmatter)
165        .or_else(|| skill_type_from_frontmatter(&base_frontmatter));
166    let skill_type = skill_type.unwrap_or_else(|| "reference".to_string());
167
168    let content = render_skill_content_block(skill_name, selected_frontmatter.body().trim());
169
170    Ok(SkillLoadOutcome::Loaded(SupplementalDoc {
171        kind: "skill".to_string(),
172        name: skill_name.to_string(),
173        content,
174        skill_type,
175        detail: base_profile.detail.clone().unwrap_or_default(),
176    }))
177}
178
179fn parse_skill_file(
180    skill_name: &str,
181    skill_path: &Path,
182) -> Result<(crate::compiler::skills::SkillProfile, Frontmatter), String> {
183    let raw = std::fs::read_to_string(skill_path).map_err(|err| {
184        format!(
185            "failed to read skill `{skill_name}` from {}: {err}",
186            skill_path.display()
187        )
188    })?;
189
190    let mut skill_diags = Vec::new();
191    let parsed = parse_skill_content(&raw, &mut skill_diags).map_err(|err| {
192        format!(
193            "failed to parse skill `{skill_name}` from {}: {err}",
194            skill_path.display()
195        )
196    })?;
197
198    if let Some(diag) = skill_diags.first() {
199        return Err(format!(
200            "skill `{skill_name}` has invalid frontmatter in {}: {}",
201            skill_path.display(),
202            diag.message()
203        ));
204    }
205
206    Ok(parsed)
207}
208
209fn skill_type_from_frontmatter(frontmatter: &Frontmatter) -> Option<String> {
210    frontmatter
211        .get("type")
212        .and_then(|value| value.as_str())
213        .map(|value| value.trim().to_string())
214        .filter(|value| !value.is_empty())
215}
216
217fn render_skill_content_block(skill_name: &str, body: &str) -> String {
218    if body.is_empty() {
219        format!("# Skill: {skill_name}")
220    } else {
221        format!("# Skill: {skill_name}\n\n{body}")
222    }
223}
224
225fn compose_system_instruction(
226    agent_body: &str,
227    supplemental_documents: &[SupplementalDoc],
228    inventory_prompt: &str,
229    report_instruction: &str,
230) -> String {
231    let mut blocks: Vec<String> = Vec::new();
232
233    let body = agent_body.trim();
234    if !body.is_empty() {
235        blocks.push(format!("# Agent Profile\n\n{body}"));
236    }
237
238    for doc in supplemental_documents {
239        let content = doc.content.trim();
240        if !content.is_empty() {
241            blocks.push(content.to_string());
242        }
243    }
244
245    let inventory = inventory_prompt.trim();
246    if !inventory.is_empty() {
247        blocks.push(inventory.to_string());
248    }
249
250    blocks.push(report_instruction.to_string());
251
252    for doc in supplemental_documents
253        .iter()
254        .filter(|doc| doc.skill_type == "principle")
255    {
256        let content = doc.content.trim();
257        if !content.is_empty() {
258            blocks.push(content.to_string());
259        }
260    }
261
262    blocks.join("\n\n")
263}
264
265fn build_inventory_prompt(
266    mars_dir: &Path,
267    subagents_filter: &[String],
268    warnings: &mut Vec<String>,
269) -> Result<String, MarsError> {
270    let agents_dir = mars_dir.join("agents");
271    if !agents_dir.is_dir() {
272        return Ok(String::new());
273    }
274
275    let read_dir = match std::fs::read_dir(&agents_dir) {
276        Ok(entries) => entries,
277        Err(err) => {
278            warnings.push(format!(
279                "failed to read agent inventory from {}: {err}",
280                agents_dir.display()
281            ));
282            return Ok(String::new());
283        }
284    };
285
286    let mut agent_paths: Vec<PathBuf> = read_dir
287        .filter_map(Result::ok)
288        .map(|entry| entry.path())
289        .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
290        .collect();
291    agent_paths.sort();
292
293    let mut primary_agents = Vec::new();
294    let mut subagent_agents = Vec::new();
295
296    for path in agent_paths {
297        match parse_inventory_agent(&path) {
298            Ok((Some(agent), agent_warnings)) => {
299                warnings.extend(agent_warnings);
300                if agent.mode == AgentMode::Primary {
301                    primary_agents.push(agent);
302                } else {
303                    subagent_agents.push(agent);
304                }
305            }
306            Ok((None, agent_warnings)) => warnings.extend(agent_warnings),
307            Err(err) => {
308                return Err(MarsError::Config(ConfigError::Invalid { message: err }));
309            }
310        }
311    }
312
313    if !subagents_filter.is_empty() {
314        primary_agents.retain(|agent| {
315            subagents_filter
316                .iter()
317                .any(|f| f.eq_ignore_ascii_case(&agent.name))
318        });
319        subagent_agents.retain(|agent| {
320            subagents_filter
321                .iter()
322                .any(|f| f.eq_ignore_ascii_case(&agent.name))
323        });
324    }
325
326    if primary_agents.is_empty() && subagent_agents.is_empty() {
327        return Ok(String::new());
328    }
329
330    primary_agents.sort_by(|left, right| left.name.cmp(&right.name));
331    subagent_agents.sort_by(|left, right| left.name.cmp(&right.name));
332
333    let mut lines = vec![
334        "# Meridian Agents".to_string(),
335        "".to_string(),
336        "Installed Meridian agents available at launch time.".to_string(),
337    ];
338
339    if !primary_agents.is_empty() {
340        lines.extend(["".to_string(), "## Primary".to_string()]);
341        for agent in &primary_agents {
342            lines.push(render_inventory_line(agent));
343        }
344    }
345
346    if !subagent_agents.is_empty() {
347        lines.extend(["".to_string(), "## Subagent".to_string()]);
348        for agent in &subagent_agents {
349            lines.push(render_inventory_line(agent));
350        }
351    }
352
353    Ok(lines.join("\n").trim().to_string())
354}
355
356fn parse_inventory_agent(
357    path: &Path,
358) -> Result<(Option<ParsedAgentInventory>, Vec<String>), String> {
359    let content = std::fs::read_to_string(path).map_err(|err| {
360        format!(
361            "failed to read agent inventory file {}: {err}",
362            path.display()
363        )
364    })?;
365
366    let mut parse_diags = Vec::new();
367    let (profile, _frontmatter) =
368        parse_agent_content(&content, &mut parse_diags).map_err(|err| {
369            format!(
370                "failed to parse agent inventory file {}: {err}",
371                path.display()
372            )
373        })?;
374
375    let mut warnings = Vec::new();
376    for diag in parse_diags {
377        if diag.is_error() {
378            return Err(format!(
379                "agent inventory file {} has invalid frontmatter: {}",
380                path.display(),
381                diag.message()
382            ));
383        }
384        warnings.push(format!(
385            "agent inventory parse warning in {}: {}",
386            path.display(),
387            diag.message()
388        ));
389    }
390    if !profile.model_invocable {
391        return Ok((None, warnings));
392    }
393
394    let fallback_name = path
395        .file_stem()
396        .and_then(|stem| stem.to_str())
397        .unwrap_or("unknown-agent")
398        .to_string();
399    let fanout = fallback_model_policies_for_inventory(&profile);
400    let name = profile.name.unwrap_or(fallback_name);
401    let description = profile.description.unwrap_or_default();
402    let mode = profile.mode.clone().unwrap_or(AgentMode::Subagent);
403
404    Ok((
405        Some(ParsedAgentInventory {
406            name,
407            description,
408            model: profile.model,
409            fanout,
410            mode,
411        }),
412        warnings,
413    ))
414}
415
416fn fallback_model_policies_for_inventory(
417    profile: &crate::compiler::agents::AgentProfile,
418) -> Vec<String> {
419    let mut entries = Vec::new();
420    let mut seen = HashSet::new();
421
422    // Limitation: this deduplicates exact fallback labels only. Alias-to-model
423    // canonical dedupe requires alias catalog context not currently loaded here.
424    for policy in &profile.model_policies {
425        if policy.no_fallback {
426            continue;
427        }
428        if !matches!(
429            policy.match_type,
430            ModelPolicyMatchType::Alias | ModelPolicyMatchType::Model
431        ) {
432            continue;
433        }
434        let value = policy.match_value.trim();
435        if value.is_empty() {
436            continue;
437        }
438        if seen.insert(value.to_string()) {
439            entries.push(value.to_string());
440        }
441    }
442
443    entries
444}
445
446fn render_inventory_line(agent: &ParsedAgentInventory) -> String {
447    let description = agent.description.trim();
448    let mut line = if description.is_empty() {
449        format!("- {}", agent.name)
450    } else {
451        format!("- {}: {}", agent.name, description)
452    };
453
454    if let Some(model) = agent.model.as_ref().map(|value| value.trim())
455        && !model.is_empty()
456    {
457        line.push_str(" | Model: ");
458        line.push_str(model);
459    }
460
461    if !agent.fanout.is_empty() {
462        line.push_str(" | Fan-out: ");
463        line.push_str(&agent.fanout.join(", "));
464    }
465
466    line
467}