//! Named roles: the system prompt a subagent runs under, chosen by name at //! dispatch (`start(role: "reviewer")` loads `reviewer.md`). //! //! One markdown file per role in one convention directory //! ([`crate::paths::subagent_roles_dir`]), because a named set can be //! listed: enumerability is the reason for the shape, not typing comfort. //! //! **A named role that is not there is an error, never a fallback.** No //! agent ships roles yet, so "named but missing" is the ordinary first-run //! state, and quietly spawning without the file would hand a subagent a //! prompt missing every clause the role was there to carry. See //! `docs/tools/subagent.md`. use std::path::{Path, PathBuf}; /// Extension every role file carries. Part of the naming contract: the /// `role` argument is the filename without it. const ROLE_EXT: &str = "md"; /// Load the role named `role` for this agent, from /// [`crate::paths::subagent_roles_dir`]. /// /// # Errors /// /// An unusable name, a role with no file, or a file that reads empty — see /// [`load_from`], which this is the ambient-path wrapper for. pub fn load(role: &str) -> anyhow::Result { load_from(&crate::paths::subagent_roles_dir(), role) } /// Load `role` out of `dir`, returning the file's text. /// /// # Errors /// /// Three refusals, all of them loud on purpose, and all of them before any /// subagent is spawned: /// /// - a name that is not a plain identifier (so `../` never reaches the /// filesystem, and the argument stays one path segment by construction), /// - a name with no file in `dir` — the message names what the directory /// does hold, which is what the one-directory-of-named-files shape buys, /// - a file that is there but carries nothing, which would put the same /// clause-free prompt in front of claude as a missing one. pub fn load_from(dir: &Path, role: &str) -> anyhow::Result { hive_types::Ident::parse(role) .map_err(|e| anyhow::anyhow!("invalid role name {role:?}: {e}"))?; let path = role_path(dir, role); let body = std::fs::read_to_string(&path).map_err(|e| { anyhow::anyhow!( "no role {role:?} for this agent: reading {} failed: {e}. Roles this agent has: {}", path.display(), available(dir) ) })?; if body.trim().is_empty() { anyhow::bail!( "role {role:?} at {} is empty — refusing to spawn under a role that says nothing", path.display() ); } Ok(body) } /// Where the file for `role` lives. `role` is a validated identifier by the /// time this is called, so the join stays inside `dir`. fn role_path(dir: &Path, role: &str) -> PathBuf { dir.join(format!("{role}.{ROLE_EXT}")) } /// The role names `dir` holds, comma-separated, for an error message to /// offer. Says so plainly when there are none — the expected answer until /// an agent writes its first role file, and far more useful than an empty /// list that reads like a lookup bug. fn available(dir: &Path) -> String { let mut names: Vec = std::fs::read_dir(dir) .into_iter() .flatten() .flatten() .filter_map(|entry| { let path = entry.path(); (path.extension()?.to_str()? == ROLE_EXT) .then(|| path.file_stem()?.to_str().map(ToOwned::to_owned)) .flatten() }) .collect(); if names.is_empty() { return "none — this agent ships no role files".to_owned(); } names.sort(); names.join(", ") } #[cfg(test)] mod tests { use super::*; /// A roles directory of this test's own, under the usual scratch root. /// Each test names its own so a parallel run never shares one. fn roles_dir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("hive-subagent-roles-test-{tag}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("a scratch dir is creatable"); dir } fn write_role(dir: &Path, role: &str, body: &str) { std::fs::write(dir.join(format!("{role}.md")), body).expect("a scratch role is writable"); } #[test] fn a_named_role_loads_its_file_verbatim() { let dir = roles_dir("loads"); write_role( &dir, "reviewer", "review the diff, block on anything unsafe", ); assert_eq!( load_from(&dir, "reviewer").expect("a role that exists loads"), "review the diff, block on anything unsafe" ); } #[test] fn an_unknown_role_is_an_error_naming_what_exists() { // The load-bearing case: with no agent shipping roles yet, a named // role with no file is the normal first-run state. It must refuse, // because the alternative is a subagent running under a prompt // missing every clause the role carried. let dir = roles_dir("unknown"); write_role(&dir, "reviewer", "…"); let err = load_from(&dir, "reviwer") .expect_err("a misspelled role must not fall back to no role at all") .to_string(); assert!( err.contains("reviwer"), "the error names what was asked for: {err}" ); assert!( err.contains("reviewer"), "the error offers the roles that do exist: {err}" ); } #[test] fn an_empty_roles_directory_says_so() { let dir = roles_dir("empty-dir"); let err = load_from(&dir, "reviewer") .expect_err("no file, no role") .to_string(); assert!( err.contains("no role files"), "the first-run state reads as itself, not as a lookup bug: {err}" ); } #[test] fn a_role_directory_that_does_not_exist_is_the_same_refusal() { let dir = roles_dir("absent").join("not-created"); let err = load_from(&dir, "reviewer") .expect_err("an absent directory is an absent role") .to_string(); assert!(err.contains("reviewer"), "{err}"); } #[test] fn an_empty_role_file_is_refused_like_a_missing_one() { let dir = roles_dir("empty-file"); write_role(&dir, "hollow", " \n\n"); let err = load_from(&dir, "hollow") .expect_err("an empty role is no role") .to_string(); assert!(err.contains("empty"), "{err}"); } #[test] fn a_role_name_that_is_not_an_identifier_never_reaches_the_filesystem() { let dir = roles_dir("traversal"); for bad in ["../secrets", "reviewer.md", "Reviewer", "a/b", ""] { let err = load_from(&dir, bad) .expect_err("only plain identifiers name a role") .to_string(); assert!( err.contains("invalid role name"), "{bad:?} must be refused as a name, before any read: {err}" ); } } }