use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
pub(crate) const MAX_PRIMARY_AGENT_PROFILE_BYTES: u64 = 64 * 1024;
const ID_RULES: &str = "primary agent id must use only ASCII letters, digits, '_' or '-'";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PrimaryAgentProfile {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) description: String,
pub(crate) path: PathBuf,
pub(crate) prompt: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PrimaryAgentProfileDiagnostic {
pub(crate) id: Option<String>,
pub(crate) path: Option<PathBuf>,
pub(crate) message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PrimaryAgentProfileDiscovery {
pub(crate) profiles: BTreeMap<String, PrimaryAgentProfile>,
pub(crate) diagnostics: Vec<PrimaryAgentProfileDiagnostic>,
}
pub(crate) fn validate_primary_agent_id(id: &str) -> anyhow::Result<String> {
if id.is_empty() {
anyhow::bail!("primary agent id must not be empty; {ID_RULES}");
}
if id != id.trim() {
anyhow::bail!("primary agent id must not have leading or trailing whitespace; {ID_RULES}");
}
if id == "." || id == ".." {
anyhow::bail!("primary agent id must not be a path segment; {ID_RULES}");
}
if !id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
{
anyhow::bail!("{ID_RULES}");
}
Ok(id.to_string())
}
pub(crate) fn discover_primary_agent_profiles(root: &Path) -> PrimaryAgentProfileDiscovery {
let mut discovery = PrimaryAgentProfileDiscovery::default();
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return discovery,
Err(error) => {
discovery.diagnostics.push(PrimaryAgentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: format!("could not read primary agent profiles directory: {error}"),
});
return discovery;
}
};
let mut paths = entries
.filter_map(|entry| match entry {
Ok(entry) => Some(entry.path()),
Err(error) => {
discovery.diagnostics.push(PrimaryAgentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: format!(
"could not inspect primary agent profile directory entry: {error}"
),
});
None
}
})
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
.collect::<Vec<_>>();
paths.sort();
for path in paths {
match load_primary_agent_profile(&path) {
Ok(profile) => {
discovery.profiles.insert(profile.id.clone(), profile);
}
Err(error) => discovery.diagnostics.push(PrimaryAgentProfileDiagnostic {
id: path
.file_stem()
.and_then(|stem| stem.to_str())
.map(ToString::to_string),
path: Some(path.clone()),
message: format!("skipping primary agent profile: {error}"),
}),
}
}
discovery
}
pub(crate) fn load_primary_agent_profile(path: &Path) -> anyhow::Result<PrimaryAgentProfile> {
let id = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| anyhow::anyhow!("profile filename stem must be valid UTF-8"))?;
let id = validate_primary_agent_id(id)?;
let text =
crate::prompt_file::read_prompt_file(path, MAX_PRIMARY_AGENT_PROFILE_BYTES, true)?.text;
let (frontmatter, body) = parse_profile_markdown(&text)?;
let name = required_string(&frontmatter, "name")?;
let description = required_string(&frontmatter, "description")?;
let prompt = body.trim().to_string();
if prompt.is_empty() {
anyhow::bail!("profile body must not be empty");
}
Ok(PrimaryAgentProfile {
id,
name,
description,
path: path.to_path_buf(),
prompt,
})
}
pub(crate) fn render_primary_agent_prompt_append(profile: &PrimaryAgentProfile) -> String {
format!(
"Primary agent profile ({}):\n\n{}",
profile.name.trim(),
profile.prompt.trim()
)
}
fn parse_profile_markdown(text: &str) -> anyhow::Result<(BTreeMap<String, String>, String)> {
if !text.starts_with("---\n") {
anyhow::bail!("missing YAML frontmatter with required 'name' and 'description'");
}
let rest = &text[4..];
let Some(end) = rest.find("\n---\n") else {
anyhow::bail!("frontmatter start marker without closing marker");
};
let frontmatter_text = &rest[..end];
let body = rest[end + 5..].to_string();
let mut frontmatter = BTreeMap::new();
for line in frontmatter_text
.lines()
.filter(|line| !line.trim().is_empty())
{
let Some((key, value)) = line.split_once(':') else {
anyhow::bail!("malformed frontmatter line");
};
let key = key.trim();
let value = parse_frontmatter_string_field(key, value.trim())?;
frontmatter.insert(key.to_string(), value);
}
Ok((frontmatter, body))
}
fn parse_frontmatter_string_field(key: &str, value: &str) -> anyhow::Result<String> {
if value.is_empty() {
return Ok(String::new());
}
if let Some(quoted) = quoted_yaml_string(value) {
return Ok(quoted.to_string());
}
if value.starts_with('[')
|| value.starts_with('{')
|| is_yaml_bool(value)
|| is_yaml_null(value)
|| is_yaml_number(value)
{
anyhow::bail!("frontmatter field '{key}' must be a YAML string");
}
Ok(value.to_string())
}
fn quoted_yaml_string(value: &str) -> Option<&str> {
let bytes = value.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
{
Some(&value[1..value.len() - 1])
} else {
None
}
}
fn is_yaml_bool(value: &str) -> bool {
matches!(
value,
"true"
| "True"
| "TRUE"
| "false"
| "False"
| "FALSE"
| "yes"
| "Yes"
| "YES"
| "no"
| "No"
| "NO"
| "on"
| "On"
| "ON"
| "off"
| "Off"
| "OFF"
)
}
fn is_yaml_null(value: &str) -> bool {
matches!(value, "~" | "null" | "Null" | "NULL")
}
fn is_yaml_number(value: &str) -> bool {
let value = value.replace('_', "");
value.parse::<i64>().is_ok() || value.parse::<f64>().is_ok()
}
fn required_string(frontmatter: &BTreeMap<String, String>, key: &str) -> anyhow::Result<String> {
let value = frontmatter
.get(key)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing required frontmatter field '{key}'"))?;
Ok(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn write_profile(root: &Path, name: &str, text: &str) {
fs::write(root.join(name), text).unwrap();
}
#[test]
fn primary_agent_paths_use_mc_home_root() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().to_path_buf());
assert_eq!(paths.primary_agents, temp.path().join("primary-agents"));
}
#[test]
fn discovers_primary_agent_profiles_from_primary_agents_dir() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"tars.md",
"---\nname: TARS\ndescription: Tactical unit\n---\nPrimary body\n",
);
let discovery = discover_primary_agent_profiles(temp.path());
assert!(
discovery.diagnostics.is_empty(),
"{:?}",
discovery.diagnostics
);
let profile = &discovery.profiles["tars"];
assert_eq!(profile.id, "tars");
assert_eq!(profile.name, "TARS");
assert_eq!(profile.description, "Tactical unit");
assert_eq!(profile.prompt, "Primary body");
}
#[test]
fn missing_primary_agents_directory_is_empty() {
let temp = TempDir::new().unwrap();
let discovery = discover_primary_agent_profiles(&temp.path().join("missing"));
assert!(discovery.profiles.is_empty());
assert!(discovery.diagnostics.is_empty());
}
#[test]
fn skips_invalid_primary_agent_profiles_with_diagnostics() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"bad.id.md",
"---\nname: Bad\ndescription: Bad\n---\nBody",
);
write_profile(temp.path(), "missing.md", "---\nname: Missing\n---\nBody");
write_profile(
temp.path(),
"empty.md",
"---\nname: Empty\ndescription: Empty\n---\n ",
);
fs::create_dir(temp.path().join("dir.md")).unwrap();
let discovery = discover_primary_agent_profiles(temp.path());
assert!(discovery.profiles.is_empty());
let messages = discovery
.diagnostics
.iter()
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(messages.contains("ASCII letters"), "{messages}");
assert!(
messages.contains("missing required frontmatter field 'description'"),
"{messages}"
);
assert!(
messages.contains("profile body must not be empty"),
"{messages}"
);
assert!(messages.contains("regular file"), "{messages}");
}
#[test]
fn primary_agent_profiles_are_ordered_deterministically() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"zeta.md",
"---\nname: Zeta\ndescription: Z\n---\nZ",
);
write_profile(
temp.path(),
"alpha.md",
"---\nname: Alpha\ndescription: A\n---\nA",
);
let ids = discover_primary_agent_profiles(temp.path())
.profiles
.keys()
.cloned()
.collect::<Vec<_>>();
assert_eq!(ids, vec!["alpha", "zeta"]);
}
#[test]
fn primary_agent_rejects_empty_name() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"empty-name.md",
"---\nname: \ndescription: D\n---\nBody",
);
let messages = discover_primary_agent_profiles(temp.path())
.diagnostics
.into_iter()
.map(|d| d.message)
.collect::<Vec<_>>()
.join("\n");
assert!(
messages.contains("missing required frontmatter field 'name'"),
"{messages}"
);
}
#[test]
fn primary_agent_rejects_whitespace_name() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"space-name.md",
"---\nname: \ndescription: D\n---\nBody",
);
assert!(
discover_primary_agent_profiles(temp.path())
.profiles
.is_empty()
);
}
#[test]
fn primary_agent_rejects_non_string_name() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"list-name.md",
"---\nname: [TARS]\ndescription: D\n---\nBody",
);
let messages = discover_primary_agent_profiles(temp.path())
.diagnostics
.into_iter()
.map(|d| d.message)
.collect::<Vec<_>>()
.join("\n");
assert!(
messages.contains("frontmatter field 'name' must be a YAML string"),
"{messages}"
);
}
#[test]
fn primary_agent_rejects_non_yaml_string_frontmatter_scalars() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"numeric-name.md",
"---\nname: 7\ndescription: D\n---\nBody",
);
write_profile(
temp.path(),
"boolean-name.md",
"---\nname: false\ndescription: D\n---\nBody",
);
write_profile(
temp.path(),
"null-description.md",
"---\nname: N\ndescription: null\n---\nBody",
);
write_profile(
temp.path(),
"map-description.md",
"---\nname: M\ndescription: {kind: tactical}\n---\nBody",
);
let messages = discover_primary_agent_profiles(temp.path())
.diagnostics
.into_iter()
.map(|d| d.message)
.collect::<Vec<_>>()
.join("\n");
assert!(
messages.contains("frontmatter field 'name' must be a YAML string"),
"{messages}"
);
assert!(
messages.contains("frontmatter field 'description' must be a YAML string"),
"{messages}"
);
assert!(!messages.contains("false"), "{messages}");
assert!(!messages.contains("{kind: tactical}"), "{messages}");
}
#[test]
fn primary_agent_accepts_quoted_yaml_string_scalars() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"quoted.md",
"---\nname: \"7\"\ndescription: 'false'\n---\nBody",
);
let discovery = discover_primary_agent_profiles(temp.path());
assert!(
discovery.diagnostics.is_empty(),
"{:?}",
discovery.diagnostics
);
let profile = &discovery.profiles["quoted"];
assert_eq!(profile.name, "7");
assert_eq!(profile.description, "false");
}
#[test]
fn oversized_primary_agent_profile_diagnostic_omits_body() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("big.md");
let mut file = fs::File::create(&path).unwrap();
writeln!(file, "---\nname: Big\ndescription: Large\n---").unwrap();
file.write_all(&vec![b's'; MAX_PRIMARY_AGENT_PROFILE_BYTES as usize + 1])
.unwrap();
let message = &discover_primary_agent_profiles(temp.path()).diagnostics[0].message;
assert!(message.contains("exceeding"), "{message}");
assert!(!message.contains("ssssssss"), "{message}");
}
}