189 lines
6.9 KiB
Rust
189 lines
6.9 KiB
Rust
//! 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/4-implementation/agent-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<String> {
|
|
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<String> {
|
|
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<String> = 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}"
|
|
);
|
|
}
|
|
}
|
|
}
|