hyperhive/hive-subagent-mcp/src/role.rs
atlas 657875b2fa hive-subagent-mcp: name a role at dispatch, load it as the system prompt
A `start` may now name a role: `role: "reviewer"` loads the spawning
agent's own `subagent_roles/reviewer.md` and renders it, alone, into one
per-session file that `--append-system-prompt-file` points at. The role is
the system prompt; the task is the turn, never the other way round — a
task baked into the system prompt would re-assert itself as an
instruction on every later turn of a continued session, not just the one
it was written for. The task instructions (`prompt_file`) are read and
folded ahead of the turn's own prompt instead, the same channel that
carries them to the subagent without a role.

The argument is optional, so every existing call is unchanged — pinned by
a test that a pre-role payload still deserializes with `role` absent from
the schema's required set, and another that the no-role path reaches
claude with the caller's own file, unrendered, and the trigger untouched.
With a role, one test pins the system-prompt file to the role's text and
nothing of the task, and another pins the task still reaching the
subagent as the turn's prompt.

A role name with no file fails the call, before the session name is even
reserved, and the error lists the roles the directory does hold. No agent
ships roles yet, so named-but-missing is the ordinary first-run state; a
fallback there would spawn a subagent under a prompt missing every clause
the role existed to carry. An empty file and a name that is not a plain
identifier refuse the same way.
2026-09-21 17:21:40 +02:00

189 lines
6.8 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/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}"
);
}
}
}