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.
189 lines
6.8 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|
|
}
|