use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
mod selection;
pub(crate) use selection::{
resolve_persisted_primary_agent_selection, sanitized_primary_agent_diagnostic_text,
};
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_your_role_prompt(persona: &str, prompt: &str) -> String {
format!(
"<Your-Role Persona={}>\nYou must always follow the instructions of your assigned persona:\n{}\n</Your-Role>",
persona.trim(),
prompt.trim()
)
}
pub(crate) fn render_primary_agent_prompt_append(profile: &PrimaryAgentProfile) -> String {
render_your_role_prompt(&profile.name, &profile.prompt)
}
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())
}