use std::collections::BTreeMap;
use std::env;
use std::path::{Path, PathBuf};
use defect_agent::error::BoxError;
use defect_agent::fs::resolve_workspace_path;
use defect_agent::llm::SamplingParams;
use defect_agent::session::TurnRequestLimit;
use serde::Deserialize;
use crate::frontmatter::{parse_frontmatter, split_frontmatter};
use crate::hooks::profile_hooks_from_raw;
use crate::loader::{find_repo_root, resolve_request_limit};
use crate::types::{ConfigError, ConfigSource, HooksConfig, LoadConfigOptions, RequestLimitMode};
const PROJECT_AGENTS_RELATIVE: &str = ".defect/agents";
const USER_AGENTS_RELATIVE: &str = "defect/agents";
const DEFAULT_PROMPT_FILE: &str = "system.md";
const DEFAULT_TOOL_ALLOW: &[&str] = &["read_file", "search"];
#[derive(Debug, Clone)]
pub struct ProfileSpec {
pub name: String,
pub dir: PathBuf,
pub description: String,
pub model: Option<String>,
pub system_prompt_text: String,
pub tool_allow: Vec<String>,
pub sampling: Option<SamplingParams>,
pub inherit_project_prompt: bool,
pub request_limit: Option<TurnRequestLimit>,
pub hooks: HooksConfig,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileConfigToml {
description: String,
#[serde(default)]
model: Option<String>,
#[serde(default)]
default: Option<ProfileDefaultToml>,
#[serde(default)]
prompt: Option<ProfilePromptToml>,
#[serde(default)]
tools: Option<ProfileToolsToml>,
#[serde(default)]
sampling: Option<ProfileSamplingToml>,
#[serde(default)]
inherit_project_prompt: bool,
#[serde(default)]
request_limit: Option<u32>,
#[serde(default)]
request_limit_mode: Option<RequestLimitMode>,
#[serde(default)]
hooks: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfilePromptToml {
#[serde(default)]
file: Option<String>,
#[serde(default)]
text: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileDefaultToml {
#[serde(default)]
model: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileToolsToml {
#[serde(default)]
allow: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileSamplingToml {
#[serde(default)]
max_tokens: Option<u32>,
#[serde(default)]
temperature: Option<f32>,
#[serde(default)]
top_p: Option<f32>,
#[serde(default)]
top_k: Option<u32>,
}
impl ProfileSamplingToml {
fn into_params(self) -> SamplingParams {
SamplingParams {
max_tokens: self.max_tokens,
temperature: self.temperature,
top_p: self.top_p,
top_k: self.top_k,
..SamplingParams::default()
}
}
}
pub fn discover_profiles(
opts: &LoadConfigOptions,
) -> Result<BTreeMap<String, ProfileSpec>, ConfigError> {
let mut profiles = BTreeMap::new();
if let Some(user_dir) = resolve_user_agents_dir(opts) {
scan_agents_dir(&user_dir, ConfigSource::User, &mut profiles)?;
}
if let Some(repo_root) = find_repo_root(&opts.cwd) {
scan_agents_dir(
&repo_root.join(PROJECT_AGENTS_RELATIVE),
ConfigSource::Project,
&mut profiles,
)?;
}
Ok(profiles)
}
fn scan_agents_dir(
agents_dir: &Path,
source: ConfigSource,
out: &mut BTreeMap<String, ProfileSpec>,
) -> Result<(), ConfigError> {
let entries = match std::fs::read_dir(agents_dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
return Err(ConfigError::Io {
path: agents_dir.to_path_buf(),
source: BoxError::new(err),
});
}
};
let mut layer: BTreeMap<String, ProfileSpec> = BTreeMap::new();
for entry in entries {
let entry = entry.map_err(|err| ConfigError::Io {
path: agents_dir.to_path_buf(),
source: BoxError::new(err),
})?;
let path = entry.path();
let parsed = if path.is_dir() {
let config_path = path.join("config.toml");
if !config_path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else {
continue;
};
Some((name, parse_profile_folder(&path, &config_path, source)?))
} else if path.extension().and_then(|e| e.to_str()) == Some("md") {
let Some(name) = path.file_stem().and_then(|n| n.to_str()).map(str::to_owned) else {
continue;
};
Some((name, parse_profile_file(agents_dir, &path, source)?))
} else {
None
};
if let Some((name, mut spec)) = parsed {
spec.name = name.clone();
if layer.insert(name.clone(), spec).is_some() {
return Err(ConfigError::Invalid {
path: agents_dir.to_path_buf(),
message: format!(
"duplicate subagent profile `{name}` in the same layer \
(a folder and a `.md` file cannot share a name)"
),
});
}
}
}
out.extend(layer);
Ok(())
}
fn spec_from_cfg(
dir: &Path,
cfg: ProfileConfigToml,
system_prompt_text: String,
source: ConfigSource,
config_path: &Path,
) -> Result<ProfileSpec, ConfigError> {
let tool_allow = cfg
.tools
.and_then(|t| t.allow)
.unwrap_or_else(|| DEFAULT_TOOL_ALLOW.iter().map(|s| s.to_string()).collect());
let hooks = profile_hooks_from_raw(cfg.hooks, source, config_path)?;
let request_limit =
resolve_request_limit(config_path, cfg.request_limit, cfg.request_limit_mode)?;
let default_model = cfg.default.and_then(|d| d.model);
let model = match (cfg.model, default_model) {
(Some(_), Some(_)) => {
return Err(ConfigError::Invalid {
path: config_path.to_path_buf(),
message: "set the model either as root `model` or as `[default] model`, not both"
.into(),
});
}
(root, default) => root.or(default),
};
Ok(ProfileSpec {
name: String::new(), dir: dir.to_path_buf(),
description: cfg.description,
model,
system_prompt_text,
tool_allow,
sampling: cfg.sampling.map(ProfileSamplingToml::into_params),
inherit_project_prompt: cfg.inherit_project_prompt,
request_limit,
hooks,
})
}
fn parse_profile_folder(
dir: &Path,
config_path: &Path,
source: ConfigSource,
) -> Result<ProfileSpec, ConfigError> {
let raw = std::fs::read_to_string(config_path).map_err(|err| ConfigError::Io {
path: config_path.to_path_buf(),
source: BoxError::new(err),
})?;
let cfg: ProfileConfigToml = toml::from_str(&raw).map_err(|err| ConfigError::Invalid {
path: config_path.to_path_buf(),
message: err.to_string(),
})?;
let (inline_text, prompt_file) = match cfg.prompt.as_ref() {
Some(p) => {
if p.text.is_some() && p.file.is_some() {
return Err(ConfigError::Invalid {
path: config_path.to_path_buf(),
message: "set `[prompt] text` or `[prompt] file`, not both".into(),
});
}
(p.text.clone(), p.file.clone())
}
None => (None, None),
};
let system_prompt_text = if let Some(text) = inline_text {
text
} else {
let prompt_file = prompt_file.unwrap_or_else(|| DEFAULT_PROMPT_FILE.to_string());
let prompt_path = resolve_workspace_path(dir, Path::new(&prompt_file)).map_err(|err| {
ConfigError::Invalid {
path: config_path.to_path_buf(),
message: format!("invalid `prompt.file` `{prompt_file}`: {err}"),
}
})?;
std::fs::read_to_string(&prompt_path).map_err(|err| ConfigError::Io {
path: prompt_path.clone(),
source: BoxError::new(err),
})?
};
spec_from_cfg(dir, cfg, system_prompt_text, source, config_path)
}
fn parse_profile_file(
dir: &Path,
file_path: &Path,
source: ConfigSource,
) -> Result<ProfileSpec, ConfigError> {
let raw = std::fs::read_to_string(file_path).map_err(|err| ConfigError::Io {
path: file_path.to_path_buf(),
source: BoxError::new(err),
})?;
let (kind, frontmatter, body) =
split_frontmatter(&raw).ok_or_else(|| ConfigError::Invalid {
path: file_path.to_path_buf(),
message: "single-file profile must start with frontmatter delimited by `+++` (TOML) \
or `---` (YAML)"
.into(),
})?;
let cfg: ProfileConfigToml =
parse_frontmatter(kind, frontmatter).map_err(|message| ConfigError::Invalid {
path: file_path.to_path_buf(),
message,
})?;
if cfg.prompt.is_some() {
return Err(ConfigError::Invalid {
path: file_path.to_path_buf(),
message: "single-file profile takes its system prompt from the body after the \
frontmatter; remove the `[prompt]` table"
.into(),
});
}
spec_from_cfg(dir, cfg, body.to_string(), source, file_path)
}
fn resolve_user_agents_dir(opts: &LoadConfigOptions) -> Option<PathBuf> {
if opts.local {
return None;
}
if let Some(xdg) = &opts.xdg_config_home {
return Some(xdg.join(USER_AGENTS_RELATIVE));
}
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg).join(USER_AGENTS_RELATIVE));
}
if let Some(home) = &opts.home_dir {
return Some(home.join(".config/defect/agents"));
}
if let Ok(home) = env::var("HOME") {
return Some(PathBuf::from(home).join(".config/defect/agents"));
}
None
}
#[cfg(test)]
mod tests;