use super::output_schema::{SubagentOutputPhase, SubagentOutputSchemaRef};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, HashSet},
fs,
path::{Path, PathBuf},
str::FromStr,
};
pub const MAX_SUBAGENT_PROFILE_BYTES: u64 = 64 * 1024;
const ID_RULES: &str = "identity id must use only ASCII letters, digits, '_' or '-'";
const SUBAGENTS_FRAGMENT: &str = "subagents.md";
const DEFAULT_SUBAGENTS_TEMPLATE: &str = include_str!("../../prompts/subagents.md");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubagentModelOverride {
pub provider: String,
pub model: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubagentProfile {
pub id: String,
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<SubagentModelOverride>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<crate::thinking::ThinkingLevel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_schema: Option<SubagentOutputSchemaRef>,
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
pub disabled_tools: HashSet<String>,
pub path: PathBuf,
pub prompt: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubagentProfileDiagnostic {
pub id: Option<String>,
pub path: Option<PathBuf>,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubagentProfileDiscovery {
pub profiles: BTreeMap<String, SubagentProfile>,
pub diagnostics: Vec<SubagentProfileDiagnostic>,
}
pub fn validate_subagent_identity_id(id: &str) -> anyhow::Result<String> {
if id.is_empty() {
anyhow::bail!("subagent identity id must not be empty; {ID_RULES}");
}
if id != id.trim() {
anyhow::bail!(
"subagent identity id must not have leading or trailing whitespace; {ID_RULES}"
);
}
if id == "." || id == ".." {
anyhow::bail!("subagent identity 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 fn discover_subagent_profiles(root: &Path) -> SubagentProfileDiscovery {
let mut discovery = SubagentProfileDiscovery::default();
let metadata = match fs::symlink_metadata(root) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return discovery,
Err(error) => {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: format!("could not inspect subagent profiles directory: {error}"),
});
return discovery;
}
};
if metadata.file_type().is_symlink() {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: "subagent profiles root directory must not be a symlink".to_string(),
});
return discovery;
}
if !metadata.is_dir() {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: "subagent profiles root must be a directory".to_string(),
});
return discovery;
}
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(error) => {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: format!("could not read subagent profiles directory: {error}"),
});
return discovery;
}
};
let mut paths = entries
.filter_map(|entry| match entry {
Ok(entry) => Some(entry.path()),
Err(error) => {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: format!("could not inspect subagent 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_subagent_profile(&path) {
Ok(profile) => {
discovery.profiles.insert(profile.id.clone(), profile);
}
Err(error) => discovery.diagnostics.push(SubagentProfileDiagnostic {
id: path
.file_stem()
.and_then(|stem| stem.to_str())
.map(ToString::to_string),
path: Some(path.clone()),
message: format!(
"skipping subagent profile {}: {error}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("<non-utf8>")
),
}),
}
}
discovery
}
pub fn load_subagent_profile(path: &Path) -> anyhow::Result<SubagentProfile> {
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_subagent_identity_id(id)?;
let text = crate::prompt_file::read_prompt_file(path, MAX_SUBAGENT_PROFILE_BYTES, true)?.text;
let (frontmatter, body, disabled_tool_names) = parse_profile_markdown(&text)?;
let name = required_string(&frontmatter, "name")?;
let description = required_string(&frontmatter, "description")?;
let model = optional_model_override(&frontmatter)?;
let reasoning = optional_reasoning(&frontmatter)?;
let output_schema = optional_output_schema(&frontmatter)?;
let disabled_tools = canonicalize_disabled_tools(disabled_tool_names)?;
let prompt = body.trim().to_string();
if prompt.is_empty() {
anyhow::bail!("profile body must not be empty");
}
Ok(SubagentProfile {
id,
name,
description,
model,
reasoning,
output_schema,
disabled_tools,
path: path.to_path_buf(),
prompt,
})
}
pub fn render_subagent_profiles_prompt(
prompt_dir: Option<&Path>,
discovery: &SubagentProfileDiscovery,
) -> anyhow::Result<Option<String>> {
if discovery.profiles.is_empty() && discovery.diagnostics.is_empty() {
return Ok(None);
}
let template = load_subagents_prompt_template(prompt_dir)?;
render_subagent_profiles_prompt_from_template(SUBAGENTS_FRAGMENT, &template, discovery)
.map(Some)
}
pub fn filter_enabled_profiles(
discovery: &SubagentProfileDiscovery,
disabled: &HashSet<String>,
) -> SubagentProfileDiscovery {
SubagentProfileDiscovery {
profiles: discovery
.profiles
.iter()
.filter(|(id, _)| !disabled.contains(*id))
.map(|(id, profile)| (id.clone(), profile.clone()))
.collect(),
diagnostics: discovery.diagnostics.clone(),
}
}
fn load_subagents_prompt_template(prompt_dir: Option<&Path>) -> anyhow::Result<String> {
if let Some(prompt_dir) = prompt_dir {
let path = prompt_dir.join(SUBAGENTS_FRAGMENT);
if path.exists() {
return crate::prompt_file::read_prompt_file(&path, MAX_SUBAGENT_PROFILE_BYTES, false)
.map(|file| file.text)
.map_err(|error| {
anyhow::anyhow!(
"failed to read prompt template fragment '{}' at {}: {error}",
SUBAGENTS_FRAGMENT,
path.display()
)
});
}
}
Ok(DEFAULT_SUBAGENTS_TEMPLATE.to_string())
}
fn render_subagent_profiles_prompt_from_template(
template_name: &'static str,
template: &str,
discovery: &SubagentProfileDiscovery,
) -> anyhow::Result<String> {
render_subagents_template(
template_name,
template,
&render_subagent_profiles_list(discovery),
)
}
fn render_subagent_profiles_list(discovery: &SubagentProfileDiscovery) -> String {
let mut out = String::new();
if discovery.profiles.is_empty() {
out.push_str("- none\n");
} else {
for profile in discovery.profiles.values() {
out.push_str(&format!("- `{}` — {}\n", profile.id, profile.description));
}
}
if !discovery.diagnostics.is_empty() {
out.push_str("\nProfile diagnostics (not selectable):\n");
for diagnostic in &discovery.diagnostics {
let label = diagnostic
.id
.as_deref()
.or_else(|| {
diagnostic
.path
.as_ref()
.and_then(|path| path.file_name())
.and_then(|name| name.to_str())
})
.unwrap_or("unknown");
out.push_str(&format!("- `{label}`: {}\n", diagnostic.message));
}
}
out.trim_end().to_string()
}
fn render_subagents_template(
template_name: &'static str,
template: &str,
list_subagents: &str,
) -> anyhow::Result<String> {
let mut rendered = String::with_capacity(template.len() + list_subagents.len());
let mut rest = template;
loop {
let Some(open) = rest.find("{{") else {
if let Some(close) = rest.find("}}") {
anyhow::bail!(
"malformed prompt template variable in '{template_name}': unexpected '}}}}' at byte {close}",
);
}
rendered.push_str(rest);
return Ok(rendered.trim_end().to_string());
};
if let Some(close) = rest[..open].find("}}") {
anyhow::bail!(
"malformed prompt template variable in '{template_name}': unexpected '}}}}' at byte {close}",
);
}
rendered.push_str(&rest[..open]);
let after_open = &rest[open + 2..];
let Some(close) = after_open.find("}}") else {
anyhow::bail!(
"malformed prompt template variable in '{template_name}': missing closing '}}}}' for token starting at byte {open}",
);
};
let token = &after_open[..close];
let replacement = match token.trim() {
"LIST_SUBAGENTS" if token == "LIST_SUBAGENTS" => list_subagents,
"LIST_SUBAGENTS" => anyhow::bail!(
"malformed prompt template variable in '{template_name}': use '{{{{LIST_SUBAGENTS}}}}' without extra whitespace",
),
other => anyhow::bail!(
"unsupported prompt template variable in '{template_name}': '{{{{{other}}}}}'; supported variable is '{{{{LIST_SUBAGENTS}}}}'",
),
};
rendered.push_str(replacement);
rest = &after_open[close + 2..];
}
}
struct ParsedProfileFrontmatter {
fields: BTreeMap<String, String>,
disabled_tools: Option<Vec<String>>,
}
fn parse_profile_markdown(
text: &str,
) -> anyhow::Result<(BTreeMap<String, String>, String, Vec<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 parsed = ParsedProfileFrontmatter {
fields: BTreeMap::new(),
disabled_tools: None,
};
let mut lines = frontmatter_text.lines().peekable();
while let Some(line) = lines.next() {
if line.trim().is_empty() || line.starts_with(char::is_whitespace) {
continue;
}
let Some((key, value)) = line.split_once(':') else {
anyhow::bail!("malformed frontmatter line: {line}");
};
let key = key.trim();
if key == "disabled_tools" {
if parsed.disabled_tools.is_some() {
anyhow::bail!("duplicate frontmatter field 'disabled_tools'");
}
let value = value.trim();
let values = if value.is_empty() {
let mut values = Vec::new();
while let Some(next) = lines.peek() {
if next.trim().is_empty() {
lines.next();
continue;
}
if next.trim_start().starts_with('#') {
reject_unsupported_disabled_tool_syntax(next)?;
}
if !next.starts_with(char::is_whitespace) {
break;
}
let next = lines.next().expect("peeked frontmatter line");
values.push(parse_disabled_tool_list_item(next)?);
}
if values.is_empty() {
anyhow::bail!(
"frontmatter field 'disabled_tools' must be a YAML sequence using supported block-list or inline-list syntax"
);
}
values
} else {
let values = parse_inline_disabled_tool_list(value)?;
reject_inline_disabled_tool_continuation(&mut lines)?;
values
};
parsed.disabled_tools = Some(values);
} else if matches!(
key,
"name" | "description" | "model" | "reasoning" | "output_schema"
) {
parsed
.fields
.insert(key.to_string(), value.trim().trim_matches('"').to_string());
}
}
Ok((
parsed.fields,
body,
parsed.disabled_tools.unwrap_or_default(),
))
}
fn reject_inline_disabled_tool_continuation(
lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
) -> anyhow::Result<()> {
while let Some(next) = lines.peek() {
if next.trim().is_empty() {
lines.next();
continue;
}
if next.starts_with(char::is_whitespace) {
anyhow::bail!(
"frontmatter field 'disabled_tools' uses inline-list syntax and cannot have an indented continuation; use 'disabled_tools:' followed by ' - <tool>' lines for a block list"
);
}
break;
}
Ok(())
}
fn reject_unsupported_disabled_tool_syntax(value: &str) -> anyhow::Result<()> {
if value.contains('\\') {
anyhow::bail!(
"frontmatter field 'disabled_tools' does not support escape syntax; use simple tool names without backslashes"
);
}
if value.contains('#') {
anyhow::bail!(
"frontmatter field 'disabled_tools' does not support comments; remove '#' and use a plain block or inline list"
);
}
Ok(())
}
fn parse_inline_disabled_tool_list(value: &str) -> anyhow::Result<Vec<String>> {
reject_unsupported_disabled_tool_syntax(value)?;
let Some(inner) = value
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
else {
anyhow::bail!(
"frontmatter field 'disabled_tools' must be a YAML sequence using supported block-list or inline-list syntax"
);
};
if inner.trim().is_empty() {
return Ok(Vec::new());
}
inner.split(',').map(parse_disabled_tool_scalar).collect()
}
fn parse_disabled_tool_list_item(line: &str) -> anyhow::Result<String> {
reject_unsupported_disabled_tool_syntax(line)?;
let item = line.trim_start().strip_prefix('-').ok_or_else(|| {
anyhow::anyhow!("malformed 'disabled_tools' list item; expected '- <tool>'")
})?;
parse_disabled_tool_scalar(item.trim())
}
fn parse_disabled_tool_scalar(value: &str) -> anyhow::Result<String> {
let value = value.trim();
reject_unsupported_disabled_tool_syntax(value)?;
if value.is_empty() {
anyhow::bail!("frontmatter field 'disabled_tools' contains an empty tool name");
}
let first = value.chars().next();
if first == Some('"') || first == Some('\'') {
let quote = first.expect("non-empty scalar");
if value.len() < 2 || !value.ends_with(quote) {
anyhow::bail!("malformed quoted 'disabled_tools' tool name");
}
let inner = &value[quote.len_utf8()..value.len() - quote.len_utf8()];
if inner.is_empty() {
anyhow::bail!("frontmatter field 'disabled_tools' contains an empty tool name");
}
if inner.chars().any(|ch| ch == '"' || ch == '\'') {
anyhow::bail!(
"malformed quoted 'disabled_tools' tool name; quoted values must contain one simple name"
);
}
return Ok(inner.to_string());
}
if value.chars().any(|ch| ch == '"' || ch == '\'') {
anyhow::bail!("malformed quoted 'disabled_tools' tool name; quote the entire simple name");
}
Ok(value.to_string())
}
fn canonicalize_disabled_tools(names: Vec<String>) -> anyhow::Result<HashSet<String>> {
names
.into_iter()
.map(|name| {
let name = name.trim();
if name.is_empty() {
anyhow::bail!("frontmatter field 'disabled_tools' contains an empty tool name");
}
crate::tools::ToolRuntime::canonical_disabled_key(name)
.map_err(|error| anyhow::anyhow!("invalid disabled_tools entry '{name}': {error}"))
})
.collect()
}
fn optional_model_override(
frontmatter: &BTreeMap<String, String>,
) -> anyhow::Result<Option<SubagentModelOverride>> {
let Some(value) = optional_string(frontmatter, "model") else {
return Ok(None);
};
let model_id = crate::model_catalog::ModelId::parse(&value)?;
Ok(Some(SubagentModelOverride {
provider: model_id.provider().to_string(),
model: model_id.model().to_string(),
}))
}
fn optional_reasoning(
frontmatter: &BTreeMap<String, String>,
) -> anyhow::Result<Option<crate::thinking::ThinkingLevel>> {
let Some(value) = optional_string(frontmatter, "reasoning") else {
return Ok(None);
};
crate::thinking::ThinkingLevel::from_str(&value)
.map(Some)
.map_err(|error| anyhow::anyhow!(error.replace("thinking_level", "reasoning")))
}
fn optional_output_schema(
frontmatter: &BTreeMap<String, String>,
) -> anyhow::Result<Option<SubagentOutputSchemaRef>> {
let Some(value) = optional_string(frontmatter, "output_schema") else {
return Ok(None);
};
if value == "none" {
return Ok(Some(SubagentOutputSchemaRef::None));
}
if let Some(phase) = SubagentOutputPhase::from_schema_ref(&value) {
return Ok(Some(SubagentOutputSchemaRef::Builtin(phase)));
}
if let Some(raw_schema) = value.strip_prefix("inline:") {
let schema: serde_json::Value = serde_json::from_str(raw_schema).map_err(|error| {
anyhow::anyhow!("invalid output_schema inline JSON object: {error}")
})?;
if !schema.is_object() {
anyhow::bail!("output_schema inline value must be a JSON object");
}
return Ok(Some(SubagentOutputSchemaRef::Inline(schema)));
}
anyhow::bail!(
"invalid output_schema '{value}'; expected none, plan, research, implement, review, document, or inline:<json object>"
)
}
fn optional_string(frontmatter: &BTreeMap<String, String>, key: &str) -> Option<String> {
frontmatter
.get(key)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
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())
}