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