defect_config/profiles.rs
1//! Subagent profile discovery and parsing.
2//!
3//! Profiles are selected by name via the `spawn_agent` tool, allowing a parent agent to
4//! delegate tasks to a fresh, context-isolated child agent. See the design note
5//! `project-subagent-design`.
6//!
7//! ## Two formats (same fields, choose one)
8//!
9//! - **Directory variant**: `agents/<name>/`, containing a `config.toml` (TOML
10//! configuration) plus a system prompt file (default `system.md`, overridden by
11//! `[prompt] file`). Suitable when the prompt is long or you want to keep it in a
12//! separate file.
13//! - **Single-file variant**: `agents/<name>.md`, where frontmatter (`+++` ⇒ **TOML**,
14//! `---` ⇒ **YAML**, community standard) is followed by the system prompt body. The
15//! field schema is identical to `config.toml`. Good for a one-file solution. This
16//! variant carries no extra resource files, so the `[prompt]` table is **illegal**
17//! here. YAML requires the `yaml` feature (enabled by default); without it, `---`
18//! headers hard-fail with an actionable error, while `+++` still works.
19//!
20//! If both variants exist with the same name in the same directory (e.g., `reviewer/` and
21//! `reviewer.md`), it's a hard error — one name must have a single source of truth.
22//!
23//! ## Layered discovery
24//!
25//! Same structure as the main configuration ([`crate::loader`]):
26//! - User layer: `<XDG_CONFIG_HOME>/defect/agents/` (or `~/.config/defect/agents/`)
27//! - Project layer: `<repo_root>/.defect/agents/`
28//!
29//! When the same name exists in both layers, **the project layer overrides the user
30//! layer**.
31//!
32//! ## Sandbox
33//!
34//! File references in `config.toml` (e.g., `[prompt] file`) are resolved relative to the
35//! profile directory, using [`defect_agent::fs::resolve_workspace_path`] with the root
36//! pinned to the profile directory. This blocks `../` traversal and symlink escapes —
37//! this sandbox protects **the profile's own resource files**, which is separate from the
38//! workspace sandbox used by the child agent during execution.
39
40use std::collections::BTreeMap;
41use std::env;
42use std::path::{Path, PathBuf};
43
44use defect_agent::error::BoxError;
45use defect_agent::fs::resolve_workspace_path;
46use defect_agent::llm::SamplingParams;
47use defect_agent::session::TurnRequestLimit;
48use serde::Deserialize;
49
50use crate::frontmatter::{parse_frontmatter, split_frontmatter};
51use crate::hooks::profile_hooks_from_raw;
52use crate::loader::{find_repo_root, resolve_request_limit};
53use crate::types::{ConfigError, ConfigSource, HooksConfig, LoadConfigOptions, RequestLimitMode};
54
55/// Profile-level agent directory (relative to repo root). Mirrors [`crate::types`]'s
56/// `PROJECT_CONFIG_RELATIVE` (`.defect/config.toml`).
57const PROJECT_AGENTS_RELATIVE: &str = ".defect/agents";
58/// User-level profile directory (relative to `XDG_CONFIG_HOME`). Corresponds to
59/// `USER_CONFIG_RELATIVE` (`defect/config.toml`).
60const USER_AGENTS_RELATIVE: &str = "defect/agents";
61/// Default value for `[prompt] file` — `system.md` under the profile directory.
62const DEFAULT_PROMPT_FILE: &str = "system.md";
63/// Default `[tools] allow`: read-only set. Omitting `allow` yields a sub-agent that can
64/// only read and search; safety is ensured by the absence of mutating tools (the tool
65/// allowlist is the primary defense).
66const DEFAULT_TOOL_ALLOW: &[&str] = &["read_file", "search"];
67
68/// A parsed subagent profile.
69///
70/// Produced by [`discover_profiles`]; consumed by the `spawn_agent` tool and the
71/// top-level CLI `--profile` flag.
72#[derive(Debug, Clone)]
73pub struct ProfileSpec {
74 /// Profile name (directory name). Corresponds to the `profile` enum variant in
75 /// `spawn_agent`.
76 pub name: String,
77 /// Absolute path to the profile directory.
78 pub dir: PathBuf,
79 /// Selection description — `spawn_agent` uses this to let the LLM decide which
80 /// profile to pick; it also goes into the tool description's catalog. Required.
81 pub description: String,
82 /// Optional model override; omitted ⇒ the sub-agent falls back to the parent
83 /// session's currently selected model.
84 ///
85 /// There is no separate `provider` field: the model ID already uniquely determines
86 /// the provider via the provider registry's `entry_for_model`, so adding a provider
87 /// field would create a second source of truth. To use a specific provider, simply
88 /// write a model ID that belongs to that provider.
89 pub model: Option<String>,
90 /// The pre-resolved system prompt text (from `[prompt] file`).
91 pub system_prompt_text: String,
92 /// Tool allowlist — sub-agents can only see these tools. Omitted ⇒
93 /// `DEFAULT_TOOL_ALLOW`.
94 pub tool_allow: Vec<String>,
95 /// Optional sampling parameter overrides.
96 pub sampling: Option<SamplingParams>,
97 /// When `true`, the subagent's system prompt is prefixed with the project instruction
98 /// layer (`AGENTS.md`), so it inherits project world-knowledge (build/test/arch
99 /// conventions) without inheriting the parent's identity. Default `false` (isolation +
100 /// token economy). Configured via `inherit_project_prompt`.
101 pub inherit_project_prompt: bool,
102 /// Optional per-turn LLM-call cap. Omitted ⇒ the subagent uses a fixed anti-runaway
103 /// default. Configured via `request_limit` (+ `request_limit_mode`), the same keys and
104 /// semantics as the top-level `[turn]` config.
105 pub request_limit: Option<TurnRequestLimit>,
106 /// The `[hooks]` declared by this profile — hooks attached when a sub-agent runs a
107 /// turn.
108 ///
109 /// Consistent with the "inherit world, not identity" principle: a profile's hooks are
110 /// part of its identity, declared in the profile's own `config.toml` / frontmatter,
111 /// and are **not** inherited from the parent session. Each entry carries the
112 /// [`ConfigSource`] of the profile's layer (replaced when a project layer overrides a
113 /// user layer, since the entire [`ProfileSpec`] is overridden). Omitted ⇒ empty
114 /// (sub-agent has no hooks).
115 pub hooks: HooksConfig,
116}
117
118/// Raw deserialization shape of `config.toml`. `deny_unknown_fields` matches the main
119/// config — unknown keys hard-fail ([[feedback-minimize-no-paternalistic-guards]]).
120#[derive(Debug, Deserialize)]
121#[serde(deny_unknown_fields)]
122struct ProfileConfigToml {
123 /// Required — if missing, serde reports "missing field `description`", which
124 /// [`discover_profiles`] wraps into a hard error that includes the file path.
125 description: String,
126 #[serde(default)]
127 model: Option<String>,
128 /// `[default]` table — accepts `model` like the top-level config. Equivalent to the
129 /// root-level `model`; setting both is a hard error.
130 #[serde(default)]
131 default: Option<ProfileDefaultToml>,
132 #[serde(default)]
133 prompt: Option<ProfilePromptToml>,
134 #[serde(default)]
135 tools: Option<ProfileToolsToml>,
136 #[serde(default)]
137 sampling: Option<ProfileSamplingToml>,
138 /// When `true`, prefix the subagent's system prompt with the project `AGENTS.md` layer.
139 #[serde(default)]
140 inherit_project_prompt: bool,
141 /// Per-turn LLM-call cap (same keys/semantics as top-level `[turn] request_limit`).
142 #[serde(default)]
143 request_limit: Option<u32>,
144 /// Strategy for `request_limit` (`fixed` / `adaptive` / `unbounded`); `None` ⇒
145 /// `adaptive` when a number is given, matching the top-level default.
146 #[serde(default)]
147 request_limit_mode: Option<RequestLimitMode>,
148 /// The `[hooks]` table: event name → array of hook entries for that event. Its shape
149 /// is identical to the top-level `[hooks]` (reuses `HookEntryRaw`). A profile is a
150 /// single closed truth source and does not support cross-layer `disable` — a
151 /// `disable` key causes a hard fail as if the event name were unknown.
152 #[serde(default)]
153 hooks: BTreeMap<String, toml::Value>,
154}
155
156/// Profile system-prompt source. Mirrors the top-level `[prompt]` shape: either an inline
157/// `text` or a `file` path (folder profiles only). At most one may be set.
158#[derive(Debug, Deserialize)]
159#[serde(deny_unknown_fields)]
160struct ProfilePromptToml {
161 #[serde(default)]
162 file: Option<String>,
163 #[serde(default)]
164 text: Option<String>,
165}
166
167/// Optional `[default]` table, accepted so a profile can be written with the same
168/// `[default] model` key the top-level config uses (in addition to the root-level `model`
169/// shorthand). At most one of the two model sources may be set.
170#[derive(Debug, Default, Deserialize)]
171#[serde(deny_unknown_fields)]
172struct ProfileDefaultToml {
173 #[serde(default)]
174 model: Option<String>,
175}
176
177#[derive(Debug, Deserialize)]
178#[serde(deny_unknown_fields)]
179struct ProfileToolsToml {
180 #[serde(default)]
181 allow: Option<Vec<String>>,
182}
183
184/// Subset of sampling overrides – only expose the scalars currently needed; when
185/// mapping to [`SamplingParams`], merge on top of `default()` and leave other fields
186/// (`thinking` / `stop_sequences`) at their defaults.
187#[derive(Debug, Default, Deserialize)]
188#[serde(deny_unknown_fields)]
189struct ProfileSamplingToml {
190 #[serde(default)]
191 max_tokens: Option<u32>,
192 #[serde(default)]
193 temperature: Option<f32>,
194 #[serde(default)]
195 top_p: Option<f32>,
196 #[serde(default)]
197 top_k: Option<u32>,
198}
199
200impl ProfileSamplingToml {
201 fn into_params(self) -> SamplingParams {
202 SamplingParams {
203 max_tokens: self.max_tokens,
204 temperature: self.temperature,
205 top_p: self.top_p,
206 top_k: self.top_k,
207 ..SamplingParams::default()
208 }
209 }
210}
211
212/// Discover and parse all available profiles.
213///
214/// Scans user-level first, then project-level; for profiles with the same name, the
215/// project-level one overrides the user-level one. If any profile's `config.toml` fails
216/// to parse or its `system.md` is out of bounds or unreadable, it is a hard error (fail
217/// loud, do not silently skip bad profiles). Non-profile items in the directory
218/// (subdirectories without `config.toml`, non-directory entries) are silently skipped.
219///
220/// # Errors
221/// - [`ConfigError::Io`]: reading `config.toml` / `system.md` failed
222/// - [`ConfigError::Invalid`]: `config.toml` parsing failed, missing `description`, or
223/// `system.md` path out of bounds
224pub fn discover_profiles(
225 opts: &LoadConfigOptions,
226) -> Result<BTreeMap<String, ProfileSpec>, ConfigError> {
227 let mut profiles = BTreeMap::new();
228
229 // User layer first, project layer second — later writes override earlier ones, so
230 // project overrides user. `source` is tagged per layer for the profile's `[hooks]`
231 // entries to record provenance (trust gating).
232 if let Some(user_dir) = resolve_user_agents_dir(opts) {
233 scan_agents_dir(&user_dir, ConfigSource::User, &mut profiles)?;
234 }
235 if let Some(repo_root) = find_repo_root(&opts.cwd) {
236 scan_agents_dir(
237 &repo_root.join(PROJECT_AGENTS_RELATIVE),
238 ConfigSource::Project,
239 &mut profiles,
240 )?;
241 }
242
243 Ok(profiles)
244}
245
246/// Scan an `agents/` directory, parse each profile into a [`ProfileSpec`], and write it
247/// into `out` (when names collide across layers, the current layer overwrites the
248/// previous one — the caller passes layers in user→project order to implement "project
249/// overrides user"). If the directory does not exist, this is a no-op.
250///
251/// Two profile forms coexist:
252/// - **Folder**: a subdirectory containing `config.toml`; the name is the directory name,
253/// and the system prompt comes from `[prompt] file` (default `system.md`).
254/// - **Single file**: `<name>.md`; the name is the filename without extension, TOML
255/// frontmatter is between `+++` delimiters, and the system prompt follows.
256///
257/// **Within the same layer**, two profiles with the same name (e.g. `reviewer/` and
258/// `reviewer.md`) cause a hard error — avoid having two sources of truth for one name.
259fn scan_agents_dir(
260 agents_dir: &Path,
261 source: ConfigSource,
262 out: &mut BTreeMap<String, ProfileSpec>,
263) -> Result<(), ConfigError> {
264 let entries = match std::fs::read_dir(agents_dir) {
265 Ok(entries) => entries,
266 // The directory not existing is normal (the user hasn't created any profiles) —
267 // not an error.
268 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
269 Err(err) => {
270 return Err(ConfigError::Io {
271 path: agents_dir.to_path_buf(),
272 source: BoxError::new(err),
273 });
274 }
275 };
276
277 // First collect into a local map for this layer to detect name collisions within it,
278 // then merge the whole layer into `out`.
279 let mut layer: BTreeMap<String, ProfileSpec> = BTreeMap::new();
280 for entry in entries {
281 let entry = entry.map_err(|err| ConfigError::Io {
282 path: agents_dir.to_path_buf(),
283 source: BoxError::new(err),
284 })?;
285 let path = entry.path();
286
287 let parsed = if path.is_dir() {
288 let config_path = path.join("config.toml");
289 if !config_path.is_file() {
290 // Subdirectories without a config.toml are not profiles — skip silently.
291 continue;
292 }
293 let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else {
294 continue;
295 };
296 Some((name, parse_profile_folder(&path, &config_path, source)?))
297 } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
298 let Some(name) = path.file_stem().and_then(|n| n.to_str()).map(str::to_owned) else {
299 continue;
300 };
301 Some((name, parse_profile_file(agents_dir, &path, source)?))
302 } else {
303 // Not a directory or `.md` file — skip.
304 None
305 };
306
307 if let Some((name, mut spec)) = parsed {
308 spec.name = name.clone();
309 if layer.insert(name.clone(), spec).is_some() {
310 return Err(ConfigError::Invalid {
311 path: agents_dir.to_path_buf(),
312 message: format!(
313 "duplicate subagent profile `{name}` in the same layer \
314 (a folder and a `.md` file cannot share a name)"
315 ),
316 });
317 }
318 }
319 }
320
321 out.extend(layer);
322 Ok(())
323}
324
325/// Assembles the parsed frontmatter/config and the already-obtained system prompt text
326/// into a [`ProfileSpec`]. Shared by both the directory-based and single-file variants —
327/// they differ only in where the system prompt text comes from; all other field mappings
328/// are identical. The `name` is filled in uniformly by the caller in `scan_agents_dir`.
329fn spec_from_cfg(
330 dir: &Path,
331 cfg: ProfileConfigToml,
332 system_prompt_text: String,
333 source: ConfigSource,
334 config_path: &Path,
335) -> Result<ProfileSpec, ConfigError> {
336 let tool_allow = cfg
337 .tools
338 .and_then(|t| t.allow)
339 .unwrap_or_else(|| DEFAULT_TOOL_ALLOW.iter().map(|s| s.to_string()).collect());
340 // Converts the `[hooks]` section of a profile into a `HooksConfig`, where each hook
341 // carries the `source` of the profile's layer. Misspelled event names or invalid
342 // handler shapes hard-fail here, with errors pointing to the config file path.
343 let hooks = profile_hooks_from_raw(cfg.hooks, source, config_path)?;
344 let request_limit =
345 resolve_request_limit(config_path, cfg.request_limit, cfg.request_limit_mode)?;
346 // Model may be given at the root (`model = "…"`) or under `[default] model` (matching
347 // the top-level config). Accept either, but not both.
348 let default_model = cfg.default.and_then(|d| d.model);
349 let model = match (cfg.model, default_model) {
350 (Some(_), Some(_)) => {
351 return Err(ConfigError::Invalid {
352 path: config_path.to_path_buf(),
353 message: "set the model either as root `model` or as `[default] model`, not both"
354 .into(),
355 });
356 }
357 (root, default) => root.or(default),
358 };
359 Ok(ProfileSpec {
360 name: String::new(), // Filled in by `scan_agents_dir`
361 dir: dir.to_path_buf(),
362 description: cfg.description,
363 model,
364 system_prompt_text,
365 tool_allow,
366 sampling: cfg.sampling.map(ProfileSamplingToml::into_params),
367 inherit_project_prompt: cfg.inherit_project_prompt,
368 request_limit,
369 hooks,
370 })
371}
372
373/// Parse a folder-based profile: read `config.toml`, then read the system prompt from the
374/// file specified by `[prompt] file` (defaults to `system.md`, resolved relative to the
375/// profile directory with sandbox confinement).
376fn parse_profile_folder(
377 dir: &Path,
378 config_path: &Path,
379 source: ConfigSource,
380) -> Result<ProfileSpec, ConfigError> {
381 let raw = std::fs::read_to_string(config_path).map_err(|err| ConfigError::Io {
382 path: config_path.to_path_buf(),
383 source: BoxError::new(err),
384 })?;
385 let cfg: ProfileConfigToml = toml::from_str(&raw).map_err(|err| ConfigError::Invalid {
386 path: config_path.to_path_buf(),
387 message: err.to_string(),
388 })?;
389
390 // System prompt source: inline `[prompt] text`, or `[prompt] file` (default
391 // `system.md`). At most one may be set; `text` wins when present, `file` is read from
392 // disk otherwise.
393 let (inline_text, prompt_file) = match cfg.prompt.as_ref() {
394 Some(p) => {
395 if p.text.is_some() && p.file.is_some() {
396 return Err(ConfigError::Invalid {
397 path: config_path.to_path_buf(),
398 message: "set `[prompt] text` or `[prompt] file`, not both".into(),
399 });
400 }
401 (p.text.clone(), p.file.clone())
402 }
403 None => (None, None),
404 };
405 let system_prompt_text = if let Some(text) = inline_text {
406 text
407 } else {
408 let prompt_file = prompt_file.unwrap_or_else(|| DEFAULT_PROMPT_FILE.to_string());
409 let prompt_path = resolve_workspace_path(dir, Path::new(&prompt_file)).map_err(|err| {
410 ConfigError::Invalid {
411 path: config_path.to_path_buf(),
412 message: format!("invalid `prompt.file` `{prompt_file}`: {err}"),
413 }
414 })?;
415 std::fs::read_to_string(&prompt_path).map_err(|err| ConfigError::Io {
416 path: prompt_path.clone(),
417 source: BoxError::new(err),
418 })?
419 };
420
421 spec_from_cfg(dir, cfg, system_prompt_text, source, config_path)
422}
423
424/// Parse a single-file profile: `<name>.md` with frontmatter (`+++` TOML or `---` YAML)
425/// followed by the system prompt body. `dir` is the `agents/` directory containing the
426/// `.md` file (a single-file profile has no extra resource files, so `[prompt] file` is
427/// meaningless and causes a conflict if specified).
428fn parse_profile_file(
429 dir: &Path,
430 file_path: &Path,
431 source: ConfigSource,
432) -> Result<ProfileSpec, ConfigError> {
433 let raw = std::fs::read_to_string(file_path).map_err(|err| ConfigError::Io {
434 path: file_path.to_path_buf(),
435 source: BoxError::new(err),
436 })?;
437 let (kind, frontmatter, body) =
438 split_frontmatter(&raw).ok_or_else(|| ConfigError::Invalid {
439 path: file_path.to_path_buf(),
440 message: "single-file profile must start with frontmatter delimited by `+++` (TOML) \
441 or `---` (YAML)"
442 .into(),
443 })?;
444
445 let cfg: ProfileConfigToml =
446 parse_frontmatter(kind, frontmatter).map_err(|message| ConfigError::Invalid {
447 path: file_path.to_path_buf(),
448 message,
449 })?;
450
451 if cfg.prompt.is_some() {
452 return Err(ConfigError::Invalid {
453 path: file_path.to_path_buf(),
454 message: "single-file profile takes its system prompt from the body after the \
455 frontmatter; remove the `[prompt]` table"
456 .into(),
457 });
458 }
459 spec_from_cfg(dir, cfg, body.to_string(), source, file_path)
460}
461
462/// Parse the user-level `agents/` directory. Uses the same priority order as
463/// [`crate::loader`]'s `resolve_user_config_path`, but returns `None` when not found (if
464/// neither XDG nor HOME is set, the user-level profile is simply absent, unlike the main
465/// config which would hard error).
466fn resolve_user_agents_dir(opts: &LoadConfigOptions) -> Option<PathBuf> {
467 // `--local`: ignore the user-level agents directory.
468 if opts.local {
469 return None;
470 }
471 if let Some(xdg) = &opts.xdg_config_home {
472 return Some(xdg.join(USER_AGENTS_RELATIVE));
473 }
474 if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
475 return Some(PathBuf::from(xdg).join(USER_AGENTS_RELATIVE));
476 }
477 if let Some(home) = &opts.home_dir {
478 return Some(home.join(".config/defect/agents"));
479 }
480 if let Ok(home) = env::var("HOME") {
481 return Some(PathBuf::from(home).join(".config/defect/agents"));
482 }
483 None
484}
485
486#[cfg(test)]
487mod tests;