Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-subagent-mcp/src/role.rs

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}"
);
}
}
}